From 3c89c4358a875b89d1f87faa07edb3df6cb6cb61 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 19 May 2026 14:08:21 -0600 Subject: [PATCH 001/142] 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 065/142] 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 066/142] 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 067/142] 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 068/142] 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 069/142] 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 070/142] 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 071/142] 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 072/142] 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 073/142] 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 074/142] 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 075/142] 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 076/142] 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 077/142] 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 078/142] 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 079/142] 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 } } From 5e3a1b2564fc8c9ac7a54528dfb854f0698f14db Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Fri, 21 Aug 2026 19:46:17 -0600 Subject: [PATCH 080/142] fix(multitool): support consoles without arrow-key input and add a non-interactive mode (review finding 10) Remoting sessions and CI hosts crashed on an unhandled IOException from [Console]::SetCursorPosition before either picker rendered. Those hosts still expose $Host.UI.RawUI, so a null check does not detect them, and ReadKey blocks indefinitely there instead of throwing - guarding only the console calls would have turned a fast crash into a hung session. Test-FinOpsRichConsole probes [Console]::CursorTop and IsInputRedirected, and each of the four prompts now has a numbered line-oriented branch beside the arrow-key one, which is also what a screen reader can follow. The arrow-key path is unchanged in a normal terminal. Adds -Scans, -DataSource and -NonInteractive so automation can supply every answer; -SubscriptionId and -OutputPath already covered two of the four prompts. Scan names match either the function or its menu label, and an unknown name is an error rather than a silent no-op. Self-review fixes folded in: a mistyped subscription number and an ambiguous entry at the A/S prompt both widened the scan to the whole tenant, the console probe cached across invocations, -Scans with GraphOnly ran nothing and reported clean, the numbered picker dropped invalid entries silently, and the Read-Host wrapper could swallow a cancellation. --- .../Invoke-FinOpsMultitool.ps1 | 223 ++++++++++++++++-- .../Public/Start-FinOpsMultitool.ps1 | 38 ++- 2 files changed, 242 insertions(+), 19 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index ca936427f..8bd157664 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -22,11 +22,17 @@ function Invoke-FinOpsMultitool { [CmdletBinding()] param( [string]$SubscriptionId, - [string]$OutputPath + [string]$OutputPath, + [string[]]$Scans, + [ValidateSet('Hub', 'API', 'GraphOnly')] + [string]$DataSource, + [switch]$NonInteractive ) # -- Load modules (always force-reimport to pick up latest changes) ---- $multitoolRoot = $PSScriptRoot + # Re-probe the console every run; the host can differ between invocations. + $script:FinOpsRichConsole = $null $psm1Path = Join-Path $multitoolRoot 'FinOpsMultitool.psm1' if (Test-Path $psm1Path) { Import-Module $psm1Path -Force @@ -104,6 +110,27 @@ function Invoke-FinOpsMultitool { @{ Name = 'MACC Commitment'; Fn = 'Get-MaccCommitment'; Selected = $true; Category = 'Account' } ) + # An explicit -Scans list replaces the default selection. Names match either the + # scan function or its display name, so both the docs and the menu labels work. + if ($Scans -and $Scans.Count -gt 0) { + if ($Scans.Count -eq 1 -and $Scans[0] -match '^(?i)all$') { + foreach ($m in $scanModules) { $m.Selected = $true } + } + else { + foreach ($m in $scanModules) { $m.Selected = $false } + $unknownScans = @() + foreach ($name in $Scans) { + $matched = @($scanModules | Where-Object { $_.Fn -eq $name -or $_.Name -eq $name }) + if ($matched.Count -gt 0) { foreach ($m in $matched) { $m.Selected = $true } } + else { $unknownScans += $name } + } + if ($unknownScans.Count -gt 0) { + Write-Error "Unknown scan name(s): $($unknownScans -join ', '). Valid names: $(($scanModules | ForEach-Object { $_.Fn }) -join ', ')" + return + } + } + } + # -- Permission Requirements per Module -------------------------------- # Maps each function to the Azure RBAC role(s) needed and a human-readable reason $permissionInfo = @{ @@ -139,7 +166,7 @@ function Invoke-FinOpsMultitool { # BANNER # ===================================================================== function Show-Banner { - Clear-Host + try { Clear-Host } catch { } # 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. @@ -191,13 +218,45 @@ function Invoke-FinOpsMultitool { return [math]::Min($Cap, $consoleWidth - 1) } + # Hosts without a usable console (remoting, CI, some editor terminals) still + # expose $Host.UI.RawUI, and there ReadKey blocks forever rather than failing, + # so probe a real console operation instead of testing for the object. + function Test-FinOpsRichConsole { + if ($null -ne $script:FinOpsRichConsole) { return $script:FinOpsRichConsole } + $rich = $true + try { $null = [Console]::CursorTop } catch { $rich = $false } + if ($rich) { + try { if ([Console]::IsInputRedirected) { $rich = $false } } catch { } + } + $script:FinOpsRichConsole = $rich + if (-not $rich) { + Write-Host "" + Write-Host " This console does not support the arrow-key menus. Using numbered prompts." -ForegroundColor DarkGray + } + return $rich + } + + # Read-Host returns an empty string in a host that cannot prompt, which would + # spin a validation loop forever, so every caller needs an attempt ceiling. + function Read-FinOpsAnswer { + param([string]$Prompt) + Write-Host $Prompt -ForegroundColor White -NoNewline + $answer = $null + try { $answer = Read-Host } + catch [System.Management.Automation.PipelineStoppedException] { throw } + catch { $answer = $null } + if ($null -eq $answer) { return '' } + return $answer.Trim() + } + # ===================================================================== # DATA SOURCE PICKER # ===================================================================== function Select-DataSource { param( [string]$TenantId, - [array]$Subscriptions + [array]$Subscriptions, + [string]$Preselected ) Write-Host "" @@ -220,6 +279,21 @@ function Invoke-FinOpsMultitool { catch { } } + if ($Preselected) { + if ($Preselected -eq 'Hub' -and -not $hubStorage) { + Write-Host " No FinOps Hub found in scope. Using the Cost Management API instead." -ForegroundColor Yellow + return @{ Source = 'API'; HubStorage = $null } + } + Write-Host " Data source set by parameter: $Preselected" -ForegroundColor DarkGray + return @{ Source = $Preselected; HubStorage = $hubStorage } + } + + if ($NonInteractive) { + $autoSource = if ($hubStorage) { 'Hub' } else { 'API' } + Write-Host " Non-interactive run. Using $autoSource." -ForegroundColor DarkGray + return @{ Source = $autoSource; HubStorage = $hubStorage } + } + if ($hubStorage) { Write-Host " FinOps Hub detected: " -ForegroundColor Green -NoNewline Write-Host "$($hubStorage.name)" -ForegroundColor White -NoNewline @@ -237,10 +311,10 @@ function Invoke-FinOpsMultitool { Write-Host " - Skip cost modules, run governance/optimization scans only" -ForegroundColor DarkGray Write-Host "" + $attempts = 0 while ($true) { - Write-Host " Select [1/2/3]: " -ForegroundColor White -NoNewline - $choice = Read-Host - switch ($choice.Trim()) { + $choice = Read-FinOpsAnswer ' Select [1/2/3]: ' + switch ($choice) { '1' { # Large-hub heads-up: the [1] Hub choice uses the scalable # Kusto engine when the hub has an ADX/Fabric cluster (auto- @@ -265,15 +339,24 @@ function Invoke-FinOpsMultitool { Write-Host "" Write-Host " Switch to the live Cost Management API instead? " -ForegroundColor White -NoNewline Write-Host "(N = continue with the storage reader)" -ForegroundColor DarkGray - $useApi = Read-Host " Select [Y/N]" - if ($useApi.Trim() -match '^(y|yes)$') { + $useApi = Read-FinOpsAnswer ' Select [Y/N]: ' + if ($useApi -match '^(?i)(y|yes)$') { return @{ Source = 'API'; HubStorage = $hubStorage } } return @{ Source = 'Hub'; HubStorage = $hubStorage } } '2' { return @{ Source = 'API'; HubStorage = $hubStorage } } '3' { return @{ Source = 'GraphOnly'; HubStorage = $hubStorage } } - default { Write-Host " Invalid choice." -ForegroundColor Red } + default { + $attempts++ + # A console that cannot take input returns empty forever, so only give + # up there. A real terminal keeps asking until it gets an answer. + if ($attempts -ge 3 -and -not (Test-FinOpsRichConsole)) { + Write-Host " No valid selection. Using the FinOps Hub." -ForegroundColor Yellow + return @{ Source = 'Hub'; HubStorage = $hubStorage } + } + Write-Host " Invalid choice." -ForegroundColor Red + } } } } @@ -288,13 +371,20 @@ function Invoke-FinOpsMultitool { Write-Host " - Skip cost modules, run governance/optimization scans only" -ForegroundColor DarkGray Write-Host "" + $attempts = 0 while ($true) { - Write-Host " Select [1/2]: " -ForegroundColor White -NoNewline - $choice = Read-Host - switch ($choice.Trim()) { + $choice = Read-FinOpsAnswer ' Select [1/2]: ' + switch ($choice) { '1' { return @{ Source = 'API'; HubStorage = $null } } '2' { return @{ Source = 'GraphOnly'; HubStorage = $null } } - default { Write-Host " Invalid choice." -ForegroundColor Red } + default { + $attempts++ + if ($attempts -ge 3 -and -not (Test-FinOpsRichConsole)) { + Write-Host " No valid selection. Using the Cost Management API." -ForegroundColor Yellow + return @{ Source = 'API'; HubStorage = $null } + } + Write-Host " Invalid choice." -ForegroundColor Red + } } } } @@ -345,7 +435,15 @@ function Invoke-FinOpsMultitool { # -- Tenant picker ------------------------------------------------ $tenants = @(Get-AzTenant -ErrorAction SilentlyContinue) - if ($tenants.Count -gt 1) { + if ($tenants.Count -gt 1 -and ($NonInteractive -or -not (Test-FinOpsRichConsole))) { + # The tenant picker is arrow-key only, so stay in the signed-in tenant + # and let -SubscriptionId reach the others. + $currentTenant = (Get-AzContext -ErrorAction SilentlyContinue).Tenant.Id + Write-Host " Tenant: $currentTenant" -ForegroundColor Green + Write-Host " $($tenants.Count) tenants available. Pass -SubscriptionId to target another one." -ForegroundColor DarkGray + Write-Host "" + } + elseif ($tenants.Count -gt 1) { Write-Host " $($tenants.Count) tenants available:" -ForegroundColor White Write-Host "" @@ -429,18 +527,42 @@ function Invoke-FinOpsMultitool { } # Multi-sub picker + if ($NonInteractive) { + Write-Host " Non-interactive run. Scanning all $($allSubs.Count) subscriptions." -ForegroundColor Green + return $allSubs + } + Write-Host " Found $($allSubs.Count) subscriptions. Select scope:" -ForegroundColor White Write-Host "" Write-Host " [A] All subscriptions" -ForegroundColor White Write-Host " [S] Single subscription (pick from list)" -ForegroundColor White Write-Host "" - $choice = Read-Host " Choice (A/S)" + $choice = Read-FinOpsAnswer ' Choice (A/S): ' - if ($choice -eq 'A' -or $choice -eq 'a') { + if ($choice -match '^(?i)(a|all)$') { Write-Host " Scanning all $($allSubs.Count) subscriptions" -ForegroundColor Green return $allSubs } + if (-not (Test-FinOpsRichConsole)) { + Write-Host "" + for ($i = 0; $i -lt $allSubs.Count; $i++) { + Write-Host (" [{0}] {1}" -f ($i + 1), $allSubs[$i].Name) + } + Write-Host "" + $pick = Read-FinOpsAnswer ' Subscription number (blank = all): ' + if ($pick -eq '') { return $allSubs } + $pickIndex = 0 + if ([int]::TryParse($pick, [ref]$pickIndex) -and $pickIndex -ge 1 -and $pickIndex -le $allSubs.Count) { + Write-Host " Selected: $($allSubs[$pickIndex - 1].Name)" -ForegroundColor Green + return @($allSubs[$pickIndex - 1]) + } + # Cancel rather than fall through to every subscription; a mistyped number + # should not silently widen the scan to the whole tenant. + Write-Host " '$pick' is not one of the listed numbers. Cancelled." -ForegroundColor Yellow + return $null + } + # Arrow-key single subscription picker $cursor = 0 $pageSize = 15 @@ -493,9 +615,56 @@ function Invoke-FinOpsMultitool { # ===================================================================== # SCAN MODULE PICKER (checkbox menu) # ===================================================================== + # Numbered alternative to the checkbox menu for hosts that cannot render it. + function Select-ScanModulesLineMode { + param([array]$Modules) + + Write-Host "" + Write-Host " SELECT SCANS" -ForegroundColor White + Write-Host "" + for ($i = 0; $i -lt $Modules.Count; $i++) { + $mark = if ($Modules[$i].Selected) { 'x' } else { ' ' } + Write-Host (" [{0,2}] [{1}] {2} ({3})" -f ($i + 1), $mark, $Modules[$i].Name, $Modules[$i].Category) + } + Write-Host "" + Write-Host " Enter numbers separated by commas, 'all', or blank to keep the [x] defaults." -ForegroundColor DarkGray + $entry = Read-FinOpsAnswer ' Scans: ' + + if ($entry -eq '') { return $Modules } + if ($entry -match '^(?i)all$') { + foreach ($m in $Modules) { $m.Selected = $true } + return $Modules + } + + $picked = @() + $ignored = @() + foreach ($piece in ($entry -split ',')) { + $parsed = 0 + if ([int]::TryParse($piece.Trim(), [ref]$parsed) -and $parsed -ge 1 -and $parsed -le $Modules.Count) { + $picked += ($parsed - 1) + } + elseif ($piece.Trim() -ne '') { + $ignored += $piece.Trim() + } + } + if ($ignored.Count -gt 0) { + Write-Host " Ignored, not a listed number: $($ignored -join ', ')" -ForegroundColor Yellow + } + if ($picked.Count -eq 0) { + Write-Host " No valid numbers. Keeping the default selection." -ForegroundColor Yellow + return $Modules + } + for ($i = 0; $i -lt $Modules.Count; $i++) { $Modules[$i].Selected = ($i -in $picked) } + return $Modules + } + function Select-ScanModules { param([array]$Modules) + # -Scans, or the defaults, already carry the selection when nothing can prompt. + if ($NonInteractive) { return $Modules } + if (-not (Test-FinOpsRichConsole)) { return (Select-ScanModulesLineMode -Modules $Modules) } + $cursor = 0 $categories = $Modules | ForEach-Object { $_.Category } | Select-Object -Unique @@ -2220,11 +2389,25 @@ function Invoke-FinOpsMultitool { } # -- Export option ------------------------------------------------- + $defaultPath = Join-Path (Get-Location) 'FinOpsResults' if ($ExportPath) { $exportDir = $ExportPath } + elseif ($NonInteractive) { + # Nothing can answer a prompt here, so -OutputPath is the way to export. + $exportDir = $null + } + elseif (-not (Test-FinOpsRichConsole)) { + $wantExport = Read-FinOpsAnswer ' Export results? [y/N]: ' + if ($wantExport -match '^(?i)y') { + $entered = Read-FinOpsAnswer " Path [$defaultPath]: " + $exportDir = if ($entered -eq '') { $defaultPath } else { $entered } + } + else { + $exportDir = $null + } + } else { - $defaultPath = Join-Path (Get-Location) 'FinOpsResults' Write-Host " Export results? [E] Export [Enter] Skip" -ForegroundColor DarkGray $eKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') if ($eKey.Character -eq 'e' -or $eKey.Character -eq 'E') { @@ -2551,7 +2734,7 @@ tr:hover { background: #161b22; } $tenantId = (Get-AzContext).Tenant.Id # Step 2: Pick data source - $dataSource = Select-DataSource -TenantId $tenantId -Subscriptions $subs + $dataSource = Select-DataSource -TenantId $tenantId -Subscriptions $subs -Preselected $DataSource # If "Resource Graph only", disable cost modules $costModuleFns = @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-CostTrend', @@ -2561,6 +2744,10 @@ tr:hover { background: #161b22; } foreach ($mod in $scanModules) { if ($mod.Fn -in $costModuleFns) { $mod.Selected = $false } } + if (-not ($scanModules | Where-Object { $_.Selected })) { + Write-Warning "Every selected scan needs cost data, which the 'Resource Graph only' source excludes. Nothing left to run." + return + } } # Show active data source diff --git a/src/powershell/Public/Start-FinOpsMultitool.ps1 b/src/powershell/Public/Start-FinOpsMultitool.ps1 index 7deb5f37a..6f06f89ce 100644 --- a/src/powershell/Public/Start-FinOpsMultitool.ps1 +++ b/src/powershell/Public/Start-FinOpsMultitool.ps1 @@ -20,6 +20,10 @@ PowerShell 7+ (cross-platform) and requires the Az modules (Az.Accounts, Az.ResourceGraph, Az.Storage) and Reader access on the target scope. + Consoles that cannot drive the arrow-key menus, such as remoting sessions and some + editor terminals, automatically fall back to numbered prompts. Use NonInteractive to + run with no prompts at all. + .PARAMETER SubscriptionId Optional subscription ID to scope the scan to a single subscription. When omitted, the tool discovers all accessible subscriptions. @@ -27,6 +31,22 @@ .PARAMETER OutputPath Optional directory for exported result files. Defaults to the tool's working folder. + .PARAMETER Scans + Optional list of scans to run, replacing the default selection. Accepts either the + scan function name, such as Get-OrphanedResources, or its menu label, such as + 'Orphaned Resources'. Use 'All' to select every scan. An unrecognized name is an error. + + .PARAMETER DataSource + Optional data source, which skips the data source prompt. Hub reads a deployed FinOps + hub, API queries Cost Management directly, and GraphOnly skips the cost scans. Hub + falls back to API when no hub is found in scope. + + .PARAMETER NonInteractive + Runs without prompting, for automation and scheduled jobs. Every choice comes from the + parameters or their defaults: all accessible subscriptions in the current tenant unless + SubscriptionId is set, a detected hub or the Cost Management API unless DataSource is + set, and results are exported only when OutputPath is supplied. + .EXAMPLE Start-FinOpsMultitool @@ -38,6 +58,12 @@ Launches the TUI scoped to a single subscription. + .EXAMPLE + Start-FinOpsMultitool -NonInteractive -Scans Get-OrphanedResources, Get-IdleVMs -OutputPath './results' + + Runs two scans without prompting and writes the CSV output to the results folder, + which is the shape to use from a pipeline or scheduled job. + .LINK https://aka.ms/ftk/Start-FinOpsMultitool #> @@ -50,7 +76,17 @@ function Start-FinOpsMultitool { [string]$SubscriptionId, [Parameter()] - [string]$OutputPath + [string]$OutputPath, + + [Parameter()] + [string[]]$Scans, + + [Parameter()] + [ValidateSet('Hub', 'API', 'GraphOnly')] + [string]$DataSource, + + [Parameter()] + [switch]$NonInteractive ) # Locate the Multitool TUI implementation From ddc72dbdcce62494fa44b8373ed40bf1036adb77 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Fri, 21 Aug 2026 22:15:27 -0600 Subject: [PATCH 081/142] fix(multitool): remove the unreachable write path and correct the docs that described it Splitting the MCP server out left the four remediation functions with no caller. Their tool names - remediate_delete_orphaned_resource, remediate_deallocate_vm, set_cost_allocation_rule - were MCP tool names, and the terminal UI never referenced them, so nothing that ships in this PR could reach them. Removed here and preserved on feature/finops-multitool-mcp, where their caller lives: modules/Remove-OrphanedResource.ps1 modules/Enable-HybridBenefit.ps1 modules/Stop-IdleVm.ps1 modules/Set-CostAllocationRule.ps1 modules/helpers/Confirm-WriteAction.ps1 Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 The docs claimed otherwise in six places, including a README section that was really an MCP server configuration and eleven KPI hints that told users to run MCP tool names. All of them now describe what this PR actually ships: a read-only scanner. Also in this pass - documented -Scans, -DataSource and -NonInteractive on the cmdlet reference and the terminal fallback behavior; corrected three stale ms.date values and a CSV/JSON export claim, since JSON was never an export format; noted that realized savings are an estimate rather than a measurement; and fixed capitalization, dash, and fenced-code-language lint. Validation: 1884 passed / 0 failed, down 22 for the removed write-safety suite. Zero net-new PSScriptAnalyzer findings excluding Write-Host. Markdown lint clean. --- .ftk-scope-test.ps1 | 38 +++ docs-mslearn/toolkit/changelog.md | 3 +- .../toolkit/finops-toolkit-overview.md | 2 +- .../multitool/finops-multitool-overview.md | 6 +- .../multitool/finops-multitool-commands.md | 16 +- .../multitool/start-finopsmultitool.md | 45 ++- .../toolkit/powershell/powershell-commands.md | 2 +- docs/multitool.md | 8 +- .../FinOpsMultitool/FinOpsMultitool.psm1 | 5 - .../Invoke-FinOpsMultitool.ps1 | 7 +- .../Private/FinOpsMultitool/README.md | 68 +--- .../FinOpsMultitool/kpi/kpi-catalog.json | 22 +- .../modules/Enable-HybridBenefit.ps1 | 179 ---------- .../modules/Remove-OrphanedResource.ps1 | 284 ---------------- .../modules/Set-CostAllocationRule.ps1 | 319 ------------------ .../FinOpsMultitool/modules/Stop-IdleVm.ps1 | 157 --------- .../modules/helpers/Confirm-WriteAction.ps1 | 300 ---------------- .../FinOpsMultitool.WriteSafety.Tests.ps1 | 218 ------------ .../Unit/Start-FinOpsMultitool.Tests.ps1 | 2 +- .../agent-skills/finops-multitool/SKILL.md | 12 +- .../references/commitments.md | 2 + 21 files changed, 129 insertions(+), 1566 deletions(-) create mode 100644 .ftk-scope-test.ps1 delete mode 100644 src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 delete mode 100644 src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 delete mode 100644 src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 delete mode 100644 src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 delete mode 100644 src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1 delete mode 100644 src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 diff --git a/.ftk-scope-test.ps1 b/.ftk-scope-test.ps1 new file mode 100644 index 000000000..9ca928555 --- /dev/null +++ b/.ftk-scope-test.ps1 @@ -0,0 +1,38 @@ +#Requires -Version 5.1 +# Proves a switch parameter resolves inside nested functions, which is how the +# multitool's pickers read -NonInteractive. + +function Outer { + [CmdletBinding()] + param( + [switch]$NonInteractive, + [string[]]$Scans + ) + + function Inner-ReadsSwitch { + if ($NonInteractive) { return 'NONINTERACTIVE' } + return 'interactive' + } + + function Inner-ReadsArray { + if ($Scans -and $Scans.Count -gt 0) { return "scans=$($Scans -join '+')" } + return 'no-scans' + } + + [PSCustomObject]@{ + Switch = Inner-ReadsSwitch + Array = Inner-ReadsArray + } +} + +Write-Host '-- default --' +Outer | Format-List + +Write-Host '-- with -NonInteractive and -Scans --' +Outer -NonInteractive -Scans 'A', 'B' | Format-List + +$a = Outer +$b = Outer -NonInteractive -Scans 'A', 'B' +$ok = ($a.Switch -eq 'interactive') -and ($b.Switch -eq 'NONINTERACTIVE') -and +($a.Array -eq 'no-scans') -and ($b.Array -eq 'scans=A+B') +Write-Host ("RESULT: {0}" -f $(if ($ok) { 'PASS - nested functions see the parent parameters' } else { 'FAIL - parameters do NOT propagate' })) -ForegroundColor $(if ($ok) { 'Green' } else { 'Red' }) diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index d621c4c5d..ec3d1bb6e 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: 08/19/2026 +ms.date: 08/21/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -39,6 +39,7 @@ The following section lists features and enhancements that are currently in deve - 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. - 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 non-interactive mode so the same scans run from a pipeline or a scheduled job. Consoles that can't render the arrow-key menus, such as PowerShell remoting sessions, fall back to numbered prompts. ### Bicep Registry module pending updates diff --git a/docs-mslearn/toolkit/finops-toolkit-overview.md b/docs-mslearn/toolkit/finops-toolkit-overview.md index f69dd8e40..aab9a4593 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: 08/19/2026 +ms.date: 08/21/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit diff --git a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md index 03e7edbad..d0dbe2d86 100644 --- a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -3,7 +3,7 @@ title: FinOps multitool overview 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/19/2026 +ms.date: 08/21/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit @@ -19,13 +19,13 @@ FinOps multitool scans an Azure environment for cost optimization, governance, a 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. +- **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. Consoles that can't render the arrow-key menus fall back to numbered prompts, and a non-interactive mode runs the same scans from a pipeline or a scheduled job. - **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. -- **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. +- **Read-only**
Every scan reads your environment and reports what it finds. The multitool never creates, changes, or deletes a resource, so you can run it against production and hand it to anyone with Reader access. ## Benefits diff --git a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md index ae8b011a1..5617faa1c 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: 08/19/2026 +ms.date: 08/21/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -15,10 +15,12 @@ ms.reviewer: micflan 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 one 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). It surfaces 26 of the 30 scans. -- **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. +- **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. + +The terminal UI prompts for each choice by default. Consoles that can't render the arrow-key menus, such as PowerShell remoting sessions, fall back to numbered prompts. To run the tool from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters.
@@ -30,7 +32,7 @@ The Multitool delivers one scan engine through two interfaces: ## Scan coverage -The Multitool includes 30 scan modules across the following categories: +The multitool includes 30 scan modules across the following categories: - **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. @@ -50,8 +52,8 @@ Analysis scans are read-only. Most need Reader or Cost Management Reader access. When a [FinOps hub](../../hubs/finops-hubs-overview.md) is present, cost scans read from the hub and choose the path automatically: -- **Kusto database (recommended for large environments)** – When the hub has an Azure Data Explorer or Microsoft Fabric cluster, the Multitool discovers it through Azure Resource Graph and pushes aggregation into the engine, returning only summarized results. This scales to large datasets without loading raw cost rows into PowerShell. To query a local hub on your own hardware, set the `FINOPS_HUB_KUSTO_URI` environment variable to a local Kusto endpoint (optionally set `FINOPS_HUB_KUSTO_DB`, which defaults to `Hub`). -- **Storage reader (small-dataset fallback)** – When no Kusto cluster is reachable, the Multitool reads the hub's storage export and aggregates in PowerShell. Use this for smaller datasets. +- **Kusto database (recommended for large environments)** – When the hub has an Azure Data Explorer or Microsoft Fabric cluster, the multitool discovers it through Azure Resource Graph and pushes aggregation into the engine, returning only summarized results. This scales to large datasets without loading raw cost rows into PowerShell. To query a local hub on your own hardware, set the `FINOPS_HUB_KUSTO_URI` environment variable to a local Kusto endpoint (optionally set `FINOPS_HUB_KUSTO_DB`, which defaults to `Hub`). +- **Storage reader (small-dataset fallback)** – When no Kusto cluster is reachable, the multitool reads the hub's storage export and aggregates in PowerShell. Use this for smaller datasets. If no hub is available, cost scans use the live Cost Management API. @@ -61,7 +63,7 @@ If no hub is available, cost scans use the live Cost Management API. 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. -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. +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, and so is the terminal UI. Both report what they find and recommend a change; applying it stays with you.
diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md index d55b5131a..242af7936 100644 --- a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -3,7 +3,7 @@ title: Start-FinOpsMultitool command 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 +ms.date: 08/21/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -15,10 +15,12 @@ ms.reviewer: micflan 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. 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. +Results are rendered in the terminal. When you choose to export, the tool writes a CSV file per scan module, a `FinOpsReport.html` summary, and a `ScanSummary.txt` file. The scan modules are read-only. 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. +The tool prompts for each choice by default. To run it from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters. +
## Syntax @@ -27,6 +29,9 @@ The command runs on PowerShell 5.1 or later on Windows, and PowerShell 7 or late Start-FinOpsMultitool ` [-SubscriptionId ] ` [-OutputPath ] ` + [-Scans ] ` + [-DataSource ] ` + [-NonInteractive] ` [] ``` @@ -34,10 +39,13 @@ Start-FinOpsMultitool ` ## Parameters -| Name | Description | -| ----------------- | -------------------------------------------------------------------------------------------------------------- | -| `‑SubscriptionId` | Optional. Scopes the scan to a single subscription. When omitted, all accessible subscriptions are discovered. | -| `‑OutputPath` | Optional. Directory for exported result files. Defaults to the tool's working folder. | +| Name | Description | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `‑SubscriptionId` | Optional. Scopes the scan to a single subscription. When omitted, all accessible subscriptions are discovered. | +| `‑OutputPath` | Optional. Directory for exported result files. Defaults to the tool's working folder. | +| `‑Scans` | Optional. Runs the specified scans instead of the default selection. Accepts a scan command name, such as `Get-OrphanedResources`, or its menu label, such as `Orphaned Resources`. Use `All` to select every scan. An unrecognized name returns an error. | +| `‑DataSource` | Optional. Sets the data source and skips the data source prompt. Valid values are `Hub`, `API`, and `GraphOnly`. `Hub` falls back to `API` when no FinOps hub is found in scope. | +| `‑NonInteractive` | Optional. Runs without prompting. Every choice comes from the parameters or their defaults, and results are exported only when you set `-OutputPath`. |
@@ -45,7 +53,7 @@ Start-FinOpsMultitool ` The following examples demonstrate how to use the Start-FinOpsMultitool command. -### Launch the Multitool +### Launch the multitool ```powershell Start-FinOpsMultitool @@ -69,11 +77,32 @@ Start-FinOpsMultitool -OutputPath './finops-results' Launches the terminal UI and writes exported result files to the specified directory. +### Run specific scans without prompting + +```powershell +Start-FinOpsMultitool ` + -NonInteractive ` + -SubscriptionId '00000000-0000-0000-0000-000000000000' ` + -Scans Get-OrphanedResources, Get-IdleVMs ` + -DataSource API ` + -OutputPath './finops-results' +``` + +Runs two scans against one subscription without prompting and writes the results to the specified directory. Use this form from a pipeline or a scheduled job. + +
+ +## Terminal support + +The tool uses arrow-key menus when the console supports them. Consoles that can't drive those menus, such as PowerShell remoting sessions and some editor terminals, automatically fall back to numbered prompts that read one line at a time. Both paths run the same scans and produce the same results. + +Use `-NonInteractive` when nothing can answer a prompt, such as a build agent. +
## 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).
diff --git a/docs-mslearn/toolkit/powershell/powershell-commands.md b/docs-mslearn/toolkit/powershell/powershell-commands.md index 37507a0c3..cdf9ff0c2 100644 --- a/docs-mslearn/toolkit/powershell/powershell-commands.md +++ b/docs-mslearn/toolkit/powershell/powershell-commands.md @@ -3,7 +3,7 @@ title: FinOps toolkit PowerShell module description: Automate and scale your FinOps efforts using the FinOps toolkit PowerShell module, which includes commands to manage FinOps solutions. author: flanakin ms.author: micflan -ms.date: 07/02/2026 +ms.date: 08/21/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit diff --git a/docs/multitool.md b/docs/multitool.md index 5f80fe26c..f3a08409b 100644 --- a/docs/multitool.md +++ b/docs/multitool.md @@ -29,7 +29,7 @@ The FinOps multitool scans an Azure environment for cost optimization, governanc -## Explore the Multitool +## Explore the multitool @@ -86,7 +86,7 @@ The FinOps multitool scans an Azure environment for cost optimization, governanc
- +
You're now ready to scan. Run the command, then choose the subscriptions and modules to scan.
diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 index b50888d54..c4c1d425a 100644 --- a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 +++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 @@ -39,7 +39,6 @@ $helpersPath = Join-Path $PSScriptRoot 'modules\helpers' . (Join-Path $helpersPath 'Get-PlainAccessToken.ps1') . (Join-Path $helpersPath 'Invoke-AzRestMethodWithRetry.ps1') . (Join-Path $helpersPath 'Search-AzGraphSafe.ps1') -. (Join-Path $helpersPath 'Confirm-WriteAction.ps1') . (Join-Path $helpersPath 'MgCostScope.ps1') . (Join-Path $helpersPath 'Read-FinOpsHubData.ps1') . (Join-Path $helpersPath 'Invoke-FOHubKustoQuery.ps1') @@ -69,9 +68,6 @@ $modulePath = Join-Path $PSScriptRoot 'modules' . (Join-Path $modulePath 'Get-BillingStructure.ps1') . (Join-Path $modulePath 'Get-CommitmentUtilization.ps1') . (Join-Path $modulePath 'Get-OrphanedResources.ps1') -. (Join-Path $modulePath 'Remove-OrphanedResource.ps1') -. (Join-Path $modulePath 'Enable-HybridBenefit.ps1') -. (Join-Path $modulePath 'Stop-IdleVm.ps1') . (Join-Path $modulePath 'Get-BudgetStatus.ps1') . (Join-Path $modulePath 'Get-MaccCommitment.ps1') . (Join-Path $modulePath 'Get-AnomalyAlerts.ps1') @@ -84,7 +80,6 @@ $modulePath = Join-Path $PSScriptRoot 'modules' . (Join-Path $modulePath 'Get-UnitEconomics.ps1') . (Join-Path $modulePath 'Get-VmCostBreakdown.ps1') . (Join-Path $modulePath 'Get-SharedCostAllocation.ps1') -. (Join-Path $modulePath 'Set-CostAllocationRule.ps1') . (Join-Path $modulePath 'Get-BillingAccount.ps1') . (Join-Path $modulePath 'Get-UsageProportionalAllocation.ps1') . (Join-Path $modulePath 'Get-AIWorkloadMetrics.ps1') diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 8bd157664..b4808acaf 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -20,6 +20,7 @@ function Invoke-FinOpsMultitool { [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'NonInteractive', Justification = 'Read by the nested picker functions, which PSScriptAnalyzer does not trace into. Verified on PowerShell 5.1 and 7.')] param( [string]$SubscriptionId, [string]$OutputPath, @@ -166,7 +167,7 @@ function Invoke-FinOpsMultitool { # BANNER # ===================================================================== function Show-Banner { - try { Clear-Host } catch { } + try { Clear-Host } catch { Write-Debug "Clear-Host is unavailable in this host: $_" } # Version comes from the toolkit so the TUI and the module cannot drift. # Get-VersionNumber is a sibling private function, absent when this script runs standalone. @@ -226,7 +227,9 @@ function Invoke-FinOpsMultitool { $rich = $true try { $null = [Console]::CursorTop } catch { $rich = $false } if ($rich) { - try { if ([Console]::IsInputRedirected) { $rich = $false } } catch { } + $redirected = $false + try { $redirected = [Console]::IsInputRedirected } catch { $redirected = $false } + if ($redirected) { $rich = $false } } $script:FinOpsRichConsole = $rich if (-not $rich) { diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index b327b8b96..149e76cdd 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -87,7 +87,7 @@ Guidance includes FinOps Foundation best practices, actionable next steps, and l - **Access denied** (403/401) — Shows the exact error, required RBAC role, scope, and API - **No data** — Explains whether the module requires specific resources (e.g., "Returns empty if no budgets are configured") -An optional CSV/JSON export saves to the output path. +Optional exports write to the output path: one CSV file per scan module, a `FinOpsReport.html` summary, and a `ScanSummary.txt` text summary. ## Required Permissions @@ -178,7 +178,7 @@ The scan modules map directly to [FinOps Foundation KPIs](https://www.finops.org > "Which of my resources are running on legacy or retiring SKUs?" -``` +```text Legacy / Retiring Resources — 47 found across 156 subscriptions By category: @@ -195,7 +195,7 @@ Legacy % = 47 ÷ total resources in scope. > "What's my cost per vCPU and per GB of storage this month?" -``` +```text Unit Economics — Month to Date (USD) Compute $128,400 (75.7%) 312 VMs / 1,840 vCPU / 7,360 GB RAM @@ -215,7 +215,7 @@ vCPU and RAM are exact (read from Compute SKU capabilities). Storage GB combines This scan is self-gating: a single Resource Graph query detects whether any AI workloads (Azure OpenAI, AI Services, Machine Learning, AI Search, GPU VMs) exist. Non-AI tenants skip the deep scan entirely, so the scan stays fast. When AI is present, it joins Azure Monitor token metrics to Cost Management spend over the same month-to-date window. -``` +```text AI footprint — OpenAI/AIServices: 3 ML workspaces: 1 AI Search: 2 GPU VMs: 0 Tokens (MTD): 412,800,000 total (288,100,000 in / 124,700,000 out) over 1,240,500 requests AI spend (MTD): USD 3,910.42 | USD 0.0095 /1K tokens | USD 0.00315 /request @@ -233,7 +233,7 @@ Like the cost scans, this honors `dataSource` (`auto` / `hub` / `api`). When a r > "Show my cloud carbon footprint and how it changed month over month." -``` +```text Carbon Emissions — latest available month: 2026-04 (data lags ~2 mo) Total emissions 18,420 kgCO2e @@ -251,7 +251,7 @@ Combined with cost data, `Carbon per Unit of Spend` = total emissions ÷ monthly > "How well are my reservations and savings plans being used?" -``` +```text Commitment Utilization — trailing 30 days Reserved Instances 94.2% utilized ($3,120 unused) @@ -265,7 +265,7 @@ Overall score 91.8% > "How much of my spend is on untagged resources?" -``` +```text Cost by Tag — Month to Date Tagged spend $612,300 (87.4%) @@ -321,57 +321,9 @@ $tagInventory = ConvertTo-TagInventoryFromHub -HubData $hubData $costByTag = ConvertTo-CostByTagFromHub -HubData $hubData -ExistingTags $tagInventory.TagNames ``` -## Write Safety (Remediation Tools) - -The remediation functions are read-only by default. Every write previews first, and enabling writes is a deliberate opt-in via `FINOPS_WRITE_MODE`. The gate lives in the functions themselves, so it applies to any caller — the TUI, a script, or anything that imports the module. - -### Modes — `FINOPS_WRITE_MODE` - -| Mode | Behavior | Use for | -| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | -| `ReadOnly` | **Default.** All write tools are blocked; read scans still work. Set `FINOPS_WRITE_MODE` to `Interactive` or `Enforced` to enable writes. | Locked-down or audit-only deployments (and the default) | -| `Interactive` | `apply=true` runs the change directly. A preview/token is offered but not required. The client (human or AI) is the gate. | Platform-agnostic AI chat — low friction, any client | -| `Enforced` | `apply=true` is **rejected** unless it carries the exact single-use token from that change's own dry-run preview (bound to a SHA-256 fingerprint, expires in 5 min). | Autonomous / unattended agents — server is the gate | - -Every write previews first: call the tool without `apply` to get the exact REST call, the resource evidence, and (in Enforced mode) a `confirmationToken` to pass back with `apply=true`. - -### Guardrails (enforced in every mode) - -These never depend on a well-behaved client. Configure via environment variables: - -| Variable | Effect | Default | -| ----------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| `FINOPS_PROTECTED_TAGS` | Resources carrying any of these tag keys are never written to | `do-not-delete`, `DoNotDelete`, `lock`, `protected` | -| `FINOPS_PROTECTED_RGS` | Resource groups (supports `*` wildcards) that are off-limits | none | -| `FINOPS_PROTECTED_SUBS` | Subscriptions that are off-limits | none | -| `FINOPS_WRITE_MAX_IMPACT` | Block any single write whose estimated monthly $ impact exceeds this cap (`0` = no cap) | `0` | -| `FINOPS_WRITE_MAX_PER_WINDOW` | Max writes allowed per rolling window (blast-radius limit) | unlimited | -| `FINOPS_WRITE_WINDOW_MIN` | Length of that window in minutes | `60` | -| `FINOPS_AUDIT_LOG` | Path for the append-only audit log (every preview / apply / block is recorded as JSON) | `%LOCALAPPDATA%\FinOpsMultitool\finops-multitool-audit.log` | - -### Example — autonomous, locked-down server - -```json -{ - "servers": { - "finops-multitool": { - "type": "stdio", - "command": "pwsh", - "env": { - "FINOPS_WRITE_MODE": "Enforced", - "FINOPS_PROTECTED_RGS": "rg-prod-*,rg-shared", - "FINOPS_WRITE_MAX_PER_WINDOW": "5" - } - } - } -} -``` - -In this configuration an agent must preview each change, pass the matching token back, stay out of protected resource groups, and is capped at five writes per hour — all enforced by the server, not the client. - ## File Structure -``` +```text FinOpsMultitool/ ├── README.md # This file ├── FinOpsMultitool.psm1 # Module loader (dot-sources all scan modules) @@ -384,7 +336,6 @@ FinOpsMultitool/ │ │ ├── Get-PlainAccessToken.ps1 # Token helper │ │ ├── Invoke-AzRestMethodWithRetry.ps1 # REST retry logic │ │ ├── Search-AzGraphSafe.ps1 # ARG query wrapper -│ │ ├── Confirm-WriteAction.ps1 # Write-safety policy gate (modes, guardrails, audit) │ │ └── MgCostScope.ps1 # Management group scope state │ ├── Initialize-Scanner.ps1 │ ├── Get-CostData.ps1 @@ -392,9 +343,6 @@ FinOpsMultitool/ │ ├── Get-TagInventory.ps1 │ ├── Get-CostByTag.ps1 │ ├── Get-OrphanedResources.ps1 -│ ├── Remove-OrphanedResource.ps1 # Write: delete orphaned resource (gated) -│ ├── Enable-HybridBenefit.ps1 # Write: enable Azure Hybrid Benefit (gated) -│ ├── Stop-IdleVm.ps1 # Write: deallocate idle VM (gated) │ ├── Get-IdleVMs.ps1 │ └── ... # One file per scan module ``` diff --git a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json index 115a27e25..c58c01350 100644 --- a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json +++ b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json @@ -13,7 +13,7 @@ "field": "CostPerGb", "unit": "per GB / month", "plainLanguage": "What you pay for each GB of stored data this month.", - "exploreHint": "Run scan_storage_tier_advice to see if moving cold data to Cool or Archive lowers this." + "exploreHint": "Run the Storage Tier Advice scan to see if moving cold data to Cool or Archive lowers this." }, { "id": "hourly-cost-per-cpu-core", @@ -26,7 +26,7 @@ "divideBy": 730, "unit": "per vCPU / hour", "plainLanguage": "Roughly what one CPU core costs you per hour.", - "exploreHint": "Run scan_optimization_advice or scan_idle_vms to find rightsizing that lowers this." + "exploreHint": "Run the Optimization Advice or Idle VMs scan to find rightsizing that lowers this." }, { "id": "effective-avg-compute-cost-per-core", @@ -38,7 +38,7 @@ "field": "CostPerVCpu", "unit": "per vCPU / month", "plainLanguage": "Average monthly cost of one CPU core across your fleet.", - "exploreHint": "Pair with scan_commitment_utilization to amortize unused reservation/savings-plan cost." + "exploreHint": "Pair with the Commitment Utilization scan to amortize unused reservation and savings plan cost." }, { "id": "pct-costs-untagged", @@ -49,7 +49,7 @@ "compute": true, "unit": "%", "plainLanguage": "How much of your spend lands on resources that are not tagged.", - "exploreHint": "Run scan_tag_recommendations to see which CAF tags to backfill and where." + "exploreHint": "Run the Tag Recommendations scan to see which CAF tags to backfill and where." }, { "id": "pct-costs-unallocated", @@ -60,7 +60,7 @@ "compute": true, "unit": "%", "plainLanguage": "Share of your bill you cannot yet assign to an owner.", - "exploreHint": "Backfill allocation tags (Customer/project/CostCenter), then re-run scan_cost_by_tag." + "exploreHint": "Backfill allocation tags (Customer/project/CostCenter), then re-run the Cost by Tag scan." }, { "id": "tagging-policy-compliant", @@ -71,7 +71,7 @@ "compute": true, "unit": "%", "plainLanguage": "How much of your spend is on properly tagged resources.", - "exploreHint": "Run scan_policy_recommendations to deploy tag-enforcement policy." + "exploreHint": "Run the Policy Recommendations scan to deploy tag-enforcement policy." }, { "id": "commitment-utilization-score", @@ -82,7 +82,7 @@ "compute": true, "unit": "%", "plainLanguage": "How much of your reserved/committed capacity you are actually using.", - "exploreHint": "Run scan_reservation_advice to right-size future commitments." + "exploreHint": "Run the Reservation Advice scan to right-size future commitments." }, { "id": "pct-commitment-discount-waste", @@ -115,7 +115,7 @@ "compute": true, "unit": "count / cost", "plainLanguage": "Resources you are paying for that nothing is using. Shown as the orphaned-resource count found by the scan.", - "exploreHint": "Run remediate_delete_orphaned_resource (gated) to clean them up." + "exploreHint": "Confirm nothing depends on them, then delete them in the Azure portal or with Azure CLI." }, { "id": "computational-waste", @@ -126,7 +126,7 @@ "compute": true, "unit": "count / cost", "plainLanguage": "Compute you are running and paying for but barely using. Shown as the share of running VMs flagged idle or underutilized.", - "exploreHint": "Run remediate_deallocate_vm (gated, reversible) on confirmed idle VMs." + "exploreHint": "Deallocate confirmed idle VMs in the Azure portal or with Azure CLI. Deallocating is reversible; the disks keep billing." }, { "id": "percent-storage-frequent-tier", @@ -247,7 +247,7 @@ "compute": false, "unit": "%", "plainLanguage": "Shared platform cost not yet split across its consumers.", - "exploreHint": "Use scan_allocate_shared_cost or scan_usage_allocation to split it." + "exploreHint": "Split it with a shared cost allocation or usage-proportional allocation scan." }, { "id": "allocation-accuracy-index", @@ -269,7 +269,7 @@ "compute": false, "unit": "cost / workload", "plainLanguage": "The true all-in cost of one workload, not just its compute line.", - "exploreHint": "scan_vm_cost_breakdown decomposes a VM into all its billed meters." + "exploreHint": "The VM cost breakdown scan decomposes a VM into all its billed meters." }, { "id": "frequency-of-data-updates", diff --git a/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 deleted file mode 100644 index 8e818e662..000000000 --- a/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 +++ /dev/null @@ -1,179 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -########################################################################### -# ENABLE-HYBRIDBENEFIT.PS1 -# AZURE FINOPS MULTITOOL - Enable Azure Hybrid Benefit on a VM -########################################################################### -# Purpose: Turn on Azure Hybrid Benefit (AHB) for a single VM found by -# scan_ahb_opportunities. AHB applies existing Windows Server / -# SQL licenses to cut compute licensing cost up to ~85%. -# -# Description: -# REVERSIBLE, savings-only write (you can set the license back to None). -# 1. Validates the resource id (Microsoft.Compute/virtualMachines only). -# 2. Reads the VM, detects OS, and picks the right licenseType -# (Windows -> Windows_Server) unless one is passed explicitly. -# 3. No-ops if AHB is already enabled. -# 4. Routes through the configurable write-safety gate (dry-run by -# default; token required only in Enforced mode). -# -# ── Parameters ────────────────────────────────────────────────────── -# ResourceId Full ARM resource ID of the VM -# LicenseType Override: Windows_Server | Windows_Client | -# RHEL_BYOS | SLES_BYOS (auto-detected if omitted) -# Apply Omitted = dry-run preview. Present = PATCH the VM. -# ConfirmationToken Required only in Enforced mode (from the preview) -# -# Usage: Enable-HybridBenefit -ResourceId [-Apply] -########################################################################### - -function Enable-HybridBenefit { - [CmdletBinding()] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes are gated by the explicit -Apply switch and routed through Resolve-WriteDecision (dry-run by default, mode/guardrail/confirmation-token enforcement, and audit logging).')] - param( - [Parameter(Mandatory)] - [string]$ResourceId, - - [Parameter()] - [ValidateSet('Windows_Server', 'Windows_Client', 'RHEL_BYOS', 'SLES_BYOS')] - [string]$LicenseType, - - [Parameter()] - [switch]$Apply, - - [Parameter()] - [string]$ConfirmationToken - ) - - $apiVersion = '2024-07-01' - - if ($ResourceId -notmatch '/providers/Microsoft\.Compute/virtualMachines/(?[^/]+)$') { - return [PSCustomObject]@{ - HasData = $false - Error = 'This tool only enables AHB on Microsoft.Compute/virtualMachines. Pass a VM resource ID.' - } - } - $vmName = $Matches.name - $path = "$ResourceId`?api-version=$apiVersion" - - # ---- Read the VM ---- - $getResp = Invoke-AzRestMethodWithRetry -Path $path -Method 'GET' - $getStatus = if ($getResp) { [int]$getResp.StatusCode } else { 0 } - if ($getStatus -eq 404) { - return [PSCustomObject]@{ HasData = $true; Applied = $false; ResourceId = $ResourceId; Note = 'VM not found.' } - } - if ($getStatus -lt 200 -or $getStatus -ge 300) { - $gErr = $null - if ($getResp -and $getResp.Content) { try { $gErr = ($getResp.Content | ConvertFrom-Json).error.message } catch { $gErr = $getResp.Content } } - return [PSCustomObject]@{ HasData = $false; Error = "Could not read the VM (HTTP $getStatus). $gErr"; ResourceId = $ResourceId } - } - - $vm = $null - try { $vm = $getResp.Content | ConvertFrom-Json -ErrorAction Stop } catch {} - $props = if ($vm) { $vm.properties } else { $null } - $osType = if ($props -and $props.storageProfile -and $props.storageProfile.osDisk) { [string]$props.storageProfile.osDisk.osType } else { '' } - $currentLicense = if ($props -and $props.licenseType) { [string]$props.licenseType } else { 'None' } - $location = if ($vm) { $vm.location } else { $null } - - # ---- Decide the target license type ---- - if (-not $LicenseType) { - if ($osType -ieq 'Windows') { $LicenseType = 'Windows_Server' } - elseif ($osType -ieq 'Linux') { - return [PSCustomObject]@{ - HasData = $false - Error = "VM '$vmName' is Linux. AHB for Linux requires the exact distro license (RHEL_BYOS or SLES_BYOS). Re-run with an explicit licenseType only if this VM is RHEL/SLES BYOS-eligible." - ResourceId = $ResourceId - CurrentLicense = $currentLicense - } - } - else { - return [PSCustomObject]@{ HasData = $false; Error = "Could not determine OS type for '$vmName'. Pass licenseType explicitly."; ResourceId = $ResourceId } - } - } - - # ---- Already enabled? No-op. ---- - if ($currentLicense -ieq $LicenseType) { - return [PSCustomObject]@{ - HasData = $true - Mode = 'NoOp' - Applied = $false - Note = "AHB already enabled on '$vmName' (licenseType=$currentLicense). Nothing to do." - ResourceId = $ResourceId - ResourceName = $vmName - } - } - - $subId = if ($ResourceId -match '/subscriptions/([^/]+)/') { $Matches[1] } else { $null } - $rg = if ($ResourceId -match '/resourceGroups/([^/]+)/') { $Matches[1] } else { $null } - $tagHash = @{} - if ($vm -and $vm.tags) { foreach ($t in $vm.tags.PSObject.Properties) { $tagHash[$t.Name] = $t.Value } } - - # ---- Write-safety gate (reversible, savings-only -> impact 0) ---- - $decision = Resolve-WriteDecision -ToolName 'remediate_enable_hybrid_benefit' -Operation 'EnableAHB' ` - -ResourceId $ResourceId -SubscriptionId $subId -ResourceGroup $rg -Tags $tagHash ` - -EstimatedMonthlyImpact 0 -Reversible $true -Apply:$Apply -ConfirmationToken $ConfirmationToken ` - -FingerprintExtra @{ licenseType = $LicenseType } - - if ($decision.Decision -eq 'Blocked') { - return [PSCustomObject]@{ - HasData = $false; Mode = 'Blocked'; Applied = $false; Error = $decision.Reason - GuardrailViolations = @($decision.GuardrailViolations); WriteMode = $decision.Mode - ResourceId = $ResourceId; ResourceName = $vmName - } - } - - $bodyJson = @{ properties = @{ licenseType = $LicenseType } } | ConvertTo-Json -Depth 5 - - if ($decision.Decision -eq 'Preview') { - return [PSCustomObject]@{ - HasData = $true - Mode = 'DryRun' - Applied = $false - WriteMode = $decision.Mode - Warning = "PREVIEW ONLY - the VM was not changed. This is REVERSIBLE and reduces licensing cost. $($decision.Reason)" - Method = 'PATCH' - Uri = "$(Get-FinOpsArmEndpoint)$path" - ResourceId = $ResourceId - ResourceName = $vmName - Location = $location - OsType = $osType - CurrentLicense = $currentLicense - NewLicense = $LicenseType - RequestBody = ($bodyJson | ConvertFrom-Json) - ConfirmationToken = $decision.ConfirmationToken - RequiresToken = $decision.RequiresToken - NextStep = if ($decision.RequiresToken) { - 'Enforced mode: re-run with apply=true AND confirmationToken=.' - } - else { 'Re-run Enable-HybridBenefit with -Apply to enable AHB.' } - } - } - - # ---- Proceed: PATCH the VM ---- - Write-Host " Enabling Azure Hybrid Benefit ($LicenseType) on '$vmName'..." -ForegroundColor Yellow - $resp = Invoke-AzRestMethodWithRetry -Path $path -Method 'PATCH' -Payload $bodyJson - $status = if ($resp) { [int]$resp.StatusCode } else { 0 } - $ok = $status -in @(200, 201, 202) - $errMsg = $null - if (-not $ok) { - $errMsg = "PATCH returned $status." - if ($resp -and $resp.Content) { try { $eb = ($resp.Content | ConvertFrom-Json); if ($eb.error.message) { $errMsg += " $($eb.error.message)" } } catch {} } - } - - return [PSCustomObject]@{ - HasData = $true - Mode = 'Apply' - Applied = $ok - WriteMode = $decision.Mode - StatusCode = $status - Warning = if ($ok) { "AHB enabled ($LicenseType). Reversible - set licenseType back to None to undo." } else { $null } - Error = $errMsg - Method = 'PATCH' - Uri = "$(Get-FinOpsArmEndpoint)$path" - ResourceId = $ResourceId - ResourceName = $vmName - CurrentLicense = $currentLicense - NewLicense = $LicenseType - } -} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 deleted file mode 100644 index bbfbf4b28..000000000 --- a/src/powershell/Private/FinOpsMultitool/modules/Remove-OrphanedResource.ps1 +++ /dev/null @@ -1,284 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -########################################################################### -# REMOVE-ORPHANEDRESOURCE.PS1 -# AZURE FINOPS MULTITOOL - Safe Deletion of Orphaned Resources -########################################################################### -# Purpose: Delete a single orphaned Azure resource (unattached managed -# disk, dangling public IP, or unattached NIC) -# discovered by scan_orphaned_resources. -# -# Description: -# Safe-by-default remediation, matching the Set-CostAllocationRule pattern: -# 1. Validates the resource id and refuses any type not on the allow-list -# (only orphan-eligible types can EVER be deleted by this tool). -# 2. Re-reads the resource and re-verifies it is genuinely orphaned at -# execution time (defense in depth - never delete on a stale scan). -# 3. DRY-RUN by default: returns the exact DELETE call + orphan evidence -# and mutates nothing. Only deletes when -Apply is explicitly passed. -# -# ── Parameters ────────────────────────────────────────────────────── -# ResourceId Full ARM resource ID of the orphan to delete -# Apply Safety gate. Omitted = dry-run preview (no write). -# Present = perform the DELETE after user approval. -# -# Prerequisites: -# - Contributor (or a delete-capable role) on the target resource scope -# -# Usage: Remove-OrphanedResource -ResourceId [-Apply] -########################################################################### - -function Remove-OrphanedResource { - [CmdletBinding()] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes are gated by the explicit -Apply switch and routed through Resolve-WriteDecision (dry-run by default, mode/guardrail/confirmation-token enforcement, and audit logging).')] - param( - [Parameter(Mandatory)] - [string]$ResourceId, - - [Parameter()] - [switch]$Apply, - - [Parameter()] - [string]$ConfirmationToken - ) - - # ----------------------------------------------------------------- - # Allow-list: ONLY these types may ever be deleted by this tool. - # inUseProps = the resource 'properties' fields that, when populated, - # mean the resource is STILL IN USE (so we must refuse to delete). - # 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' } - } - - # ---- Validate the resource id ---- - if ($ResourceId -notmatch '^/subscriptions/[0-9a-fA-F-]{36}/resourceGroups/[^/]+/providers/') { - return [PSCustomObject]@{ - HasData = $false - Error = "Invalid resourceId. Expected a full ARM resource ID like /subscriptions/{guid}/resourceGroups/{rg}/providers/{ns}/{type}/{name}." - } - } - - if ($ResourceId -notmatch '/providers/(?Microsoft\.[^/]+)/(?[^/]+)/(?[^/]+)$') { - return [PSCustomObject]@{ - HasData = $false - Error = "Could not parse a top-level resource type from resourceId. This tool only deletes top-level orphaned resources (disks, public IPs, NICs)." - } - } - $fullType = "$($Matches.ns)/$($Matches.type)" - $resName = $Matches.name - - if (-not $allowList.ContainsKey($fullType)) { - return [PSCustomObject]@{ - HasData = $false - Error = "Refusing to delete '$fullType'. This tool only deletes orphan-eligible types: $($allowList.Keys -join ', '). Use the appropriate scan/remediation path for other resources." - ResourceId = $ResourceId - ResourceType = $fullType - } - } - - $cfg = $allowList[$fullType] - $apiVersion = $cfg.api - $path = "$ResourceId`?api-version=$apiVersion" - - # ---- Re-read the resource (confirm it exists + is still orphaned) ---- - $getResp = Invoke-AzRestMethodWithRetry -Path $path -Method 'GET' - $getStatus = if ($getResp) { [int]$getResp.StatusCode } else { 0 } - - if ($getStatus -eq 404) { - return [PSCustomObject]@{ - HasData = $true - Mode = if ($Apply) { 'Apply' } else { 'DryRun' } - Applied = $false - ResourceId = $ResourceId - ResourceType = $fullType - Note = 'Resource not found (already deleted or never existed). Nothing to do.' - } - } - if ($getStatus -lt 200 -or $getStatus -ge 300) { - $gErr = $null - if ($getResp -and $getResp.Content) { - try { $gErr = ($getResp.Content | ConvertFrom-Json -ErrorAction Stop).error.message } catch { $gErr = $getResp.Content } - } - return [PSCustomObject]@{ - HasData = $false - Error = "Could not read the resource before deleting (HTTP $getStatus). $gErr" - ResourceId = $ResourceId - ResourceType = $fullType - } - } - - $resObj = $null - try { $resObj = $getResp.Content | ConvertFrom-Json -ErrorAction Stop } catch {} - - # Without a parsed body the orphan check below cannot evaluate anything and - # would pass by default, so refuse rather than delete on unverified state. - if (-not $resObj) { - return [PSCustomObject]@{ - HasData = $false - Mode = 'Blocked' - Applied = $false - Error = "Refusing to delete: the response for '$resName' could not be parsed, so it cannot be confirmed as orphaned." - ResourceId = $ResourceId - ResourceType = $fullType - } - } - - $props = $resObj.properties - $location = $resObj.location - - # ---- Defense in depth: verify the resource is genuinely orphaned ---- - $inUseBy = @() - foreach ($p in $cfg.inUseProps) { - if ($p -eq 'diskState') { - # A disk is in use unless its diskState is Unattached/Reserved. - if ($props -and $props.diskState -and $props.diskState -notin @('Unattached', 'Reserved')) { - $inUseBy += "diskState=$($props.diskState)" - } - continue - } - $val = if ($props) { $props.$p } else { $null } - $populated = $false - if ($null -ne $val) { - if ($val -is [string]) { $populated = -not [string]::IsNullOrWhiteSpace($val) } - elseif ($val.PSObject -and $val.PSObject.Properties.Name -contains 'id') { $populated = [bool]$val.id } - else { $populated = $true } - } - if ($populated) { $inUseBy += $p } - } - - if ($inUseBy.Count -gt 0) { - return [PSCustomObject]@{ - HasData = $false - Mode = 'Blocked' - Applied = $false - Error = "Refusing to delete: '$resName' appears to be IN USE ($($inUseBy -join ', ')). It is not orphaned. Re-run the orphaned resources scan to refresh, or detach it first." - ResourceId = $ResourceId - ResourceType = $fullType - Location = $location - InUseBy = $inUseBy - } - } - - # ---- Build human-readable evidence for the preview ---- - $evidence = [ordered]@{} - switch ($fullType) { - 'Microsoft.Compute/disks' { - $evidence['diskState'] = $props.diskState - $evidence['sizeGB'] = $props.diskSizeGB - $evidence['sku'] = if ($resObj.sku) { $resObj.sku.name } else { $null } - } - 'Microsoft.Network/publicIPAddresses' { - $evidence['ipAddress'] = $props.ipAddress - $evidence['allocationMethod'] = $props.publicIPAllocationMethod - $evidence['sku'] = if ($resObj.sku) { $resObj.sku.name } else { $null } - } - 'Microsoft.Network/networkInterfaces' { - $evidence['attachedVM'] = 'none' - } - } - - $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 } - $rg = if ($ResourceId -match '/resourceGroups/([^/]+)/') { $Matches[1] } else { $null } - $tagHash = @{} - if ($resObj -and $resObj.tags) { - foreach ($t in $resObj.tags.PSObject.Properties) { $tagHash[$t.Name] = $t.Value } - } - $estImpact = 0 - if ($evidence['estMonthlySavings']) { $estImpact = [double]$evidence['estMonthlySavings'] } - - $decision = Resolve-WriteDecision -ToolName 'remediate_delete_orphaned_resource' -Operation 'Delete' ` - -ResourceId $ResourceId -SubscriptionId $subId -ResourceGroup $rg -Tags $tagHash ` - -EstimatedMonthlyImpact $estImpact -Reversible $false -Apply:$Apply -ConfirmationToken $ConfirmationToken - - # ---- BLOCKED by mode/guardrails/enforcement ---- - if ($decision.Decision -eq 'Blocked') { - return [PSCustomObject]@{ - HasData = $false - Mode = 'Blocked' - Applied = $false - Error = $decision.Reason - GuardrailViolations = @($decision.GuardrailViolations) - WriteMode = $decision.Mode - ResourceId = $ResourceId - ResourceName = $resName - ResourceType = $fullType - } - } - - # ---- DRY RUN (default): preview the DELETE, mutate nothing ---- - if ($decision.Decision -eq 'Preview') { - return [PSCustomObject]@{ - HasData = $true - Mode = 'DryRun' - Applied = $false - WriteMode = $decision.Mode - Warning = "PREVIEW ONLY - nothing was deleted. $irreversible $($decision.Reason)" - Method = 'DELETE' - Uri = "$(Get-FinOpsArmEndpoint)$path" - ResourceId = $ResourceId - ResourceName = $resName - ResourceType = $fullType - TypeLabel = $cfg.label - Location = $location - OrphanEvidence = [PSCustomObject]$evidence - ConfirmationToken = $decision.ConfirmationToken - RequiresToken = $decision.RequiresToken - NextStep = if ($decision.RequiresToken) { - 'Enforced mode: re-run Remove-OrphanedResource with -Apply AND -ConfirmationToken , after user confirmation.' - } - else { - 'Re-run Remove-OrphanedResource with -Apply (after user confirmation) to delete this resource.' - } - } - } - - # ---- APPLY (decision = Proceed): perform the DELETE ---- - Write-Host " Deleting orphaned $($cfg.label) '$resName'..." -ForegroundColor Yellow - $delResp = Invoke-AzRestMethodWithRetry -Path $path -Method 'DELETE' - $delStatus = if ($delResp) { [int]$delResp.StatusCode } else { 0 } - # 200 OK, 202 Accepted (async), 204 No Content all indicate success. - $ok = $delStatus -in @(200, 202, 204) - - $errMsg = $null - if (-not $ok) { - $errMsg = "DELETE returned $delStatus." - if ($delResp -and $delResp.Content) { - try { - $eb = ($delResp.Content | ConvertFrom-Json -ErrorAction Stop) - if ($eb.error -and $eb.error.message) { $errMsg += " $($eb.error.message)" } - } - catch {} - } - } - - return [PSCustomObject]@{ - HasData = $true - Mode = 'Apply' - Applied = $ok - WriteMode = $decision.Mode - StatusCode = $delStatus - Warning = if ($ok) { 'Resource deleted. This is irreversible.' } else { $null } - Error = $errMsg - Method = 'DELETE' - Uri = "$(Get-FinOpsArmEndpoint)$path" - ResourceId = $ResourceId - ResourceName = $resName - ResourceType = $fullType - TypeLabel = $cfg.label - Location = $location - Async = ($delStatus -eq 202) - } -} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 deleted file mode 100644 index 8d2560961..000000000 --- a/src/powershell/Private/FinOpsMultitool/modules/Set-CostAllocationRule.ps1 +++ /dev/null @@ -1,319 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -########################################################################### -# SET-COSTALLOCATIONRULE.PS1 -# WRITE-BACK: NATIVE AZURE COST ALLOCATION RULE (CHARGEBACK) -########################################################################### -# Purpose: Push transfer-weighted percentages into a native Azure Cost -# Management cost allocation rule so chargeback reflects the split. -# Date: Created for FinOps Multitool shared-cost allocation -# -# Description: -# Creates or updates a Cost Management cost allocation rule via the ARM REST -# API (api-version 2025-03-01). The rule reallocates the cost of a shared -# source (a hub resource group or subscription) to spoke subscriptions by -# fixed percentage. Intended to be fed from scan_allocate_shared_cost output. -# -# SAFETY: This MUTATES chargeback. It is DRY-RUN by default. Nothing is -# written to Azure unless -Apply is passed. The dry-run returns the exact -# PUT URI and request body so a human can review before it is applied. The -# agent must present the preview and obtain explicit confirmation first. -# -# ── Parameters ────────────────────────────────────────────────── -# BillingAccountId EA enrollment id or MCA billing account id (rule scope) -# RuleName Rule name ([A-Za-z0-9_-]+, max 260 chars) -# SourceResourceGroup Shared-cost source resource group name(s) (Dimension) -# SourceSubscriptionId Shared-cost source subscription id(s) (alt to RG) -# Targets Spoke targets: array of {subscriptionId, percentage} -# or {spoke, allocatedShared} (percent derived) -# TargetDimension SubscriptionId (default) or ResourceGroupName -# Status Active (default) or NotActive -# Description Free-text rule description -# Apply Switch. Without it, returns a preview and writes nothing -# -# Prerequisites: -# - Cost Management Contributor on the billing account / enrollment -# - EA, MCA-E, or MCA-online billing account (cost allocation supported) -# -# Usage: Set-CostAllocationRule -BillingAccountId 100 -RuleName hub-split ` -# -SourceResourceGroup rg-hub -Targets $alloc -Apply -########################################################################### - -function Get-AllocTargetProp { - param( - [object]$Item, - [string[]]$Names - ) - foreach ($n in $Names) { - if ($Item -is [hashtable]) { - if ($Item.ContainsKey($n) -and $null -ne $Item[$n] -and "$($Item[$n])".Trim().Length -gt 0) { - return $Item[$n] - } - } - elseif ($null -ne $Item) { - $p = $Item.PSObject.Properties[$n] - if ($p -and $null -ne $p.Value -and "$($p.Value)".Trim().Length -gt 0) { - return $p.Value - } - } - } - return $null -} - -function ConvertTo-AllocationPercentages { - # Normalizes a target list to whole percentages that sum to EXACTLY 100.00. - param( - [object[]]$Targets, - [string]$TargetDimension - ) - - $raw = @() - $anyPct = $false - foreach ($t in $Targets) { - $name = Get-AllocTargetProp -Item $t -Names @('subscriptionId', 'SubscriptionId', 'spoke', 'Spoke', 'name', 'Name') - if (-not $name) { continue } - $pct = Get-AllocTargetProp -Item $t -Names @('percentage', 'Percentage', 'pct', 'Pct') - $share = Get-AllocTargetProp -Item $t -Names @('allocatedShared', 'AllocatedShared', 'weight', 'Weight', 'value', 'Value') - if ($null -ne $pct) { $anyPct = $true; $val = [double]$pct } - elseif ($null -ne $share) { $val = [double]$share } - else { $val = 0.0 } - $raw += [PSCustomObject]@{ Name = [string]$name; Value = $val } - } - - if ($raw.Count -eq 0) { - return @{ Ok = $false; Error = 'No usable targets. Each target needs subscriptionId/spoke plus percentage or allocatedShared.'; Values = @() } - } - - $sum = ($raw | Measure-Object -Property Value -Sum).Sum - if ($sum -le 0) { - return @{ Ok = $false; Error = ('Target weights/percentages sum to {0}; cannot build a rule.' -f $sum); Values = @() } - } - - $scaled = foreach ($r in $raw) { - [PSCustomObject]@{ Name = $r.Name; Percentage = [math]::Round(($r.Value / $sum) * 100, 2) } - } - $scaled = @($scaled) - - # Fix rounding residual so the rule sums to exactly 100.00. - $pSum = ($scaled | Measure-Object -Property Percentage -Sum).Sum - $residual = [math]::Round(100 - $pSum, 2) - if ($residual -ne 0) { - $top = $scaled | Sort-Object -Property Percentage -Descending | Select-Object -First 1 - $top.Percentage = [math]::Round($top.Percentage + $residual, 2) - } - - return @{ - Ok = $true - Error = $null - Values = @($scaled | ForEach-Object { @{ name = $_.Name; percentage = $_.Percentage } }) - } -} - -function Set-CostAllocationRule { - [CmdletBinding()] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes are gated by the explicit -Apply switch and routed through Resolve-WriteDecision (dry-run by default, mode/guardrail/confirmation-token enforcement, and audit logging).')] - param( - [Parameter(Mandatory)] - [string]$BillingAccountId, - - [Parameter(Mandatory)] - [string]$RuleName, - - [Parameter()] - [string[]]$SourceResourceGroup, - - [Parameter()] - [string[]]$SourceSubscriptionId, - - [Parameter(Mandatory)] - [object[]]$Targets, - - [Parameter()] - [ValidateSet('SubscriptionId', 'ResourceGroupName')] - [string]$TargetDimension = 'SubscriptionId', - - [Parameter()] - [ValidateSet('Active', 'NotActive')] - [string]$Status = 'Active', - - [Parameter()] - [string]$Description, - - [Parameter()] - [switch]$Apply, - - [Parameter()] - [string]$ConfirmationToken - ) - - $apiVersion = '2025-03-01' - - # ---- Validate inputs (fail before building anything) ---- - if ($RuleName -notmatch '^[A-Za-z0-9_\-]+$' -or $RuleName.Length -gt 260) { - return [PSCustomObject]@{ - HasData = $false - Error = "RuleName '$RuleName' is invalid. Use only letters, digits, '_' and '-' (max 260 chars)." - } - } - - $srcRg = @($SourceResourceGroup | Where-Object { $_ -and $_.Trim().Length -gt 0 }) - $srcSub = @($SourceSubscriptionId | Where-Object { $_ -and $_.Trim().Length -gt 0 }) - if ($srcRg.Count -gt 0 -and $srcSub.Count -gt 0) { - return [PSCustomObject]@{ - HasData = $false - Error = 'Provide a source as EITHER sourceResourceGroup OR sourceSubscriptionId, not both. A rule has one source resource type.' - } - } - if ($srcRg.Count -eq 0 -and $srcSub.Count -eq 0) { - return [PSCustomObject]@{ - HasData = $false - Error = 'A source is required: pass sourceResourceGroup (hub RG) or sourceSubscriptionId (hub subscription).' - } - } - - $source = if ($srcRg.Count -gt 0) { - @{ name = 'ResourceGroupName'; resourceType = 'Dimension'; values = @($srcRg) } - } - else { - @{ name = 'SubscriptionId'; resourceType = 'Dimension'; values = @($srcSub) } - } - if ($source.values.Count -gt 25) { - return [PSCustomObject]@{ HasData = $false; Error = 'A cost allocation rule source allows at most 25 values.' } - } - - # ---- Normalize target percentages to sum exactly 100.00 ---- - $norm = ConvertTo-AllocationPercentages -Targets $Targets -TargetDimension $TargetDimension - if (-not $norm.Ok) { - return [PSCustomObject]@{ HasData = $false; Error = $norm.Error } - } - $targetValues = @($norm.Values) - if ($targetValues.Count -gt 25) { - return [PSCustomObject]@{ HasData = $false; Error = 'A cost allocation rule allows at most 25 target values.' } - } - $pctTotal = [math]::Round((($targetValues | ForEach-Object { [double]$_.percentage } | Measure-Object -Sum).Sum), 2) - - $target = @{ - name = $TargetDimension - policyType = 'FixedProportion' - resourceType = 'Dimension' - values = $targetValues - } - - $desc = if ($Description) { $Description } else { "FinOps Multitool shared-cost allocation ($RuleName)" } - - $bodyObj = @{ - properties = @{ - description = $desc - status = $Status - details = @{ - sourceResources = @($source) - targetResources = @($target) - } - } - } - $bodyJson = $bodyObj | ConvertTo-Json -Depth 12 - - $path = "/providers/Microsoft.Billing/billingAccounts/$BillingAccountId/providers/Microsoft.CostManagement/costAllocationRules/$RuleName" + "?api-version=$apiVersion" - - $previewTargets = @($targetValues | ForEach-Object { - [PSCustomObject]@{ Name = $_.name; Percentage = $_.percentage } - }) - - # ---- Route through the configurable write-safety gate ---- - # A cost allocation rule lives at billing-account scope (no sub/RG). The - # fingerprint binds the EXACT rule body so Enforced mode cannot apply a - # different source/target split than was previewed. Reallocating cost is - # reversible (update/deactivate/delete the rule) so impact is left at 0. - $ruleResourceId = "/providers/Microsoft.Billing/billingAccounts/$BillingAccountId/providers/Microsoft.CostManagement/costAllocationRules/$RuleName" - $fpExtra = @{ - billingAccountId = $BillingAccountId - status = $Status - source = ($source.name + ':' + ((@($source.values) | Sort-Object) -join ',')) - targetDimension = $TargetDimension - targets = ((@($targetValues | ForEach-Object { "$($_.name)=$($_.percentage)" }) | Sort-Object) -join ';') - } - $decision = Resolve-WriteDecision -ToolName 'set_cost_allocation_rule' -Operation 'CreateUpdateCostAllocationRule' ` - -ResourceId $ruleResourceId -EstimatedMonthlyImpact 0 -Reversible $true ` - -Apply:$Apply -ConfirmationToken $ConfirmationToken -FingerprintExtra $fpExtra - - # ---- BLOCKED by mode/guardrails/enforcement ---- - if ($decision.Decision -eq 'Blocked') { - return [PSCustomObject]@{ - HasData = $false - Mode = 'Blocked' - Applied = $false - Error = $decision.Reason - GuardrailViolations = @($decision.GuardrailViolations) - WriteMode = $decision.Mode - BillingAccountId = $BillingAccountId - RuleName = $RuleName - } - } - - # ---- DRY RUN (default): show what WOULD be written, mutate nothing ---- - if ($decision.Decision -eq 'Preview') { - return [PSCustomObject]@{ - HasData = $true - Mode = 'DryRun' - Applied = $false - WriteMode = $decision.Mode - Warning = "PREVIEW ONLY - nothing was written to Azure. This rule changes chargeback/cost allocation. Show this preview to the user and get explicit approval, then re-run with apply=true to write it. $($decision.Reason)" - Method = 'PUT' - Uri = "$(Get-FinOpsArmEndpoint)$path" - BillingAccountId = $BillingAccountId - RuleName = $RuleName - Status = $Status - Source = [PSCustomObject]@{ Dimension = $source.name; Values = @($source.values) } - Targets = $previewTargets - PercentageTotal = $pctTotal - RequestBody = $bodyObj - ConfirmationToken = $decision.ConfirmationToken - RequiresToken = $decision.RequiresToken - NextStep = if ($decision.RequiresToken) { - 'Enforced mode: re-run set_cost_allocation_rule with apply=true AND confirmationToken=, after user confirmation.' - } - else { - 'Re-run set_cost_allocation_rule with apply=true (after user confirmation) to create/update this rule.' - } - } - } - - # ---- APPLY (decision = Proceed): actually create/update the rule ---- - Write-Host " Writing cost allocation rule '$RuleName' on billing account '$BillingAccountId'..." -ForegroundColor Yellow - $resp = Invoke-AzRestMethodWithRetry -Path $path -Method 'PUT' -Payload $bodyJson - - $status = if ($resp) { [int]$resp.StatusCode } else { 0 } - $ok = ($status -eq 200 -or $status -eq 201) - $respObj = $null - if ($resp -and $resp.Content) { - try { $respObj = $resp.Content | ConvertFrom-Json -ErrorAction Stop } catch { $respObj = $resp.Content } - } - - $errMsg = $null - if (-not $ok) { - $errMsg = "PUT returned $status." - if ($respObj -and $respObj.error -and $respObj.error.message) { - $errMsg += " $($respObj.error.message)" - } - } - - return [PSCustomObject]@{ - HasData = $true - Mode = 'Apply' - Applied = $ok - WriteMode = $decision.Mode - StatusCode = $status - Warning = if ($ok) { 'Cost allocation rule written. It changes how shared cost is charged back; allow time for Cost Management to reprocess.' } else { $null } - Error = $errMsg - Method = 'PUT' - Uri = "$(Get-FinOpsArmEndpoint)$path" - BillingAccountId = $BillingAccountId - RuleName = $RuleName - Status = $Status - Source = [PSCustomObject]@{ Dimension = $source.name; Values = @($source.values) } - Targets = $previewTargets - PercentageTotal = $pctTotal - Response = $respObj - } -} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 deleted file mode 100644 index 073f293cf..000000000 --- a/src/powershell/Private/FinOpsMultitool/modules/Stop-IdleVm.ps1 +++ /dev/null @@ -1,157 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -########################################################################### -# STOP-IDLEVM.PS1 -# AZURE FINOPS MULTITOOL - Deallocate an Idle VM -########################################################################### -# Purpose: Deallocate (stop) a single idle VM found by scan_idle_vms so -# it stops billing for compute. Deallocate is REVERSIBLE - the -# VM can be started again and keeps its disks and config. -# -# Description: -# 1. Validates the resource id (Microsoft.Compute/virtualMachines only). -# 2. Reads the VM power state; no-ops if it is already deallocated. -# 3. Routes through the configurable write-safety gate (dry-run by -# default; token required only in Enforced mode). -# Note: deallocate releases the dynamic public IP and ephemeral state; -# it does NOT delete the VM or its disks. -# -# ── Parameters ────────────────────────────────────────────────────── -# ResourceId Full ARM resource ID of the VM -# Apply Omitted = dry-run preview. Present = deallocate. -# ConfirmationToken Required only in Enforced mode (from the preview) -# -# Usage: Stop-IdleVm -ResourceId [-Apply] -########################################################################### - -function Stop-IdleVm { - [CmdletBinding()] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes are gated by the explicit -Apply switch and routed through Resolve-WriteDecision (dry-run by default, mode/guardrail/confirmation-token enforcement, and audit logging).')] - param( - [Parameter(Mandatory)] - [string]$ResourceId, - - [Parameter()] - [switch]$Apply, - - [Parameter()] - [string]$ConfirmationToken - ) - - $apiVersion = '2024-07-01' - - if ($ResourceId -notmatch '/providers/Microsoft\.Compute/virtualMachines/(?[^/]+)$') { - return [PSCustomObject]@{ - HasData = $false - Error = 'This tool only deallocates Microsoft.Compute/virtualMachines. Pass a VM resource ID.' - } - } - $vmName = $Matches.name - - # ---- Read power state (instanceView) ---- - $ivPath = "$ResourceId/instanceView`?api-version=$apiVersion" - $ivResp = Invoke-AzRestMethodWithRetry -Path $ivPath -Method 'GET' - $ivStatus = if ($ivResp) { [int]$ivResp.StatusCode } else { 0 } - if ($ivStatus -eq 404) { - return [PSCustomObject]@{ HasData = $true; Applied = $false; ResourceId = $ResourceId; Note = 'VM not found.' } - } - if ($ivStatus -lt 200 -or $ivStatus -ge 300) { - $gErr = $null - if ($ivResp -and $ivResp.Content) { try { $gErr = ($ivResp.Content | ConvertFrom-Json).error.message } catch { $gErr = $ivResp.Content } } - return [PSCustomObject]@{ HasData = $false; Error = "Could not read VM power state (HTTP $ivStatus). $gErr"; ResourceId = $ResourceId } - } - - $iv = $null - try { $iv = $ivResp.Content | ConvertFrom-Json -ErrorAction Stop } catch {} - $powerState = 'unknown' - if ($iv -and $iv.statuses) { - $ps = $iv.statuses | Where-Object { $_.code -like 'PowerState/*' } | Select-Object -First 1 - if ($ps) { $powerState = ($ps.code -replace '^PowerState/', '') } - } - - # ---- Already stopped? No-op. ---- - if ($powerState -in @('deallocated', 'deallocating', 'stopped')) { - return [PSCustomObject]@{ - HasData = $true - Mode = 'NoOp' - Applied = $false - Note = "VM '$vmName' is already '$powerState'. Nothing to do." - ResourceId = $ResourceId - ResourceName = $vmName - PowerState = $powerState - } - } - - $subId = if ($ResourceId -match '/subscriptions/([^/]+)/') { $Matches[1] } else { $null } - $rg = if ($ResourceId -match '/resourceGroups/([^/]+)/') { $Matches[1] } else { $null } - # Tags require a separate GET; deallocate is reversible so a light touch - # is fine - read tags from the VM resource for guardrail checks. - $tagHash = @{} - $vmResp = Invoke-AzRestMethodWithRetry -Path "$ResourceId`?api-version=$apiVersion" -Method 'GET' - if ($vmResp -and [int]$vmResp.StatusCode -ge 200 -and [int]$vmResp.StatusCode -lt 300) { - try { $vmObj = $vmResp.Content | ConvertFrom-Json; if ($vmObj.tags) { foreach ($t in $vmObj.tags.PSObject.Properties) { $tagHash[$t.Name] = $t.Value } } } catch {} - } - - # ---- Write-safety gate (reversible -> impact 0) ---- - $decision = Resolve-WriteDecision -ToolName 'remediate_deallocate_vm' -Operation 'Deallocate' ` - -ResourceId $ResourceId -SubscriptionId $subId -ResourceGroup $rg -Tags $tagHash ` - -EstimatedMonthlyImpact 0 -Reversible $true -Apply:$Apply -ConfirmationToken $ConfirmationToken - - if ($decision.Decision -eq 'Blocked') { - return [PSCustomObject]@{ - HasData = $false; Mode = 'Blocked'; Applied = $false; Error = $decision.Reason - GuardrailViolations = @($decision.GuardrailViolations); WriteMode = $decision.Mode - ResourceId = $ResourceId; ResourceName = $vmName - } - } - - $deallocPath = "$ResourceId/deallocate`?api-version=$apiVersion" - - if ($decision.Decision -eq 'Preview') { - return [PSCustomObject]@{ - HasData = $true - Mode = 'DryRun' - Applied = $false - WriteMode = $decision.Mode - Warning = "PREVIEW ONLY - the VM was not stopped. Deallocate is REVERSIBLE (you can start the VM again; disks are kept). $($decision.Reason)" - Method = 'POST' - Uri = "$(Get-FinOpsArmEndpoint)$deallocPath" - ResourceId = $ResourceId - ResourceName = $vmName - CurrentPowerState = $powerState - ConfirmationToken = $decision.ConfirmationToken - RequiresToken = $decision.RequiresToken - NextStep = if ($decision.RequiresToken) { - 'Enforced mode: re-run with apply=true AND confirmationToken=, after user confirmation.' - } - else { 'Re-run Stop-IdleVm with -Apply (after user confirmation) to deallocate this VM.' } - } - } - - # ---- Proceed: POST deallocate ---- - Write-Host " Deallocating idle VM '$vmName'..." -ForegroundColor Yellow - $resp = Invoke-AzRestMethodWithRetry -Path $deallocPath -Method 'POST' - $status = if ($resp) { [int]$resp.StatusCode } else { 0 } - $ok = $status -in @(200, 202) - $errMsg = $null - if (-not $ok) { - $errMsg = "POST deallocate returned $status." - if ($resp -and $resp.Content) { try { $eb = ($resp.Content | ConvertFrom-Json); if ($eb.error.message) { $errMsg += " $($eb.error.message)" } } catch {} } - } - - return [PSCustomObject]@{ - HasData = $true - Mode = 'Apply' - Applied = $ok - WriteMode = $decision.Mode - StatusCode = $status - Warning = if ($ok) { "Deallocate started (async). Reversible - start the VM to bring it back." } else { $null } - Error = $errMsg - Method = 'POST' - Uri = "$(Get-FinOpsArmEndpoint)$deallocPath" - ResourceId = $ResourceId - ResourceName = $vmName - Async = ($status -eq 202) - } -} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1 deleted file mode 100644 index 2a6d0c511..000000000 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Confirm-WriteAction.ps1 +++ /dev/null @@ -1,300 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -########################################################################### -# CONFIRM-WRITEACTION.PS1 -# AZURE FINOPS MULTITOOL - Configurable Write-Safety Core -########################################################################### -# Purpose: One choke point that every MUTATING tool routes through, so -# writeback is safe whether a human is driving an AI chat or an -# autonomous agent is acting unattended. -# -# Description: -# Safety is OPTIONAL and configurable via FINOPS_WRITE_MODE. Writes are -# OPT-IN: the server is read-only out of the box and a mutating tool only -# acts after an operator deliberately enables a write mode. -# ReadOnly (default) - all writes blocked. Set FINOPS_WRITE_MODE to -# Interactive or Enforced to enable remediation. -# Interactive - platform-agnostic AI chat. apply=true is -# honored directly (the human/AI client showed the preview). A -# confirmation token is still issued, but NOT required. Low friction. -# Enforced - autonomous-safe. apply=true is REJECTED unless -# it carries the exact confirmation token returned by the matching -# dry-run. An agent cannot skip the preview or apply a different / -# larger change than was previewed. -# -# Guardrails (apply in BOTH Interactive and Enforced, all configurable): -# - Protected tag keys/values (e.g. do-not-delete) - never mutate -# - Protected resource groups / subscriptions (deny-list patterns) -# - Estimated monthly $ impact cap -# - Blast-radius cap (max writes per rolling window) -# Every preview and apply is written to an append-only audit log. -# -# ── Configuration (env) ───────────────────────────────────────────── -# FINOPS_WRITE_MODE Interactive | Enforced | ReadOnly -# FINOPS_WRITE_MAX_IMPACT Max estimated monthly USD impact (0 = no cap) -# FINOPS_WRITE_MAX_PER_WINDOW Max writes per window (0 = no cap) -# FINOPS_WRITE_WINDOW_MIN Rolling window minutes for blast radius -# FINOPS_PROTECTED_TAGS Comma list of tag keys that block a write -# FINOPS_PROTECTED_RGS Comma list of resource-group name patterns -# FINOPS_PROTECTED_SUBS Comma list of subscription IDs to deny -# FINOPS_AUDIT_LOG Audit log path -# -# Usage: $d = Resolve-WriteDecision -ToolName x -Operation Delete ... -########################################################################### - -# -- Per-process state ----------------------------------------------------- -$script:FinOpsPendingConfirmations = @{} -$script:FinOpsWriteHistory = New-Object System.Collections.ArrayList - -function Get-FinOpsDefaultAuditLogPath { - # Durable, per-user audit location that survives reboots - unlike %TEMP%, - # which the user (or OS cleanup) can clear, weakening an append-only trail. - # Falls back to the temp dir only if the profile path cannot be resolved or - # created. Override entirely with FINOPS_AUDIT_LOG. - $base = [Environment]::GetFolderPath('LocalApplicationData') - if ([string]::IsNullOrWhiteSpace($base)) { $base = [System.IO.Path]::GetTempPath() } - $dir = Join-Path $base 'FinOpsMultitool' - try { - if (-not (Test-Path -LiteralPath $dir)) { - New-Item -ItemType Directory -Path $dir -Force -ErrorAction Stop | Out-Null - } - } - catch { - $dir = [System.IO.Path]::GetTempPath() - } - return (Join-Path $dir 'finops-multitool-audit.log') -} - -function Initialize-FinOpsWritePolicy { - $envList = { - param($v) - if ([string]::IsNullOrWhiteSpace($v)) { @() } - else { @($v -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } - } - - $mode = $env:FINOPS_WRITE_MODE - if ($mode -notin @('Interactive', 'Enforced', 'ReadOnly')) { $mode = 'ReadOnly' } - - $script:FinOpsWritePolicy = [ordered]@{ - Mode = $mode - MaxEstimatedImpact = [double]($env:FINOPS_WRITE_MAX_IMPACT | ForEach-Object { if ($_) { $_ } else { 0 } }) - MaxWritesPerWindow = [int]( $env:FINOPS_WRITE_MAX_PER_WINDOW | ForEach-Object { if ($_) { $_ } else { 0 } }) - WindowMinutes = [int]( $env:FINOPS_WRITE_WINDOW_MIN | ForEach-Object { if ($_) { $_ } else { 60 } }) - ProtectedTagKeys = & $envList ($env:FINOPS_PROTECTED_TAGS) - ProtectedResourceGroups = & $envList ($env:FINOPS_PROTECTED_RGS) - ProtectedSubscriptions = & $envList ($env:FINOPS_PROTECTED_SUBS) - TokenTtlSeconds = 300 - AuditLogPath = if ($env:FINOPS_AUDIT_LOG) { $env:FINOPS_AUDIT_LOG } else { Get-FinOpsDefaultAuditLogPath } - } - # Built-in safety defaults so a fresh deploy is never wide open on the - # most dangerous classes even before an operator tunes the env vars. - if ($script:FinOpsWritePolicy.ProtectedTagKeys.Count -eq 0) { - $script:FinOpsWritePolicy.ProtectedTagKeys = @('do-not-delete', 'DoNotDelete', 'lock', 'protected') - } - return $script:FinOpsWritePolicy -} - -function Get-FinOpsWritePolicy { - if (-not $script:FinOpsWritePolicy) { Initialize-FinOpsWritePolicy | Out-Null } - return $script:FinOpsWritePolicy -} - -function Set-FinOpsWritePolicy { - [CmdletBinding()] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Updates only the in-process write-policy hashtable; it does not change system or Azure state.')] - param([hashtable]$Settings) - if (-not $script:FinOpsWritePolicy) { Initialize-FinOpsWritePolicy | Out-Null } - foreach ($k in $Settings.Keys) { - if ($script:FinOpsWritePolicy.Contains($k)) { $script:FinOpsWritePolicy[$k] = $Settings[$k] } - } - return $script:FinOpsWritePolicy -} - -# -- Stable fingerprint of the exact intended change ---------------------- -function Get-WriteFingerprint { - [CmdletBinding()] - param( - [Parameter(Mandatory)][string]$ToolName, - [Parameter(Mandatory)][string]$Operation, - [Parameter(Mandatory)][string]$ResourceId, - [hashtable]$Extra - ) - $parts = @($ToolName.ToLower(), $Operation.ToLower(), $ResourceId.ToLower()) - if ($Extra) { - foreach ($k in ($Extra.Keys | Sort-Object)) { $parts += "$k=$($Extra[$k])".ToLower() } - } - $canonical = ($parts -join '|') - $sha = [System.Security.Cryptography.SHA256]::Create() - $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($canonical)) - $sha.Dispose() - return -join ($bytes | ForEach-Object { $_.ToString('x2') }) -} - -function New-WriteConfirmation { - [CmdletBinding()] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Issues an in-process, short-lived confirmation token; it does not change system or Azure state.')] - param([Parameter(Mandatory)][string]$Fingerprint) - $policy = Get-FinOpsWritePolicy - $token = [guid]::NewGuid().ToString('N') - $script:FinOpsPendingConfirmations[$token] = @{ - Fingerprint = $Fingerprint - Expires = (Get-Date).AddSeconds($policy.TokenTtlSeconds) - } - # Opportunistic cleanup of expired tokens. - $now = Get-Date - @($script:FinOpsPendingConfirmations.Keys) | ForEach-Object { - if ($script:FinOpsPendingConfirmations[$_].Expires -lt $now) { $script:FinOpsPendingConfirmations.Remove($_) } - } - return $token -} - -function Test-WriteConfirmation { - [CmdletBinding()] - param( - [string]$Token, - [Parameter(Mandatory)][string]$Fingerprint - ) - if ([string]::IsNullOrWhiteSpace($Token)) { return @{ Valid = $false; Reason = 'No confirmationToken supplied.' } } - $entry = $script:FinOpsPendingConfirmations[$Token] - if (-not $entry) { return @{ Valid = $false; Reason = 'confirmationToken is unknown, already used, or expired. Re-run the dry-run preview to get a fresh token.' } } - # Single use, regardless of outcome. - $script:FinOpsPendingConfirmations.Remove($Token) - if ($entry.Expires -lt (Get-Date)) { return @{ Valid = $false; Reason = 'confirmationToken has expired. Re-run the dry-run preview.' } } - if ($entry.Fingerprint -ne $Fingerprint) { return @{ Valid = $false; Reason = 'confirmationToken does not match this exact change. The intended action differs from what was previewed; re-run the dry-run preview.' } } - return @{ Valid = $true; Reason = $null } -} - -# -- Guardrails (protected resources, caps) ------------------------------- -function Test-WriteGuardrails { - [CmdletBinding()] - param( - [string]$ResourceId, - [string]$SubscriptionId, - [string]$ResourceGroup, - [hashtable]$Tags, - [double]$EstimatedMonthlyImpact = 0 - ) - $policy = Get-FinOpsWritePolicy - $violations = @() - - if ($SubscriptionId -and ($policy.ProtectedSubscriptions -contains $SubscriptionId)) { - $violations += "Subscription '$SubscriptionId' is on the protected (deny) list." - } - if ($ResourceGroup) { - foreach ($pat in $policy.ProtectedResourceGroups) { - if ($ResourceGroup -like $pat -or $ResourceGroup -eq $pat) { - $violations += "Resource group '$ResourceGroup' matches a protected pattern ('$pat')." - break - } - } - } - if ($Tags) { - foreach ($pk in $policy.ProtectedTagKeys) { - $hit = $Tags.Keys | Where-Object { $_ -ieq $pk } - if ($hit) { $violations += "Resource carries a protected tag ('$hit') - refusing to mutate it." } - } - } - if ($policy.MaxEstimatedImpact -gt 0 -and $EstimatedMonthlyImpact -gt $policy.MaxEstimatedImpact) { - $violations += ("Estimated monthly impact `$$([math]::Round($EstimatedMonthlyImpact,2)) exceeds the configured cap `$$($policy.MaxEstimatedImpact).") - } - if ($policy.MaxWritesPerWindow -gt 0) { - $cutoff = (Get-Date).AddMinutes(-1 * $policy.WindowMinutes) - $recent = @($script:FinOpsWriteHistory | Where-Object { $_ -gt $cutoff }) - if ($recent.Count -ge $policy.MaxWritesPerWindow) { - $violations += "Blast-radius cap reached: $($recent.Count) writes in the last $($policy.WindowMinutes) min (max $($policy.MaxWritesPerWindow)). Wait or raise FINOPS_WRITE_MAX_PER_WINDOW." - } - } - return @{ Allowed = ($violations.Count -eq 0); Violations = $violations } -} - -function Write-FinOpsAudit { - [CmdletBinding()] - param([Parameter(Mandatory)][hashtable]$Entry) - try { - $policy = Get-FinOpsWritePolicy - $Entry['timestamp'] = (Get-Date -Format 'o') - $line = ($Entry | ConvertTo-Json -Depth 6 -Compress) - Add-Content -Path $policy.AuditLogPath -Value $line -Encoding utf8 -ErrorAction SilentlyContinue - } - catch { Write-Warning "Audit log write failed: $($_.Exception.Message)" } -} - -# -- The single decision the mutating tools call -------------------------- -function Resolve-WriteDecision { - [CmdletBinding()] - param( - [Parameter(Mandatory)][string]$ToolName, - [Parameter(Mandatory)][string]$Operation, - [Parameter(Mandatory)][string]$ResourceId, - [string]$SubscriptionId, - [string]$ResourceGroup, - [hashtable]$Tags, - [double]$EstimatedMonthlyImpact = 0, - [bool]$Reversible = $true, - [switch]$Apply, - [string]$ConfirmationToken, - [hashtable]$FingerprintExtra - ) - $policy = Get-FinOpsWritePolicy - $fp = Get-WriteFingerprint -ToolName $ToolName -Operation $Operation -ResourceId $ResourceId -Extra $FingerprintExtra - - $result = [ordered]@{ - Mode = $policy.Mode - Decision = $null # Preview | Proceed | Blocked - Reason = $null - ConfirmationToken = $null - RequiresToken = ($policy.Mode -eq 'Enforced') - GuardrailViolations = @() - Reversible = $Reversible - Fingerprint = $fp - } - - # Read-only server: nothing writes, ever. - if ($policy.Mode -eq 'ReadOnly') { - $result.Decision = 'Blocked' - $result.Reason = 'Server is in ReadOnly write mode (FINOPS_WRITE_MODE=ReadOnly, the default). No mutations are permitted. To enable remediation, set FINOPS_WRITE_MODE=Interactive (human-in-the-loop) or =Enforced (autonomous-safe; apply=true also requires the confirmation token from a dry-run).' - Write-FinOpsAudit -Entry @{ event = 'blocked'; tool = $ToolName; operation = $Operation; resourceId = $ResourceId; reason = $result.Reason } - return [PSCustomObject]$result - } - - # Guardrails apply in every mode (these are the "costly mistake" guard). - $g = Test-WriteGuardrails -ResourceId $ResourceId -SubscriptionId $SubscriptionId -ResourceGroup $ResourceGroup -Tags $Tags -EstimatedMonthlyImpact $EstimatedMonthlyImpact - if (-not $g.Allowed) { - $result.Decision = 'Blocked' - $result.GuardrailViolations = @($g.Violations) - $result.Reason = 'Blocked by write guardrails: ' + ($g.Violations -join ' ') - Write-FinOpsAudit -Entry @{ event = 'blocked'; tool = $ToolName; operation = $Operation; resourceId = $ResourceId; reason = $result.Reason; violations = @($g.Violations) } - return [PSCustomObject]$result - } - - # Dry-run preview: issue a token (usable later in any mode). - if (-not $Apply) { - $result.Decision = 'Preview' - $result.ConfirmationToken = New-WriteConfirmation -Fingerprint $fp - $result.Reason = if ($policy.Mode -eq 'Enforced') { - 'Preview only. Server is in Enforced mode: re-run with apply=true AND confirmationToken= to execute.' - } - else { - 'Preview only. Show this to the user; re-run with apply=true to execute (Interactive mode does not require the token).' - } - Write-FinOpsAudit -Entry @{ event = 'preview'; tool = $ToolName; operation = $Operation; resourceId = $ResourceId; mode = $policy.Mode; reversible = $Reversible } - return [PSCustomObject]$result - } - - # Apply path. - if ($policy.Mode -eq 'Enforced') { - $chk = Test-WriteConfirmation -Token $ConfirmationToken -Fingerprint $fp - if (-not $chk.Valid) { - $result.Decision = 'Blocked' - $result.Reason = "Enforced mode: $($chk.Reason)" - Write-FinOpsAudit -Entry @{ event = 'blocked'; tool = $ToolName; operation = $Operation; resourceId = $ResourceId; reason = $result.Reason } - return [PSCustomObject]$result - } - } - - $result.Decision = 'Proceed' - [void]$script:FinOpsWriteHistory.Add((Get-Date)) - Write-FinOpsAudit -Entry @{ event = 'apply'; tool = $ToolName; operation = $Operation; resourceId = $ResourceId; mode = $policy.Mode; reversible = $Reversible; estimatedMonthlyImpact = $EstimatedMonthlyImpact } - return [PSCustomObject]$result -} diff --git a/src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 b/src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 deleted file mode 100644 index fc76c244f..000000000 --- a/src/powershell/Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 +++ /dev/null @@ -1,218 +0,0 @@ -# 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 4caa7258f..39e295712 100644 --- a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 +++ b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 @@ -34,7 +34,7 @@ InModuleScope 'FinOpsToolkit' { $modules = Get-ChildItem -Path $modulesPath -Filter '*.ps1' # Exact count so a deleted scanner fails the build instead of # silently passing a loose lower bound. - $modules.Count | Should -Be 37 + $modules.Count | Should -Be 33 } It 'Should dot-source every scanner module file from the loader' { diff --git a/src/templates/agent-skills/finops-multitool/SKILL.md b/src/templates/agent-skills/finops-multitool/SKILL.md index 1cf346717..8f696d6b2 100644 --- a/src/templates/agent-skills/finops-multitool/SKILL.md +++ b/src/templates/agent-skills/finops-multitool/SKILL.md @@ -2,7 +2,7 @@ name: finops-multitool description: This skill should be used when the user asks to "scan for cost savings", "find orphaned resources", "find idle VMs", "check Azure Hybrid Benefit", "review tags", "tag coverage", "tag recommendations", "policy coverage", "cost by tag", "cost trend", "top resources by cost", "reservation recommendations", "commitment utilization", "realized savings", "budget status", "cost anomaly alerts", "Advisor cost recommendations", "billing structure", "contract info", or run a "FinOps assessment", "FinOps scan", or "cost optimization scan". Also use it proactively whenever the conversation turns to Azure cost, waste, savings, governance, or FinOps health and live data would answer the question better than a general explanation. license: MIT -compatibility: Requires an authenticated Azure session (az login, or Connect-AzAccount for PowerShell) with at least Reader access on the target scope. Everything in this skill is read-only. Remediation is deliberately out of scope - use the FinOps multitool terminal UI (Start-FinOpsMultitool, from the FinOpsToolkit PowerShell module) which gates every write behind a preview and confirmation. +compatibility: Requires an authenticated Azure session (az login, or Connect-AzAccount for PowerShell) with at least Reader access on the target scope. Everything here is read-only, including the FinOps multitool terminal UI. Recommend changes and explain their impact, and leave applying them to the user. metadata: author: microsoft version: '2.0' @@ -15,10 +15,10 @@ This is the routing hub for FinOps investigations. It answers "what should I loo Two ways to gather the data: -- **Interactively** - `Start-FinOpsMultitool` launches a terminal UI that runs 30 read-only scan modules, renders the results, and exports them. Best when a person wants the full picture, and the only supported path for remediation. +- **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. - **Directly** - query Azure Resource Graph, Cost Management, Advisor, Monitor, or a FinOps hub yourself with `az` or an Azure MCP server. Best when answering one question inside a conversation. This skill carries the queries and the interpretation rules. -Prefer the direct path for a single question. Suggest the terminal UI when the user wants a full assessment or intends to act on the findings. +Prefer the direct path for a single question. Suggest the terminal UI when the user wants a full assessment. ## Always confirm scope first @@ -84,9 +84,11 @@ This is the part raw API output gets wrong. Apply these before reporting a numbe - Surface the cost driver, not just the resource list. - Name the scope you scanned every time. -## Remediation +## Acting on findings -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. +Nothing here changes Azure, and neither does the terminal UI. Every investigation ends in a recommendation, not an action. + +Name the specific change, explain what it saves and what it risks, and give the user the command or portal path to apply it. Check for a dependency before recommending a deletion - an unattached disk can be a deliberate spare, and a snapshot is sometimes the backup. ## FinOps skill ecosystem diff --git a/src/templates/agent-skills/finops-multitool/references/commitments.md b/src/templates/agent-skills/finops-multitool/references/commitments.md index de2a00c31..a757e6519 100644 --- a/src/templates/agent-skills/finops-multitool/references/commitments.md +++ b/src/templates/agent-skills/finops-multitool/references/commitments.md @@ -66,6 +66,8 @@ Sources: cost data with `pricingModel` or `benefitId` populated, compared agains 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. +The FinOps multitool reports this figure as an estimate and labels it as one. It derives savings from amortized cost and an assumed discount rate rather than comparing each line against its retail rate, so treat it as an order-of-magnitude number. When you need a defensible figure, use the retail-rate comparison described above and say which method produced it. + ## 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. From fa442bf6507a02d08045435a3feb508a150baa17 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Fri, 21 Aug 2026 22:16:04 -0600 Subject: [PATCH 082/142] chore(multitool): remove a scratch test script committed by mistake .ftk-scope-test.ps1 was a throwaway harness used to confirm that nested functions see their parent's parameters on PowerShell 5.1 and 7. Its cleanup step did not run, so git add -A picked it up in ddc72dbd. It is not part of the toolkit and has no callers. --- .ftk-scope-test.ps1 | 38 -------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .ftk-scope-test.ps1 diff --git a/.ftk-scope-test.ps1 b/.ftk-scope-test.ps1 deleted file mode 100644 index 9ca928555..000000000 --- a/.ftk-scope-test.ps1 +++ /dev/null @@ -1,38 +0,0 @@ -#Requires -Version 5.1 -# Proves a switch parameter resolves inside nested functions, which is how the -# multitool's pickers read -NonInteractive. - -function Outer { - [CmdletBinding()] - param( - [switch]$NonInteractive, - [string[]]$Scans - ) - - function Inner-ReadsSwitch { - if ($NonInteractive) { return 'NONINTERACTIVE' } - return 'interactive' - } - - function Inner-ReadsArray { - if ($Scans -and $Scans.Count -gt 0) { return "scans=$($Scans -join '+')" } - return 'no-scans' - } - - [PSCustomObject]@{ - Switch = Inner-ReadsSwitch - Array = Inner-ReadsArray - } -} - -Write-Host '-- default --' -Outer | Format-List - -Write-Host '-- with -NonInteractive and -Scans --' -Outer -NonInteractive -Scans 'A', 'B' | Format-List - -$a = Outer -$b = Outer -NonInteractive -Scans 'A', 'B' -$ok = ($a.Switch -eq 'interactive') -and ($b.Switch -eq 'NONINTERACTIVE') -and -($a.Array -eq 'no-scans') -and ($b.Array -eq 'scans=A+B') -Write-Host ("RESULT: {0}" -f $(if ($ok) { 'PASS - nested functions see the parent parameters' } else { 'FAIL - parameters do NOT propagate' })) -ForegroundColor $(if ($ok) { 'Green' } else { 'Red' }) From a9f16901a41b0e7b5a75b550c97788bdd1180889 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Fri, 21 Aug 2026 23:53:26 -0600 Subject: [PATCH 083/142] docs(multitool): drop the production safety claim from the read-only description Read-only is a fact about what the tool writes; safe to run against production is a guarantee about outcomes, and the two are not the same. A tenant-wide scan still puts real load on Resource Graph, Cost Management, and Monitor, which the skill itself warns about. Stating what the tool does and letting the reader draw the conclusion is both accurate and shorter. --- docs-mslearn/toolkit/multitool/finops-multitool-overview.md | 2 +- docs/multitool.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md index d0dbe2d86..e42086e7e 100644 --- a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -25,7 +25,7 @@ FinOps multitool runs 30 scan modules against the subscriptions you select and r - **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. -- **Read-only**
Every scan reads your environment and reports what it finds. The multitool never creates, changes, or deletes a resource, so you can run it against production and hand it to anyone with Reader access. +- **Read-only**
Every scan reads your environment and reports what it finds. The multitool never creates, changes, or deletes a resource. ## Benefits diff --git a/docs/multitool.md b/docs/multitool.md index f3a08409b..024ff8948 100644 --- a/docs/multitool.md +++ b/docs/multitool.md @@ -49,7 +49,7 @@ The FinOps multitool scans an Azure environment for cost optimization, governanc
🛡️ Read-only by design
-
Every scan reads your environment and reports what it finds. The multitool never creates, changes, or deletes a resource, so it's safe to run against production.
+
Every scan reads your environment and reports what it finds. The multitool never creates, changes, or deletes a resource.
Learn more
From 6311634a13bbfa0c0b10003bf326c38947fa2b52 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Sat, 22 Aug 2026 00:12:33 -0600 Subject: [PATCH 084/142] fix(multitool): render scan guidance in the HTML report and scope the carbon permissions The HTML report carried CSS for guidance blocks and a comment reading "re-evaluate guidance items for HTML", but nothing ever emitted them. Guidance is built during the terminal display pass and the report is written afterwards, so the items were gone by the time the HTML was assembled. An exported report therefore had the tables but none of the interpretation that made them useful. Captures the per-scan items in $guidanceByFn as the display pass builds them and emits them into the report, rather than re-running the 450-line guidance switch a second time and creating two copies to keep in sync. Severity maps onto the .guidance red/yellow/green classes that were already defined, and docs values that look like URLs become links. Messages interpolate scan data, so every value goes through HtmlEncode; verified with a script tag in a guidance message. Also corrects the carbon permissions wording. Learn's carbon optimization permissions table shows Subscription Reader can view emissions but Resource Group Reader and Resource Reader cannot, and that carbon permissions apply at the subscription level only. The docs said "Reader or Carbon Optimization Reader" without that qualifier, so someone holding Reader on a resource group would have read it as sufficient. The in-tool permission readout was already correct. ms.date bumped to today on the three changed Learn articles. --- .../multitool/finops-multitool-overview.md | 4 ++-- .../multitool/finops-multitool-commands.md | 4 ++-- .../multitool/start-finopsmultitool.md | 4 ++-- .../Invoke-FinOpsMultitool.ps1 | 21 ++++++++++++++++++- .../agent-skills/finops-multitool/SKILL.md | 2 +- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md index e42086e7e..5723f2269 100644 --- a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -3,7 +3,7 @@ title: FinOps multitool overview 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/21/2026 +ms.date: 08/22/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit @@ -37,7 +37,7 @@ FinOps multitool shortens the path from "what is this costing us?" to a specific ## 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. +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](/azure/carbon-optimization/permissions) assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. ## Give feedback diff --git a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md index 5617faa1c..daffe2c06 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: 08/21/2026 +ms.date: 08/22/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -44,7 +44,7 @@ The multitool includes 30 scan modules across the following categories: - **AI and ML** – Azure AI workload spend. - **Sustainability** – Carbon emissions. -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. +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 assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope.
diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md index 242af7936..a71767591 100644 --- a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -3,7 +3,7 @@ title: Start-FinOpsMultitool command 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: 08/21/2026 +ms.date: 08/22/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -17,7 +17,7 @@ The **Start-FinOpsMultitool** command launches the FinOps multitool interactive Results are rendered in the terminal. When you choose to export, the tool writes a CSV file per scan module, a `FinOpsReport.html` summary, and a `ScanSummary.txt` file. The scan modules are read-only. -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. +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 assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. The tool prompts for each choice by default. To run it from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters. diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index b4808acaf..f76371a10 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1361,6 +1361,9 @@ function Invoke-FinOpsMultitool { Write-Host "" # -- Display results per module ------------------------------------ + # Guidance is built per scan during this pass; the HTML report is written + # later, so keep it here rather than recomputing the whole switch. + $guidanceByFn = @{} foreach ($mod in ($Modules | Where-Object { $_.Selected })) { $data = $Results[$mod.Fn] if (-not $data -or @($data).Count -eq 0) { @@ -2370,6 +2373,7 @@ function Invoke-FinOpsMultitool { # Render guidance with severity colors if ($guidanceItems.Count -gt 0) { + $guidanceByFn[$mod.Fn] = $guidanceItems Write-Host "" # Determine overall severity for the header $hasCritical = $guidanceItems | Where-Object { $_.Severity -eq 'Red' } @@ -2678,7 +2682,22 @@ tr:hover { background: #161b22; } } # Render guidance - # (Re-evaluate guidance items for HTML — reuse the same logic) + if ($guidanceByFn.ContainsKey($fn)) { + foreach ($item in $guidanceByFn[$fn]) { + $gClass = switch ($item.Severity) { 'Red' { 'guidance red' } 'Yellow' { 'guidance yellow' } 'Green' { 'guidance green' } default { 'guidance' } } + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$item.Message))") + if ($item.Docs) { + $eDocs = [System.Net.WebUtility]::HtmlEncode([string]$item.Docs) + if ($item.Docs -match '^https?://') { + [void]$htmlSb.Append("
$eDocs") + } + else { + [void]$htmlSb.Append("
$eDocs") + } + } + [void]$htmlSb.Append('
') + } + } } [void]$htmlSb.Append('
Generated by FinOps Multitool — part of the FinOps Toolkit
') diff --git a/src/templates/agent-skills/finops-multitool/SKILL.md b/src/templates/agent-skills/finops-multitool/SKILL.md index 8f696d6b2..d3d53e444 100644 --- a/src/templates/agent-skills/finops-multitool/SKILL.md +++ b/src/templates/agent-skills/finops-multitool/SKILL.md @@ -119,5 +119,5 @@ Gather the data first so the numbers are real, then route. Don't answer governan - **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. +- **Carbon Optimization Reader** for emissions data, assigned at the subscription. Reader works too, but only at subscription scope - carbon permissions don't apply at resource group or resource level. - The terminal UI additionally needs the `FinOpsToolkit` PowerShell module and the `Az.Accounts`, `Az.ResourceGraph`, and `Az.Storage` modules. From 9a8fcf43d56656518fea4860f7f28802650b9e9c Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Sat, 22 Aug 2026 00:25:07 -0600 Subject: [PATCH 085/142] chore(multitool): remove three unreachable modules Initialize-Scanner, Get-TenantHierarchy, and New-PowerBITemplate had no callers. Initialize-Scanner also carried a WPF tenant picker, which is Windows-only and does not belong in a cross-platform terminal UI. New-PowerBITemplate was only reachable through the MCP server, which moved to its own branch. Module count assertion updated 33 -> 30. --- .../FinOpsMultitool/FinOpsMultitool.psm1 | 3 - .../Private/FinOpsMultitool/README.md | 1 - .../modules/Get-TenantHierarchy.ps1 | 126 ----- .../modules/Initialize-Scanner.ps1 | 259 ---------- .../modules/New-PowerBITemplate.ps1 | 468 ------------------ .../Unit/Start-FinOpsMultitool.Tests.ps1 | 2 +- 6 files changed, 1 insertion(+), 858 deletions(-) delete mode 100644 src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 delete mode 100644 src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 delete mode 100644 src/powershell/Private/FinOpsMultitool/modules/New-PowerBITemplate.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 index c4c1d425a..6c0b36ea5 100644 --- a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 +++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 @@ -52,8 +52,6 @@ $script:ScriptRootDir = $PSScriptRoot # -- Analysis Modules ---------------------------------------------------- $modulePath = Join-Path $PSScriptRoot 'modules' -. (Join-Path $modulePath 'Initialize-Scanner.ps1') -. (Join-Path $modulePath 'Get-TenantHierarchy.ps1') . (Join-Path $modulePath 'Get-ContractInfo.ps1') . (Join-Path $modulePath 'Get-CostData.ps1') . (Join-Path $modulePath 'Get-ResourceCosts.ps1') @@ -84,4 +82,3 @@ $modulePath = Join-Path $PSScriptRoot 'modules' . (Join-Path $modulePath 'Get-UsageProportionalAllocation.ps1') . (Join-Path $modulePath 'Get-AIWorkloadMetrics.ps1') . (Join-Path $modulePath 'Get-CarbonMetrics.ps1') -. (Join-Path $modulePath 'New-PowerBITemplate.ps1') diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index 149e76cdd..e54c3d009 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -337,7 +337,6 @@ FinOpsMultitool/ │ │ ├── Invoke-AzRestMethodWithRetry.ps1 # REST retry logic │ │ ├── Search-AzGraphSafe.ps1 # ARG query wrapper │ │ └── MgCostScope.ps1 # Management group scope state -│ ├── Initialize-Scanner.ps1 │ ├── Get-CostData.ps1 │ ├── Get-ResourceCosts.ps1 │ ├── Get-TagInventory.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 deleted file mode 100644 index fadba3449..000000000 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 +++ /dev/null @@ -1,126 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -########################################################################### -# GET-TENANTHIERARCHY.PS1 -# AZURE FINOPS MULTITOOL - Management Group & Subscription Hierarchy -########################################################################### -# Purpose: Retrieve the full management group tree with subscriptions -# nested under their parent groups. -########################################################################### - -function Get-TenantHierarchy { - [CmdletBinding()] - param( - [Parameter(Mandatory)] - [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] - [string]$TenantId, - - [Parameter()] - [object[]]$Subscriptions, - - [Parameter()] - [int]$TimeoutSeconds = 60 - ) - - try { - # Run in a background runspace with timeout to prevent UI freeze - $rs = [runspacefactory]::CreateRunspace() - $rs.Open() - $ps = [powershell]::Create() - $ps.Runspace = $rs - [void]$ps.AddScript({ - param($tid) - Get-AzManagementGroup -GroupId $tid -Expand -Recurse -ErrorAction Stop - }).AddArgument($TenantId) - - $asyncResult = $ps.BeginInvoke() - - # Wait for runspace — uses DispatcherFrame when WPF is loaded, else Start-Sleep - Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds - - if ($asyncResult.IsCompleted) { - $rootGroup = $ps.EndInvoke($asyncResult) - if ($ps.Streams.Error.Count -gt 0) { - throw $ps.Streams.Error[0].Exception - } - $ps.Dispose(); $rs.Close() - - if ($rootGroup) { - $actual = if ($rootGroup -is [array]) { $rootGroup[0] } else { $rootGroup } - $subMap = @{} - Build-SubMap -Group $actual -Map ([ref]$subMap) - return [PSCustomObject]@{ - RootGroup = $actual - SubscriptionMap = $subMap - } - } - } - else { - # Timed out — stop and fall through to fallback - $ps.Stop() - $ps.Dispose(); $rs.Close() - Write-Warning "Management group hierarchy timed out after $TimeoutSeconds seconds. Using flat subscription list." - } - - # Fallback - $subs = if ($Subscriptions) { @($Subscriptions) } else { - @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' }) - } - $fallbackRoot = [PSCustomObject]@{ - DisplayName = "Tenant Root" - Name = $TenantId - Children = @() - } - return [PSCustomObject]@{ - RootGroup = $fallbackRoot - SubscriptionMap = @{} - FlatSubs = $subs - } - } - catch { - # Lacking Microsoft.Management read/register access is expected for many - # scopes — fall back to a flat subscription list quietly instead of - # dumping a wall of authorization noise to the console. - $errMsg = $_.Exception.Message - if ($errMsg -match 'does not have authorization|Authorization_RequestDenied|register/action|RBACAccessDenied|Forbidden|\(403\)') { - Write-Verbose "Management group hierarchy not accessible (insufficient permissions); using flat subscription list." - } - else { - Write-Verbose "Management group hierarchy unavailable; using flat subscription list. ($errMsg)" - } - - $subs = if ($Subscriptions) { @($Subscriptions) } else { - @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' }) - } - $fallbackRoot = [PSCustomObject]@{ - DisplayName = "Tenant Root" - Name = $TenantId - Children = @() - } - - return [PSCustomObject]@{ - RootGroup = $fallbackRoot - SubscriptionMap = @{} - FlatSubs = $subs - } - } -} - -function Build-SubMap { - param( - [object]$Group, - [ref]$Map - ) - - if ($Group.Children) { - foreach ($child in $Group.Children) { - if ($child.Type -eq '/subscriptions') { - $Map.Value[$child.Name] = $Group.DisplayName - } - elseif ($child.Children) { - Build-SubMap -Group $child -Map $Map - } - } - } -} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 deleted file mode 100644 index d5c579505..000000000 --- a/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 +++ /dev/null @@ -1,259 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -########################################################################### -# INITIALIZE-SCANNER.PS1 -# AZURE FINOPS MULTITOOL - Authentication & Prerequisites -########################################################################### -# Purpose: Validate required Az modules, authenticate to Azure, and return -# tenant context for the scanner to operate against. -########################################################################### - -function Show-TenantPicker { - param([object[]]$Tenants) - - Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue - - $pickerXaml = @" - - - - - - - - - - - ") + } + [void]$htmlSb.Append('') + + $orderedMods = @(foreach ($c in $tabCats) { $selectedMods | Where-Object { $_.Category -eq $c } }) + $currentCat = $null + foreach ($mod in $orderedMods) { + if ($mod.Category -ne $currentCat) { + if ($null -ne $currentCat) { [void]$htmlSb.Append('') } + $currentCat = $mod.Category + $paneCls = if ($tabCats[0] -eq $currentCat) { 'tabpane active' } else { 'tabpane' } + $paneId = 'tab-' + ($currentCat -replace '[^A-Za-z0-9]', '') + [void]$htmlSb.Append("
") + } $fn = $mod.Fn $data = $Results[$fn] $eName = [System.Net.WebUtility]::HtmlEncode($mod.Name) @@ -2540,10 +2615,28 @@ tr:hover { background: #161b22; } # Render module-specific summaries + table $htmlRows = $null $htmlCols = $null + $tableNote = $null switch ($fn) { 'Get-OrphanedResources' { - $htmlRows = $data.Orphans - $htmlCols = @('Category', 'ResourceName', 'ResourceGroup', 'Detail') + if ($data.MonthlyCost) { + [void]$htmlSb.Append("

Observed last-month cost: $('{0:C2}' -f [double]$data.MonthlyCost) across $($data.CostedCount) of $($data.TotalCount) resources

") + } + if ($data.CostIssue) { + [void]$htmlSb.Append("
Cost column incomplete: $([System.Net.WebUtility]::HtmlEncode([string]$data.CostIssue)). An empty cost cell below means the lookup failed, not that the resource is free.
") + } + # 'n/a' when the lookup failed, '-' when it succeeded and the resource simply had no spend. + $noCostHtml = if ($data.CostAvailable) { '-' } else { 'n/a' } + $htmlRows = $data.Orphans | ForEach-Object { + [PSCustomObject]@{ + Category = $_.Category + ResourceName = $_.ResourceName + ResourceGroup = $_.ResourceGroup + 'Last month' = if ($null -ne $_.MonthlyCost) { '{0:C2}' -f [double]$_.MonthlyCost } else { $noCostHtml } + Detail = $_.Detail + } + } + $htmlCols = @('Category', 'ResourceName', 'ResourceGroup', 'Last month', 'Detail') + $tableNote = 'Last month is actual billed cost for that resource over the previous full calendar month, not a projection. Deleting it avoids recurring charges such as disks and reserved IPs. A resource stopped part way through last month shows the cost it incurred while still running, so the ongoing saving is lower than the figure shown.' } 'Get-IdleVMs' { [void]$htmlSb.Append("

Scanned $($data.ScannedVMs) running VMs

") @@ -2595,6 +2688,7 @@ tr:hover { background: #161b22; } } } $htmlCols = @('Tag', 'Value', 'Cost') + $tableNote = 'Each tag is measured on its own, so a resource missing that tag counts as (untagged) for it and appears once per tag it lacks. Costs overlap between tags and do not sum to total spend.' } } 'Get-CostTrend' { @@ -2605,10 +2699,54 @@ tr:hover { background: #161b22; } } 'Get-ReservationAdvice' { if ($data.EstimatedAnnualSavings) { [void]$htmlSb.Append("

Est. annual savings: `$$($data.EstimatedAnnualSavings.ToString('N0'))

") } + + # Wrapping a null in @() yields a one-element array, so filter before counting. + $rrRows = @($data.ReservationRecommendations | Where-Object { $_ }) + if ($rrRows.Count -gt 0) { + [void]$htmlSb.Append('

Reservation purchase detail

') + [void]$htmlSb.Append('') + foreach ($c in @('SKU', 'Resource type', 'Region', 'Qty', 'Term', 'Lookback', 'Cost without RI', 'Cost with RI', 'Net savings')) { + [void]$htmlSb.Append("") + } + [void]$htmlSb.Append('') + foreach ($rr in $rrRows) { + $cells = @( + [string]$rr.SKU + [string]$rr.ResourceType + [string]$rr.Region + [string]$rr.RecommendedQty + [string]$rr.Term + [string]$rr.LookBackPeriod + $(if ($null -ne $rr.CostWithoutRI) { '{0:N2}' -f [double]$rr.CostWithoutRI } else { '-' }) + $(if ($null -ne $rr.CostWithRI) { '{0:N2}' -f [double]$rr.CostWithRI } else { '-' }) + $(if ($null -ne $rr.NetSavings) { '{0:N2}' -f [double]$rr.NetSavings } else { '-' }) + ) + [void]$htmlSb.Append('') + for ($ci = 0; $ci -lt $cells.Count; $ci++) { + $cell = [System.Net.WebUtility]::HtmlEncode([string]$cells[$ci]) + if ($ci -eq ($cells.Count - 1) -and $cells[$ci] -ne '-') { $cell = "$cell" } + [void]$htmlSb.Append("") + } + [void]$htmlSb.Append('') + } + [void]$htmlSb.Append('
$([System.Net.WebUtility]::HtmlEncode($c))
$cell
') + [void]$htmlSb.Append('

From the Consumption reservation recommendation API at single-subscription scope over the last 30 days, queried per resource type. Costs are modeled over the lookback window and the API does not return a currency.

') + [void]$htmlSb.Append('

Advisor recommendations

') + } + $htmlRows = $data.AdvisorRecommendations | ForEach-Object { - [PSCustomObject]@{ Resource = ($_.ResourceName -split '/')[-1]; Type = ($_.ResourceType -split '/')[-1]; Term = $_.Term; Savings = '{0:C0}' -f [double]$_.AnnualSavings; Impact = $_.Impact } + [PSCustomObject]@{ + Resource = ($_.ResourceName -split '/')[-1] + Type = ($_.ResourceType -split '/')[-1] + SKU = $_.SKU + Region = $_.Region + Qty = $_.Qty + Term = $_.Term + Savings = '{0:C0}' -f [double]$_.AnnualSavings + Impact = $_.Impact + } } - $htmlCols = @('Resource', 'Type', 'Term', 'Savings', 'Impact') + $htmlCols = @('Resource', 'Type', 'SKU', 'Region', 'Qty', 'Term', 'Savings', 'Impact') } 'Get-CommitmentUtilization' { if ($data.HasData) { @@ -2674,7 +2812,16 @@ tr:hover { background: #161b22; } [void]$htmlSb.Append('') foreach ($c in $htmlCols) { $val = $r.$c - $enc = [System.Net.WebUtility]::HtmlEncode([string]$val) + $raw = [string]$val + $tdAttr = '' + if ($raw.Length -gt 60) { + # Keep the full value reachable on hover rather than blowing out the column. + $tdAttr = " title=`"$([System.Net.WebUtility]::HtmlEncode($raw))`"" + $enc = [System.Net.WebUtility]::HtmlEncode($raw.Substring(0, 57)) + '…' + } + else { + $enc = [System.Net.WebUtility]::HtmlEncode($raw) + } # Colorize money values and risk/severity if ($enc -match '^\$') { $enc = "$enc" } if ($c -eq 'Risk' -and $r.PSObject.Properties['_riskClass']) { $enc = "$enc" } @@ -2682,11 +2829,14 @@ tr:hover { background: #161b22; } $impClass = switch ($val) { 'High' { 'severity-red' } 'Medium' { 'severity-yellow' } default { 'severity-green' } } $enc = "$enc" } - [void]$htmlSb.Append("$enc") + [void]$htmlSb.Append("$enc") } [void]$htmlSb.Append('') } [void]$htmlSb.Append('') + if ($tableNote) { + [void]$htmlSb.Append("

$([System.Net.WebUtility]::HtmlEncode($tableNote))

") + } } # Render guidance @@ -2708,8 +2858,27 @@ tr:hover { background: #161b22; } } } - [void]$htmlSb.Append('
Generated by FinOps Multitool — part of the FinOps Toolkit
') - [void]$htmlSb.Append('') + if ($null -ne $currentCat) { [void]$htmlSb.Append('
') } + [void]$htmlSb.Append('') + [void]$htmlSb.Append('
') + [void]$htmlSb.Append(@' + + +'@) $htmlPath = Join-Path $exportDir 'FinOpsReport.html' $htmlSb.ToString() | Out-File -FilePath $htmlPath -Encoding utf8 diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 index c2402202e..5dcc93fc5 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -30,7 +30,7 @@ resources | where type =~ 'microsoft.compute/disks' | where managedBy == '' or isnull(managedBy) | where properties.diskState == 'Unattached' -| project name, resourceGroup, subscriptionId, location, +| project id, name, resourceGroup, subscriptionId, location, diskSizeGb = properties.diskSizeGB, sku = sku.name, diskState = properties.diskState, type = 'Orphaned Disk' @@ -39,17 +39,19 @@ resources $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allOrphans.Add([PSCustomObject]@{ - Category = 'Orphaned Disk' - ResourceName = $r.name - ResourceGroup = $r.resourceGroup - SubscriptionId = $r.subscriptionId - Location = $r.location - Detail = "$($r.diskSizeGb) GB ($($r.sku))" - Impact = 'Medium' - }) + Category = 'Orphaned Disk' + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.diskSizeGb) GB ($($r.sku))" + Impact = 'Medium' + }) } Write-Host " Orphaned disks: $($rows.Count)" -ForegroundColor Gray - } catch { + } + catch { Write-Warning " Orphaned disk query failed: $($_.Exception.Message)" } @@ -60,7 +62,7 @@ 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, +| project id, name, resourceGroup, subscriptionId, location, sku = sku.name, ipAddress = properties.ipAddress, allocationMethod = properties.publicIPAllocationMethod, type = 'Unattached Public IP' @@ -69,17 +71,19 @@ resources $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allOrphans.Add([PSCustomObject]@{ - Category = 'Unattached Public IP' - ResourceName = $r.name - ResourceGroup = $r.resourceGroup - SubscriptionId = $r.subscriptionId - Location = $r.location - Detail = "$($r.sku) - $($r.allocationMethod)" - Impact = if ($r.sku -eq 'Standard') { 'Medium' } else { 'Low' } - }) + Category = 'Unattached Public IP' + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.sku) - $($r.allocationMethod)" + Impact = if ($r.sku -eq 'Standard') { 'Medium' } else { 'Low' } + }) } Write-Host " Unattached public IPs: $($rows.Count)" -ForegroundColor Gray - } catch { + } + catch { Write-Warning " Unattached public IP query failed: $($_.Exception.Message)" } @@ -90,7 +94,7 @@ 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, +| project id, name, resourceGroup, subscriptionId, location, enableAcceleratedNetworking = properties.enableAcceleratedNetworking, type = 'Unattached NIC' "@ @@ -98,17 +102,19 @@ resources $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allOrphans.Add([PSCustomObject]@{ - Category = 'Unattached NIC' - ResourceName = $r.name - ResourceGroup = $r.resourceGroup - SubscriptionId = $r.subscriptionId - Location = $r.location - Detail = "Accelerated: $($r.enableAcceleratedNetworking)" - Impact = 'Low' - }) + Category = 'Unattached NIC' + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "Accelerated: $($r.enableAcceleratedNetworking)" + Impact = 'Low' + }) } Write-Host " Unattached NICs: $($rows.Count)" -ForegroundColor Gray - } catch { + } + catch { Write-Warning " Unattached NIC query failed: $($_.Exception.Message)" } @@ -119,7 +125,7 @@ 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, +| project id, name, resourceGroup, subscriptionId, location, vmSize = properties.hardwareProfile.vmSize, powerState = properties.extended.instanceView.powerState.displayStatus, type = 'Deallocated VM' @@ -128,17 +134,19 @@ resources $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allOrphans.Add([PSCustomObject]@{ - Category = 'Deallocated VM' - ResourceName = $r.name - ResourceGroup = $r.resourceGroup - SubscriptionId = $r.subscriptionId - Location = $r.location - Detail = "$($r.vmSize) - still incurs disk/IP costs" - Impact = 'Medium' - }) + Category = 'Deallocated VM' + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.vmSize) - still incurs disk/IP costs" + Impact = 'Medium' + }) } Write-Host " Deallocated VMs: $($rows.Count)" -ForegroundColor Gray - } catch { + } + catch { Write-Warning " Deallocated VM query failed: $($_.Exception.Message)" } @@ -149,7 +157,7 @@ resources | where type =~ 'microsoft.web/serverfarms' | where properties.numberOfSites == 0 | where sku.tier != 'Free' and sku.tier != 'Shared' -| project name, resourceGroup, subscriptionId, location, +| project id, name, resourceGroup, subscriptionId, location, sku = strcat(sku.tier, ' / ', sku.name), workers = properties.numberOfWorkers, type = 'Empty App Service Plan' @@ -158,17 +166,19 @@ resources $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allOrphans.Add([PSCustomObject]@{ - Category = 'Empty App Service Plan' - ResourceName = $r.name - ResourceGroup = $r.resourceGroup - SubscriptionId = $r.subscriptionId - Location = $r.location - Detail = "$($r.sku), $($r.workers) worker(s), 0 apps" - Impact = 'High' - }) + Category = 'Empty App Service Plan' + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.sku), $($r.workers) worker(s), 0 apps" + Impact = 'High' + }) } Write-Host " Empty App Service Plans: $($rows.Count)" -ForegroundColor Gray - } catch { + } + catch { Write-Warning " Empty ASP query failed: $($_.Exception.Message)" } @@ -179,7 +189,7 @@ resources resources | where type =~ 'microsoft.compute/snapshots' | where properties.timeCreated < datetime('$snapshotCutoff') -| project name, resourceGroup, subscriptionId, location, +| project id, name, resourceGroup, subscriptionId, location, diskSizeGb = properties.diskSizeGB, timeCreated = properties.timeCreated, type = 'Old Snapshot' @@ -188,20 +198,91 @@ resources $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allOrphans.Add([PSCustomObject]@{ - Category = 'Old Snapshot (30d+)' - ResourceName = $r.name - ResourceGroup = $r.resourceGroup - SubscriptionId = $r.subscriptionId - Location = $r.location - Detail = "$($r.diskSizeGb) GB, created $($r.timeCreated)" - Impact = 'Low' - }) + Category = 'Old Snapshot (30d+)' + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.diskSizeGb) GB, created $($r.timeCreated)" + Impact = 'Low' + }) } Write-Host " Old snapshots (30d+): $($rows.Count)" -ForegroundColor Gray - } catch { + } + catch { Write-Warning " Snapshot query failed: $($_.Exception.Message)" } + # -- Observed cost per orphan (best effort) --------------------------- + # Cost Management is a separate grant from Reader, so a denial here leaves + # MonthlyCost null instead of failing the scan. TheLastMonth is used rather + # than month-to-date so the figure is a whole month of spend. + $costMap = @{} + $costFailures = [System.Collections.Generic.List[string]]::new() + $costQueried = 0 + if ($allOrphans.Count -gt 0) { + foreach ($sub in $Subscriptions) { + try { + $costBody = @{ + type = 'ActualCost' + timeframe = 'TheLastMonth' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'ResourceId' }) + } + } | ConvertTo-Json -Depth 10 + + $costResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" -Method POST -Payload $costBody + if (-not $costResp -or $costResp.StatusCode -ne 200) { + $code = if ($costResp) { [string]$costResp.StatusCode } else { 'no response' } + $reason = switch ($code) { + '429' { 'rate limited by Cost Management' } + '401' { 'not authorized for Cost Management' } + '403' { 'not authorized for Cost Management' } + default { "Cost Management returned $code" } + } + [void]$costFailures.Add("$($sub.Name): $reason") + continue + } + $costQueried++ + + $costResult = ($costResp.Content | ConvertFrom-Json) + $costCols = @{} + for ($cIdx = 0; $cIdx -lt $costResult.properties.columns.Count; $cIdx++) { + $costCols[$costResult.properties.columns[$cIdx].name] = $cIdx + } + foreach ($costRow in $costResult.properties.rows) { + $rid = [string]$costRow[$costCols['ResourceId']] + # Resource Graph and Cost Management disagree on ID casing. + if ($rid) { $costMap[$rid.ToLowerInvariant()] = [math]::Round([double]$costRow[$costCols['Cost']], 2) } + } + } + catch { + [void]$costFailures.Add("$($sub.Name): $($_.Exception.Message)") + Write-Verbose "Orphan cost lookup failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + foreach ($orphan in $allOrphans) { + $ridKey = if ($orphan.ResourceId) { ([string]$orphan.ResourceId).ToLowerInvariant() } else { $null } + $orphanCost = if ($ridKey -and $costMap.ContainsKey($ridKey)) { $costMap[$ridKey] } else { $null } + $orphan | Add-Member -NotePropertyName MonthlyCost -NotePropertyValue $orphanCost -Force + } + + $costed = @($allOrphans | Where-Object { $null -ne $_.MonthlyCost }) + $totalMonthlyCost = if ($costed.Count -gt 0) { [math]::Round((($costed | Measure-Object -Property MonthlyCost -Sum).Sum), 2) } else { $null } + $costAvailable = ($costQueried -gt 0) + $costIssue = if ($costFailures.Count -gt 0) { ($costFailures | Select-Object -Unique) -join '; ' } else { $null } + if ($costed.Count -gt 0) { + Write-Host " Observed last-month cost on $($costed.Count) of $($allOrphans.Count) orphans." -ForegroundColor Gray + } + if ($costIssue) { + Write-Host " Cost lookup incomplete - $costIssue" -ForegroundColor Yellow + } + # -- Summary by category -- $summary = $allOrphans | Group-Object Category | ForEach-Object { [PSCustomObject]@{ @@ -211,9 +292,13 @@ resources } return [PSCustomObject]@{ - Orphans = @($allOrphans) - Summary = @($summary) - TotalCount = $allOrphans.Count - HasData = ($allOrphans.Count -gt 0) + Orphans = @($allOrphans) + Summary = @($summary) + TotalCount = $allOrphans.Count + HasData = ($allOrphans.Count -gt 0) + MonthlyCost = $totalMonthlyCost + CostedCount = $costed.Count + CostAvailable = $costAvailable + CostIssue = $costIssue } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 index bbfa6b38b..4eabbf052 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 @@ -79,31 +79,32 @@ advisorresources # commitment rather than a bare ID with missing data. RIs keep their # real SKU/region/qty. The savings come straight from Advisor. $isSavingsPlan = ($row.shortDescriptionSolution -match '(?i)savings plan') -or ($row.shortDescriptionProblem -match '(?i)savings plan') - $isSubScope = $row.impactedField -match '(?i)subscriptions/subscriptions' + $isSubScope = $row.impactedField -match '(?i)subscriptions/subscriptions' $resName = if ($isSubScope) { "$subName (subscription-wide)" } else { $row.impactedValue } - $sku = if ($row.displaySKU) { $row.displaySKU } elseif ($isSavingsPlan) { 'Any (flexible)' } else { '-' } - $region = if ($row.region) { $row.region } elseif ($isSavingsPlan) { 'Any' } else { '-' } - $qty = if ($row.displayQty) { $row.displayQty } elseif ($isSavingsPlan) { 'Commitment' } else { '-' } + $sku = if ($row.displaySKU) { $row.displaySKU } elseif ($isSavingsPlan) { 'Any (flexible)' } else { '-' } + $region = if ($row.region) { $row.region } elseif ($isSavingsPlan) { 'Any' } else { '-' } + $qty = if ($row.displayQty) { $row.displayQty } elseif ($isSavingsPlan) { 'Commitment' } else { '-' } [void]$allRecommendations.Add([PSCustomObject]@{ - Subscription = $subName - SubscriptionId = $subId - Problem = $row.shortDescriptionProblem - Solution = $row.shortDescriptionSolution - Impact = $row.impact - Category = 'Reservation / Savings Plan' - ResourceType = $row.impactedField - ResourceName = $resName - SKU = $sku - Region = $region - Qty = $qty - AnnualSavings = $savings - Currency = $row.savingsCurrency - Term = $row.term - RecommendationId = $row.recName - }) + Subscription = $subName + SubscriptionId = $subId + Problem = $row.shortDescriptionProblem + Solution = $row.shortDescriptionSolution + Impact = $row.impact + Category = 'Reservation / Savings Plan' + ResourceType = $row.impactedField + ResourceName = $resName + SKU = $sku + Region = $region + Qty = $qty + AnnualSavings = $savings + Currency = $row.savingsCurrency + Term = $row.term + RecommendationId = $row.recName + }) } - } catch { + } + catch { Write-Warning " Advisor Resource Graph query failed: $($_.Exception.Message)" Write-Warning " Falling back to per-subscription REST calls..." @@ -125,32 +126,34 @@ advisorresources foreach ($item in $riRecs) { $rec = $item.properties $isSavingsPlan = ($rec.shortDescription.solution -match '(?i)savings plan') -or ($rec.shortDescription.problem -match '(?i)savings plan') - $isSubScope = $rec.impactedField -match '(?i)subscriptions/subscriptions' + $isSubScope = $rec.impactedField -match '(?i)subscriptions/subscriptions' $resName = if ($isSubScope) { "$($sub.Name) (subscription-wide)" } else { $rec.impactedValue } - $sku = if ($rec.extendedProperties.displaySKU) { $rec.extendedProperties.displaySKU } elseif ($isSavingsPlan) { 'Any (flexible)' } else { '-' } - $region = if ($rec.extendedProperties.region) { $rec.extendedProperties.region } elseif ($isSavingsPlan) { 'Any' } else { '-' } - $qty = if ($rec.extendedProperties.displayQty) { $rec.extendedProperties.displayQty } elseif ($isSavingsPlan) { 'Commitment' } else { '-' } + $sku = if ($rec.extendedProperties.displaySKU) { $rec.extendedProperties.displaySKU } elseif ($isSavingsPlan) { 'Any (flexible)' } else { '-' } + $region = if ($rec.extendedProperties.region) { $rec.extendedProperties.region } elseif ($isSavingsPlan) { 'Any' } else { '-' } + $qty = if ($rec.extendedProperties.displayQty) { $rec.extendedProperties.displayQty } elseif ($isSavingsPlan) { 'Commitment' } else { '-' } [void]$allRecommendations.Add([PSCustomObject]@{ - Subscription = $sub.Name - SubscriptionId = $sub.Id - Problem = $rec.shortDescription.problem - Solution = $rec.shortDescription.solution - Impact = $rec.impact - Category = 'Reservation / Savings Plan' - ResourceType = $rec.impactedField - ResourceName = $resName - SKU = $sku - Region = $region - Qty = $qty - AnnualSavings = if ($rec.extendedProperties.annualSavingsAmount) { - [math]::Round([double]$rec.extendedProperties.annualSavingsAmount, 2) - } else { $null } - Currency = $rec.extendedProperties.savingsCurrency - Term = $rec.extendedProperties.term - RecommendationId = $item.name - }) + Subscription = $sub.Name + SubscriptionId = $sub.Id + Problem = $rec.shortDescription.problem + Solution = $rec.shortDescription.solution + Impact = $rec.impact + Category = 'Reservation / Savings Plan' + ResourceType = $rec.impactedField + ResourceName = $resName + SKU = $sku + Region = $region + Qty = $qty + AnnualSavings = if ($rec.extendedProperties.annualSavingsAmount) { + [math]::Round([double]$rec.extendedProperties.annualSavingsAmount, 2) + } + else { $null } + Currency = $rec.extendedProperties.savingsCurrency + Term = $rec.extendedProperties.term + RecommendationId = $item.name + }) } - } catch { + } + catch { if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } Write-Warning " Advisor query failed for $($sub.Name): $($_.Exception.Message)" } @@ -158,34 +161,55 @@ advisorresources } # -- Also try the Reservation Recommendation API -------------------- + # Must be subscription scoped: the bare provider path returns 404 + # InvalidResourceType. 'Shared' scope is only valid at billing-account scope + # and returns 422 here. resourceType defaults to VirtualMachines, so every + # other type has to be requested by name or it is silently absent. + $rrResourceTypes = @( + 'VirtualMachines', 'SQLDatabases', 'PostgreSQL', 'ManagedDisk', 'MySQL', + 'RedHat', 'MariaDB', 'RedisCache', 'CosmosDB', 'SqlDataWarehouse', + 'SUSELinux', 'AppService', 'BlockBlob', 'AzureDataExplorer', 'VMwareCloudSimple' + ) $reservationRecs = [System.Collections.Generic.List[PSCustomObject]]::new() - try { - $rrPath = "/providers/Microsoft.Consumption/reservationRecommendations?api-version=2023-05-01&`$filter=properties/scope eq 'Shared' and properties/lookBackPeriod eq 'Last30Days'" - $rrResp = Invoke-AzRestMethodWithRetry -Path $rrPath -Method GET - if ($rrResp -and $rrResp.StatusCode -in @(401, 403)) { $accessDenied = $true } - if (-not $rrResp -or -not $rrResp.Content) { throw "Reservation recommendation API returned no content (HTTP $($rrResp.StatusCode))" } - $rrResult = ($rrResp.Content | ConvertFrom-Json) - - if ($rrResult.value) { - foreach ($item in $rrResult.value) { - $props = $item.properties - [void]$reservationRecs.Add([PSCustomObject]@{ - ResourceType = $props.resourceType - SKU = $props.skuProperties.name - RecommendedQty = $props.recommendedQuantity - Term = $props.term - CostWithoutRI = if ($props.costWithNoReservedInstances) { [math]::Round($props.costWithNoReservedInstances, 2) } else { $null } - CostWithRI = if ($props.totalCostWithReservedInstances) { [math]::Round($props.totalCostWithReservedInstances, 2) } else { $null } - NetSavings = if ($props.netSavings) { [math]::Round($props.netSavings, 2) } else { $null } - Currency = $props.currencyCode - Scope = $props.scope - LookBackPeriod = $props.lookBackPeriod - }) + foreach ($sub in $Subscriptions) { + foreach ($rrType in $rrResourceTypes) { + try { + $rrFilter = "properties/scope eq 'Single' and properties/resourceType eq '$rrType' and properties/lookBackPeriod eq 'Last30Days'" + $rrPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/reservationRecommendations?api-version=2023-05-01&`$filter=$rrFilter" + $rrResp = Invoke-AzRestMethodWithRetry -Path $rrPath -Method GET + if ($rrResp -and $rrResp.StatusCode -in @(401, 403)) { $accessDenied = $true; break } + if (-not $rrResp -or $rrResp.StatusCode -ne 200 -or -not $rrResp.Content) { continue } + $rrResult = ($rrResp.Content | ConvertFrom-Json) + if (-not $rrResult.value) { continue } + + foreach ($item in $rrResult.value) { + $props = $item.properties + # Legacy records carry normalizedSize and no resourceType; modern carry skuName. + $rrSku = if ($props.skuName) { $props.skuName } elseif ($props.normalizedSize) { $props.normalizedSize } else { '-' } + [void]$reservationRecs.Add([PSCustomObject]@{ + Subscription = $sub.Name + ResourceType = if ($props.resourceType) { $props.resourceType } else { $rrType } + SKU = $rrSku + Region = $item.location + RecommendedQty = $props.recommendedQuantity + Term = $props.term + CostWithoutRI = if ($null -ne $props.costWithNoReservedInstances) { [math]::Round($props.costWithNoReservedInstances, 2) } else { $null } + CostWithRI = if ($null -ne $props.totalCostWithReservedInstances) { [math]::Round($props.totalCostWithReservedInstances, 2) } else { $null } + NetSavings = if ($null -ne $props.netSavings) { [math]::Round($props.netSavings, 2) } else { $null } + Scope = $props.scope + LookBackPeriod = $props.lookBackPeriod + FlexGroup = $props.instanceFlexibilityGroup + }) + } + } + catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Verbose "Reservation recommendation query failed for $($sub.Name)/$rrType : $($_.Exception.Message)" } } - } catch { - if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } - Write-Warning "Reservation recommendation API query failed (non-critical): $($_.Exception.Message)" + } + if ($reservationRecs.Count -gt 0) { + Write-Host " Retrieved $($reservationRecs.Count) reservation purchase recommendations." -ForegroundColor Cyan } # -- De-duplicate Advisor records ----------------------------------- @@ -222,12 +246,12 @@ advisorresources $denied = ($accessDenied -and $allRecommendations.Count -eq 0 -and $reservationRecs.Count -eq 0) return [PSCustomObject]@{ - AdvisorRecommendations = $allRecommendations + AdvisorRecommendations = $allRecommendations ReservationRecommendations = $reservationRecs - TotalAdvisorCount = $allRecommendations.Count - TotalReservationCount = $reservationRecs.Count - EstimatedAnnualSavings = [math]::Round($totalAnnualSavings, 2) - AccessDenied = $denied - Summary = "$($allRecommendations.Count) Advisor + $($reservationRecs.Count) reservation recommendations" + TotalAdvisorCount = $allRecommendations.Count + TotalReservationCount = $reservationRecs.Count + EstimatedAnnualSavings = [math]::Round($totalAnnualSavings, 2) + AccessDenied = $denied + Summary = "$($allRecommendations.Count) Advisor + $($reservationRecs.Count) reservation recommendations" } } From b92ac4f268db19c934cfc350c56e79e94b4efc23 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 18:29:32 -0600 Subject: [PATCH 091/142] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index ecc258e7d..db1c68a35 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -2590,6 +2590,8 @@ tr:hover td { background: var(--surface); } $data = $Results[$fn] $eName = [System.Net.WebUtility]::HtmlEncode($mod.Name) [void]$htmlSb.Append("

$eName

") + # Anything appended past this point counts as content for the section. + $sectionMark = $htmlSb.Length $errorKey = "_error_$fn" if ($Results.ContainsKey($errorKey)) { @@ -2801,6 +2803,88 @@ tr:hover td { background: var(--surface); } $htmlRows = @($data) | ForEach-Object { [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Currency = $_.Currency; Status = $_.AccountStatus } } $htmlCols = @('Account', 'Agreement', 'Type', 'Currency', 'Status') } + 'Get-BudgetHistory' { + $htmlRows = @($data) | Where-Object { $_ } | ForEach-Object { + [PSCustomObject]@{ + Subscription = $_.Subscription + Budget = $_.BudgetName + Month = $_.Month + Budgeted = '{0:C0}' -f [double]$_.BudgetAmount + Actual = '{0:C0}' -f [double]$_.ActualSpend + PctUsed = "$($_.PctUsed)%" + Status = $_.Status + } + } + $htmlCols = @('Subscription', 'Budget', 'Month', 'Budgeted', 'Actual', 'PctUsed', 'Status') + } + 'Get-CarbonMetrics' { + $cLatest = [System.Net.WebUtility]::HtmlEncode([string]$data.LatestMonth) + $cUnit = [System.Net.WebUtility]::HtmlEncode([string]$data.Unit) + [void]$htmlSb.Append("

Latest month ($cLatest): $($data.TotalEmissionsKg) $cUnit  |  month over month $($data.ChangeRatio)%

") + $htmlRows = $data.BySubscription | Where-Object { $_ } | ForEach-Object { + [PSCustomObject]@{ Subscription = $_.Subscription; Emissions = "$($_.EmissionsKg) kg" } + } + $htmlCols = @('Subscription', 'Emissions') + } + 'Get-UnitEconomics' { + $uCur = [System.Net.WebUtility]::HtmlEncode([string]$data.Currency) + [void]$htmlSb.Append("

Compute: $uCur $($data.ComputeCost) ($($data.ComputeSharePct)%) over $($data.VmCount) VMs, $($data.TotalVCpu) vCPU, $($data.TotalMemoryGb) GB RAM

") + [void]$htmlSb.Append("

Storage: $uCur $($data.StorageCost) ($($data.StorageSharePct)%) over $($data.TotalStorageGb) GB

") + $htmlRows = @( + [PSCustomObject]@{ Metric = 'Cost per vCPU'; Value = "$($data.Currency) $($data.CostPerVCpu)" } + [PSCustomObject]@{ Metric = 'Cost per GB RAM'; Value = "$($data.Currency) $($data.CostPerGbRam)" } + [PSCustomObject]@{ Metric = 'Cost per VM'; Value = "$($data.Currency) $($data.CostPerVm)" } + [PSCustomObject]@{ Metric = 'Cost per GB stored'; Value = "$($data.Currency) $($data.CostPerGb)" } + ) + $htmlCols = @('Metric', 'Value') + if ($data.Note) { $tableNote = [string]$data.Note } + } + 'Get-LegacyResources' { + [void]$htmlSb.Append("

$($data.TotalCount) legacy or retiring resources found

") + $htmlRows = $data.LegacyResources | Where-Object { $_ } | ForEach-Object { + [PSCustomObject]@{ Category = $_.Category; Resource = $_.ResourceName; Detail = $_.Detail; Impact = $_.Impact } + } + $htmlCols = @('Category', 'Resource', 'Detail', 'Impact') + } + 'Get-AIWorkloadMetrics' { + if ($data.HasData) { + $fp = $data.AIFootprint + $aCur = [System.Net.WebUtility]::HtmlEncode([string]$data.Currency) + [void]$htmlSb.Append("

AI footprint — OpenAI/AI Services: $($fp.OpenAIAccounts + $fp.AIServices)  |  ML workspaces: $($fp.MLWorkspaces)  |  AI Search: $($fp.SearchServices)  |  GPU VMs: $($fp.GpuVmCount)

") + [void]$htmlSb.Append("

Tokens (MTD): $($data.TotalTokens) over $($data.TotalRequests) requests  |  AI spend: $aCur $($data.TotalAICost)

") + if ($data.ByModel -and @($data.ByModel | Where-Object { $_ }).Count -gt 0) { + $htmlRows = $data.ByModel + $htmlCols = @('Deployment', 'PromptTokens', 'GeneratedTokens', 'TotalTokens', 'PctOfTokens') + } + elseif ($data.ByAccount -and @($data.ByAccount | Where-Object { $_ }).Count -gt 0) { + $htmlRows = $data.ByAccount + $htmlCols = @('Name', 'Tokens', 'Requests', 'Cost', 'CostPer1KTokens') + } + if ($data.Note) { $tableNote = [string]$data.Note } + } + else { + [void]$htmlSb.Append('
No AI workloads detected.
') + } + } + 'Get-MaccCommitment' { + if ($data.Applicable -and $data.HasMacc -and @($data.Commitments | Where-Object { $_ }).Count -gt 0) { + $htmlRows = @($data.Commitments) | ForEach-Object { + [PSCustomObject]@{ + Account = $_.BillingAccount + Commitment = '{0:C0}' -f [double]$_.Commitment + Consumed = '{0:C0}' -f [double]$_.Consumed + Remaining = '{0:C0}' -f [double]$_.Remaining + PctUsed = "$($_.PctUsed)%" + Status = $_.Status + Expires = $_.ExpirationDate + } + } + $htmlCols = @('Account', 'Commitment', 'Consumed', 'Remaining', 'PctUsed', 'Status', 'Expires') + } + elseif ($data.Reason) { + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$data.Reason))
") + } + } } # Render HTML table @@ -2856,6 +2940,11 @@ tr:hover td { background: var(--surface); } [void]$htmlSb.Append('') } } + + # A scan that produced no table and no summary would otherwise be a bare heading. + if ($htmlSb.Length -eq $sectionMark) { + [void]$htmlSb.Append('
This scan ran but returned nothing to display.
') + } } if ($null -ne $currentCat) { [void]$htmlSb.Append('') } From 55889749d6ad96bcea99151505d355bf5b5c17f6 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 18:36:24 -0600 Subject: [PATCH 092/142] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 26 +++--- .../modules/Get-OrphanedResources.ps1 | 91 ++++++++++++++----- 2 files changed, 82 insertions(+), 35 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index db1c68a35..470062006 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1415,23 +1415,25 @@ function Invoke-FinOpsMultitool { switch ($mod.Fn) { 'Get-OrphanedResources' { if ($data.MonthlyCost) { - Write-Host " Observed last-month cost: $('{0:C2}' -f [double]$data.MonthlyCost) across $($data.CostedCount) of $($data.TotalCount) resources" -ForegroundColor White + Write-Host " Observed cost ($($data.CostPeriod)): $('{0:C2}' -f [double]$data.MonthlyCost) across $($data.CostedCount) of $($data.TotalCount) resources" -ForegroundColor White } if ($data.CostIssue) { Write-Host " Cost column incomplete - $($data.CostIssue)" -ForegroundColor Yellow } # 'n/a' when the lookup failed, '-' when it succeeded and the resource simply had no spend. $noCost = if ($data.CostAvailable) { '-' } else { 'n/a' } + $costCol = if ($data.CostPeriod) { [string]$data.CostPeriod } else { 'Cost' } $rows = $data.Orphans | ForEach-Object { - [PSCustomObject]@{ + $o = [ordered]@{ Category = $_.Category ResourceName = $_.ResourceName ResourceGroup = $_.ResourceGroup - 'Last month' = if ($null -ne $_.MonthlyCost) { '{0:C2}' -f [double]$_.MonthlyCost } else { $noCost } - Detail = $_.Detail } + $o[$costCol] = if ($null -ne $_.MonthlyCost) { '{0:C2}' -f [double]$_.MonthlyCost } else { $noCost } + $o['Detail'] = $_.Detail + [PSCustomObject]$o } - $cols = @('Category', 'ResourceName', 'ResourceGroup', 'Last month', 'Detail') + $cols = @('Category', 'ResourceName', 'ResourceGroup', $costCol, 'Detail') } 'Get-IdleVMs' { $scanned = if ($data.ScannedVMs) { $data.ScannedVMs } else { 0 } @@ -2621,24 +2623,26 @@ tr:hover td { background: var(--surface); } switch ($fn) { 'Get-OrphanedResources' { if ($data.MonthlyCost) { - [void]$htmlSb.Append("

Observed last-month cost: $('{0:C2}' -f [double]$data.MonthlyCost) across $($data.CostedCount) of $($data.TotalCount) resources

") + [void]$htmlSb.Append("

Observed cost ($([System.Net.WebUtility]::HtmlEncode([string]$data.CostPeriod))): $('{0:C2}' -f [double]$data.MonthlyCost) across $($data.CostedCount) of $($data.TotalCount) resources

") } if ($data.CostIssue) { [void]$htmlSb.Append("
Cost column incomplete: $([System.Net.WebUtility]::HtmlEncode([string]$data.CostIssue)). An empty cost cell below means the lookup failed, not that the resource is free.
") } # 'n/a' when the lookup failed, '-' when it succeeded and the resource simply had no spend. $noCostHtml = if ($data.CostAvailable) { '-' } else { 'n/a' } + $costColHtml = if ($data.CostPeriod) { [string]$data.CostPeriod } else { 'Cost' } $htmlRows = $data.Orphans | ForEach-Object { - [PSCustomObject]@{ + $o = [ordered]@{ Category = $_.Category ResourceName = $_.ResourceName ResourceGroup = $_.ResourceGroup - 'Last month' = if ($null -ne $_.MonthlyCost) { '{0:C2}' -f [double]$_.MonthlyCost } else { $noCostHtml } - Detail = $_.Detail } + $o[$costColHtml] = if ($null -ne $_.MonthlyCost) { '{0:C2}' -f [double]$_.MonthlyCost } else { $noCostHtml } + $o['Detail'] = $_.Detail + [PSCustomObject]$o } - $htmlCols = @('Category', 'ResourceName', 'ResourceGroup', 'Last month', 'Detail') - $tableNote = 'Last month is actual billed cost for that resource over the previous full calendar month, not a projection. Deleting it avoids recurring charges such as disks and reserved IPs. A resource stopped part way through last month shows the cost it incurred while still running, so the ongoing saving is lower than the figure shown.' + $htmlCols = @('Category', 'ResourceName', 'ResourceGroup', $costColHtml, 'Detail') + $tableNote = 'Cost is actual billed spend for that resource over the stated period, not a projection. Deleting it avoids recurring charges such as disks and reserved IPs. A resource stopped part way through the period shows what it incurred while still running, so the ongoing saving is lower than the figure shown.' } 'Get-IdleVMs' { [void]$htmlSb.Append("

Scanned $($data.ScannedVMs) running VMs

") diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 index 5dcc93fc5..a06c38723 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -216,36 +216,78 @@ resources # -- Observed cost per orphan (best effort) --------------------------- # Cost Management is a separate grant from Reader, so a denial here leaves - # MonthlyCost null instead of failing the scan. TheLastMonth is used rather - # than month-to-date so the figure is a whole month of spend. + # MonthlyCost null instead of failing the scan. The API rejects the + # TheLastMonth timeframe, so a full previous month needs an explicit Custom + # range, with month-to-date as the fallback. $costMap = @{} $costFailures = [System.Collections.Generic.List[string]]::new() $costQueried = 0 + $costPeriodLabel = $null + $firstOfThisMonth = (Get-Date -Day 1).Date + $lastMonthStart = $firstOfThisMonth.AddMonths(-1) + $lastMonthEnd = $firstOfThisMonth.AddDays(-1) + $costAttempts = @( + @{ + Label = $lastMonthStart.ToString('MMM yyyy') + Body = @{ + type = 'ActualCost' + timeframe = 'Custom' + timePeriod = @{ + from = $lastMonthStart.ToString('yyyy-MM-ddT00:00:00Z') + to = $lastMonthEnd.ToString('yyyy-MM-ddT23:59:59Z') + } + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'ResourceId' }) + } + } + } + @{ + Label = 'Month to date' + Body = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'ResourceId' }) + } + } + } + ) if ($allOrphans.Count -gt 0) { foreach ($sub in $Subscriptions) { - try { - $costBody = @{ - type = 'ActualCost' - timeframe = 'TheLastMonth' - dataset = @{ - granularity = 'None' - aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } - grouping = @(@{ type = 'Dimension'; name = 'ResourceId' }) - } - } | ConvertTo-Json -Depth 10 + $costResp = $null + $usedLabel = $null + $lastCode = 'no response' + foreach ($attempt in $costAttempts) { + # A later subscription reuses whatever period already worked. + if ($costPeriodLabel -and $attempt.Label -ne $costPeriodLabel) { continue } + try { + $body = $attempt.Body | ConvertTo-Json -Depth 10 + $r = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" -Method POST -Payload $body + if ($r -and $r.StatusCode -eq 200) { $costResp = $r; $usedLabel = $attempt.Label; break } + $lastCode = if ($r) { [string]$r.StatusCode } else { 'no response' } + } + catch { + $lastCode = $_.Exception.Message + } + } - $costResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" -Method POST -Payload $costBody - if (-not $costResp -or $costResp.StatusCode -ne 200) { - $code = if ($costResp) { [string]$costResp.StatusCode } else { 'no response' } - $reason = switch ($code) { - '429' { 'rate limited by Cost Management' } - '401' { 'not authorized for Cost Management' } - '403' { 'not authorized for Cost Management' } - default { "Cost Management returned $code" } - } - [void]$costFailures.Add("$($sub.Name): $reason") - continue + if (-not $costResp) { + $reason = switch ($lastCode) { + '429' { 'rate limited by Cost Management' } + '401' { 'not authorized for Cost Management' } + '403' { 'not authorized for Cost Management' } + default { "Cost Management returned $lastCode" } } + [void]$costFailures.Add("$($sub.Name): $reason") + continue + } + + try { + if (-not $costPeriodLabel) { $costPeriodLabel = $usedLabel } $costQueried++ $costResult = ($costResp.Content | ConvertFrom-Json) @@ -277,7 +319,7 @@ resources $costAvailable = ($costQueried -gt 0) $costIssue = if ($costFailures.Count -gt 0) { ($costFailures | Select-Object -Unique) -join '; ' } else { $null } if ($costed.Count -gt 0) { - Write-Host " Observed last-month cost on $($costed.Count) of $($allOrphans.Count) orphans." -ForegroundColor Gray + Write-Host " Observed cost ($costPeriodLabel) on $($costed.Count) of $($allOrphans.Count) orphans." -ForegroundColor Gray } if ($costIssue) { Write-Host " Cost lookup incomplete - $costIssue" -ForegroundColor Yellow @@ -299,6 +341,7 @@ resources MonthlyCost = $totalMonthlyCost CostedCount = $costed.Count CostAvailable = $costAvailable + CostPeriod = $costPeriodLabel CostIssue = $costIssue } } From e6ab672228e679689d70140d9f4ddcf22fdaa2cd Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 19:20:32 -0600 Subject: [PATCH 093/142] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 2 +- .../modules/Get-OrphanedResources.ps1 | 45 ++++++++++++++----- 2 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 470062006..fe0bb7416 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -2642,7 +2642,7 @@ tr:hover td { background: var(--surface); } [PSCustomObject]$o } $htmlCols = @('Category', 'ResourceName', 'ResourceGroup', $costColHtml, 'Detail') - $tableNote = 'Cost is actual billed spend for that resource over the stated period, not a projection. Deleting it avoids recurring charges such as disks and reserved IPs. A resource stopped part way through the period shows what it incurred while still running, so the ongoing saving is lower than the figure shown.' + $tableNote = 'Cost is actual billed spend over the stated period, not a projection. A deallocated VM bills nothing on the VM object itself, so its attached managed disks are rolled into its row - those disks are excluded from the orphaned disk rows above, so nothing is double counted. A resource stopped part way through the period shows what it incurred while still running, so the ongoing saving is lower than the figure shown.' } 'Get-IdleVMs' { [void]$htmlSb.Append("

Scanned $($data.ScannedVMs) running VMs

") diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 index a06c38723..37758b2e7 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -128,20 +128,30 @@ resources | project id, name, resourceGroup, subscriptionId, location, vmSize = properties.hardwareProfile.vmSize, powerState = properties.extended.instanceView.powerState.displayStatus, + osDiskId = tostring(properties.storageProfile.osDisk.managedDisk.id), + dataDisks = properties.storageProfile.dataDisks, type = 'Deallocated VM' "@ $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { + # A stopped VM bills nothing itself; the spend sits on its managed disks. + $vmDiskIds = [System.Collections.Generic.List[string]]::new() + if ($r.osDiskId) { [void]$vmDiskIds.Add([string]$r.osDiskId) } + foreach ($dd in @($r.dataDisks)) { + if ($dd -and $dd.managedDisk -and $dd.managedDisk.id) { [void]$vmDiskIds.Add([string]$dd.managedDisk.id) } + } + $diskLabel = if ($vmDiskIds.Count -eq 1) { '1 disk' } else { "$($vmDiskIds.Count) disks" } [void]$allOrphans.Add([PSCustomObject]@{ - Category = 'Deallocated VM' - ResourceId = $r.id - ResourceName = $r.name - ResourceGroup = $r.resourceGroup - SubscriptionId = $r.subscriptionId - Location = $r.location - Detail = "$($r.vmSize) - still incurs disk/IP costs" - Impact = 'Medium' + Category = 'Deallocated VM' + ResourceId = $r.id + ChildResourceIds = @($vmDiskIds) + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.vmSize) - $diskLabel still billing" + Impact = 'Medium' }) } Write-Host " Deallocated VMs: $($rows.Count)" -ForegroundColor Gray @@ -310,8 +320,23 @@ resources foreach ($orphan in $allOrphans) { $ridKey = if ($orphan.ResourceId) { ([string]$orphan.ResourceId).ToLowerInvariant() } else { $null } - $orphanCost = if ($ridKey -and $costMap.ContainsKey($ridKey)) { $costMap[$ridKey] } else { $null } - $orphan | Add-Member -NotePropertyName MonthlyCost -NotePropertyValue $orphanCost -Force + $ownCost = if ($ridKey -and $costMap.ContainsKey($ridKey)) { $costMap[$ridKey] } else { $null } + + # A deallocated VM bills nothing on its own object, so fold in its disks. + $attachedCost = $null + if ($orphan.PSObject.Properties['ChildResourceIds']) { + foreach ($childId in @($orphan.ChildResourceIds)) { + $childKey = ([string]$childId).ToLowerInvariant() + if ($costMap.ContainsKey($childKey)) { + if ($null -eq $attachedCost) { $attachedCost = 0 } + $attachedCost += $costMap[$childKey] + } + } + } + + $combined = if ($null -eq $ownCost -and $null -eq $attachedCost) { $null } else { [math]::Round(([double]$ownCost + [double]$attachedCost), 2) } + $orphan | Add-Member -NotePropertyName MonthlyCost -NotePropertyValue $combined -Force + $orphan | Add-Member -NotePropertyName AttachedCost -NotePropertyValue $(if ($null -ne $attachedCost) { [math]::Round($attachedCost, 2) } else { $null }) -Force } $costed = @($allOrphans | Where-Object { $null -ne $_.MonthlyCost }) From bd868d17cd31b12f8ca60d58fde2ba8715ab303e Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 19:39:15 -0600 Subject: [PATCH 094/142] Update FinOps Multitool --- .../modules/Get-OrphanedResources.ps1 | 65 ++++++++++++++++--- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 index 37758b2e7..400a2e080 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -55,36 +55,81 @@ resources Write-Warning " Orphaned disk query failed: $($_.Exception.Message)" } - # -- 2: Unattached Public IPs ----------------------------------------- + # -- 2: Idle Public IPs ------------------------------------------------ + # Two distinct cases with different remediation: an IP attached to nothing, + # and an IP still reserved by a stopped VM. Both bill; only the first is + # safe to simply delete. try { $pipQuery = @" resources | where type =~ 'microsoft.network/publicipaddresses' -| where properties.ipConfiguration == '' or isnull(properties.ipConfiguration) -| where properties.natGateway == '' or isnull(properties.natGateway) +| extend ipConfigId = tolower(tostring(properties.ipConfiguration.id)) +| extend natGw = tostring(properties.natGateway.id) | project id, name, resourceGroup, subscriptionId, location, - sku = sku.name, ipAddress = properties.ipAddress, - allocationMethod = properties.publicIPAllocationMethod, - type = 'Unattached Public IP' + sku = tostring(sku.name), + allocationMethod = tostring(properties.publicIPAllocationMethod), + ipAddress = tostring(properties.ipAddress), + ipConfigId, natGw +| join kind=leftouter ( + resources + | where type =~ 'microsoft.network/networkinterfaces' + | mv-expand ipc = properties.ipConfigurations + | project nicName = name, + nicVmId = tolower(tostring(properties.virtualMachine.id)), + ipConfigId = tolower(tostring(ipc.id)) + ) on ipConfigId +| join kind=leftouter ( + resources + | where type =~ 'microsoft.compute/virtualmachines' + | project nicVmId = tolower(tostring(id)), vmName = name, + vmPower = tostring(properties.extended.instanceView.powerState.displayStatus) + ) on nicVmId +| project id, name, resourceGroup, subscriptionId, location, sku, allocationMethod, + ipAddress, ipConfigId, natGw, nicName, vmName, vmPower "@ $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 $rows = if ($result) { @($result.Data) } else { @() } + $pipUnattached = 0 + $pipStoppedVm = 0 foreach ($r in $rows) { + $addr = if ($r.ipAddress) { $r.ipAddress } else { 'no address' } + $held = if ($r.allocationMethod -eq 'Static') { 'address is reserved' } else { 'address is dynamic' } + $isStoppedVm = ($r.vmName -and $r.vmPower -and $r.vmPower -notmatch '(?i)running') + $isUnattached = ([string]::IsNullOrWhiteSpace([string]$r.ipConfigId) -and [string]::IsNullOrWhiteSpace([string]$r.natGw)) + + if ($isStoppedVm) { + $category = 'Public IP on stopped VM' + $detail = "$($r.sku)/$($r.allocationMethod) $addr - held by stopped VM $($r.vmName), $held" + $pipStoppedVm++ + } + elseif ($isUnattached) { + $category = 'Unattached Public IP' + $detail = "$($r.sku)/$($r.allocationMethod) $addr - attached to nothing, $held" + $pipUnattached++ + } + else { + # In use by a load balancer, gateway, Bastion, firewall or running VM. + continue + } + [void]$allOrphans.Add([PSCustomObject]@{ - Category = 'Unattached Public IP' + Category = $category ResourceId = $r.id ResourceName = $r.name ResourceGroup = $r.resourceGroup SubscriptionId = $r.subscriptionId Location = $r.location - Detail = "$($r.sku) - $($r.allocationMethod)" + IpAddress = $r.ipAddress + AttachedTo = if ($isStoppedVm) { $r.vmName } else { $null } + Detail = $detail Impact = if ($r.sku -eq 'Standard') { 'Medium' } else { 'Low' } }) } - Write-Host " Unattached public IPs: $($rows.Count)" -ForegroundColor Gray + Write-Host " Unattached public IPs: $pipUnattached" -ForegroundColor Gray + Write-Host " Public IPs on stopped VMs: $pipStoppedVm" -ForegroundColor Gray } catch { - Write-Warning " Unattached public IP query failed: $($_.Exception.Message)" + Write-Warning " Public IP query failed: $($_.Exception.Message)" } # -- 3: Unattached NICs ----------------------------------------------- From 3d14bbb4c5e519a0d7133335d34b046c708b7120 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 19:53:29 -0600 Subject: [PATCH 095/142] Update FinOps Multitool --- .../modules/helpers/Get-KpiInsights.ps1 | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index 6c4d71e75..1665e00a9 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -63,6 +63,13 @@ function Get-ScanField { # Compute a KPI value from scan data where we have a real formula. Returns # a string value or $null when it cannot be computed (stays informational). +# Display is what a human reads; Value is the same figure as a number so scoring +# never has to recompute (and diverge from) the displayed math. +function New-KpiValue { + param([Parameter(Mandatory)][string]$Display, $Value = $null) + [PSCustomObject]@{ Display = $Display; Value = $Value } +} + function Get-KpiComputedValue { param([string]$KpiId, $Data, $Catalog) @@ -70,20 +77,20 @@ function Get-KpiComputedValue { 'cost-per-gb-stored' { $v = Get-ScanField $Data 'CostPerGb' $cur = Get-ScanField $Data 'Currency' - if ($null -ne $v -and $v -gt 0) { return "$cur $v per GB / month" } + if ($null -ne $v -and $v -gt 0) { return (New-KpiValue "$cur $v per GB / month" ([double]$v)) } } 'hourly-cost-per-cpu-core' { $v = Get-ScanField $Data 'CostPerVCpu' $cur = Get-ScanField $Data 'Currency' if ($null -ne $v -and $v -gt 0) { $hourly = [math]::Round([double]$v / 730, 4) - return "$cur $hourly per vCPU / hour" + return (New-KpiValue "$cur $hourly per vCPU / hour" $hourly) } } 'effective-avg-compute-cost-per-core' { $v = Get-ScanField $Data 'CostPerVCpu' $cur = Get-ScanField $Data 'Currency' - if ($null -ne $v -and $v -gt 0) { return "$cur $v per vCPU / month" } + if ($null -ne $v -and $v -gt 0) { return (New-KpiValue "$cur $v per vCPU / month" ([double]$v)) } } 'commitment-utilization-score' { $ri = Get-ScanField $Data 'RIAvgUtilization' @@ -91,7 +98,7 @@ function Get-KpiComputedValue { $vals = @($ri, $sp) | Where-Object { $null -ne $_ -and $_ -gt 0 } if ($vals.Count -gt 0) { $avg = [math]::Round(($vals | Measure-Object -Average).Average, 1) - return "$avg%" + return (New-KpiValue "$avg%" $avg) } } 'anomaly-detection-rate' { @@ -106,7 +113,8 @@ function Get-KpiComputedValue { $r = if ($null -ne $rules) { [int]$rules } else { 0 } $alertWord = if ($a -eq 1) { 'alert' } else { 'alerts' } $ruleWord = if ($r -eq 1) { 'rule' } else { 'rules' } - return "$a anomaly $alertWord caught, $r detection $ruleWord configured (proxy)" + # Score on rules configured: that is the controllable maturity signal. + return (New-KpiValue "$a anomaly $alertWord caught, $r detection $ruleWord configured (proxy)" $r) } } 'percent-unused-resources' { @@ -115,7 +123,7 @@ function Get-KpiComputedValue { $n = Get-ScanField $Data 'TotalCount' if ($null -ne $n) { $word = if ([int]$n -eq 1) { 'orphaned resource' } else { 'orphaned resources' } - return "$([int]$n) $word" + return (New-KpiValue "$([int]$n) $word" ([int]$n)) } } 'computational-waste' { @@ -124,7 +132,7 @@ function Get-KpiComputedValue { $scanned = Get-ScanField $Data 'ScannedVMs' if ($null -ne $idle -and $null -ne $scanned -and [int]$scanned -gt 0) { $pct = [math]::Round(100 * [int]$idle / [int]$scanned, 1) - return "$pct% of running VMs idle ($([int]$idle) of $([int]$scanned))" + return (New-KpiValue "$pct% of running VMs idle ($([int]$idle) of $([int]$scanned))" $pct) } } 'budget-burn-rate' { @@ -134,7 +142,7 @@ function Get-KpiComputedValue { $pcts = @($budgets | ForEach-Object { $_.PctUsed } | Where-Object { $null -ne $_ }) if ($pcts.Count -gt 0) { $avg = [math]::Round(($pcts | Measure-Object -Average).Average, 1) - return "$avg% of budget consumed (avg across $($pcts.Count))" + return (New-KpiValue "$avg% of budget consumed (avg across $($pcts.Count))" $avg) } } } @@ -147,7 +155,8 @@ function Get-KpiComputedValue { if ($totBudget -and $totBudget -gt 0) { $variance = [math]::Round(100 * ($totActual - $totBudget) / $totBudget, 1) $sign = if ($variance -ge 0) { 'over' } else { 'under' } - return "$([math]::Abs($variance))% $sign budget (actual vs planned)" + # Score on distance from plan in either direction. + return (New-KpiValue "$([math]::Abs($variance))% $sign budget (actual vs planned)" ([math]::Abs($variance))) } } } @@ -158,7 +167,7 @@ function Get-KpiComputedValue { $cur = Get-ScanField $Data 'Currency' if (-not $cur) { $cur = 'USD' } if ($null -ne $monthly -and [double]$monthly -gt 0) { - return "$cur $([math]::Round([double]$monthly, 2)) / month realized (proxy)" + return (New-KpiValue "$cur $([math]::Round([double]$monthly, 2)) / month realized (proxy)" ([math]::Round([double]$monthly, 2))) } } 'pct-compute-covered-by-commitment' { @@ -176,7 +185,7 @@ function Get-KpiComputedValue { $base = [double]$committed + [double]$onDemand $detail = " ($cur $([math]::Round([double]$committed, 0)) committed of $cur $([math]::Round($base, 0)) eligible)" } - return "$cov% covered by commitments$detail" + return (New-KpiValue "$cov% covered by commitments$detail" ([double]$cov)) } } 'token-consumption-metrics' { @@ -186,7 +195,7 @@ function Get-KpiComputedValue { if (-not $cur) { $cur = 'USD' } if ($null -ne $tokens -and [long]$tokens -gt 0) { $costStr = if ($null -ne $cost -and [double]$cost -gt 0) { " for $cur $([math]::Round([double]$cost, 2)) (MTD)" } else { '' } - return "$('{0:N0}' -f [long]$tokens) tokens$costStr" + return (New-KpiValue "$('{0:N0}' -f [long]$tokens) tokens$costStr" ([long]$tokens)) } } 'cost-per-api-call' { @@ -194,7 +203,7 @@ function Get-KpiComputedValue { $cur = Get-ScanField $Data 'Currency' if (-not $cur) { $cur = 'USD' } if ($null -ne $cpr -and [double]$cpr -gt 0) { - return "$cur $([math]::Round([double]$cpr, 5)) per AI request" + return (New-KpiValue "$cur $([math]::Round([double]$cpr, 5)) per AI request" ([math]::Round([double]$cpr, 5))) } } 'pct-commitment-discount-waste' { @@ -203,7 +212,8 @@ function Get-KpiComputedValue { $vals = @($ri, $sp) | Where-Object { $null -ne $_ -and $_ -gt 0 } if ($vals.Count -gt 0) { $avg = ($vals | Measure-Object -Average).Average - return "$([math]::Round(100 - $avg, 1))%" + $waste = [math]::Round(100 - $avg, 1) + return (New-KpiValue "$waste%" $waste) } } { $_ -in @('pct-costs-untagged', 'pct-costs-unallocated', 'tagging-policy-compliant') } { @@ -216,9 +226,9 @@ function Get-KpiComputedValue { if ($seen -and [double]$seen -gt 0 -and $null -ne $unalloc) { $pct = [math]::Round(100 * [double]$unalloc / [double]$seen, 1) switch ($KpiId) { - 'pct-costs-untagged' { return "$pct% of resource spend carries no allocation tag" } - 'pct-costs-unallocated' { return "$pct% unallocated across all allocation tags" } - 'tagging-policy-compliant' { return "$([math]::Round(100 - $pct, 1))% of resource spend is allocated" } + 'pct-costs-untagged' { return (New-KpiValue "$pct% of resource spend carries no allocation tag" $pct) } + 'pct-costs-unallocated' { return (New-KpiValue "$pct% unallocated across all allocation tags" $pct) } + 'tagging-policy-compliant' { return (New-KpiValue "$([math]::Round(100 - $pct, 1))% of resource spend is allocated" ([math]::Round(100 - $pct, 1))) } } } @@ -250,9 +260,9 @@ function Get-KpiComputedValue { } if ($null -eq $worst) { return $null } # no allocation tags -> stays informational switch ($KpiId) { - 'pct-costs-untagged' { return "$($worst.PctUntag)% untagged (worst allocation tag: '$($worst.Tag)')" } - 'pct-costs-unallocated' { return "$($worst.PctUntag)% unallocated (worst: '$($worst.Tag)')" } - 'tagging-policy-compliant' { return "$([math]::Round(100 - $worst.PctUntag, 1))% compliant (worst: '$($worst.Tag)')" } + 'pct-costs-untagged' { return (New-KpiValue "$($worst.PctUntag)% untagged (worst allocation tag: '$($worst.Tag)')" $worst.PctUntag) } + 'pct-costs-unallocated' { return (New-KpiValue "$($worst.PctUntag)% unallocated (worst: '$($worst.Tag)')" $worst.PctUntag) } + 'tagging-policy-compliant' { return (New-KpiValue "$([math]::Round(100 - $worst.PctUntag, 1))% compliant (worst: '$($worst.Tag)')" ([math]::Round(100 - $worst.PctUntag, 1))) } } } } @@ -286,7 +296,8 @@ function Add-KpiInsights { kpiName = $kpi.name domain = $kpi.domain status = $status - yourValue = $value + yourValue = if ($value) { $value.Display } else { $null } + numericValue = if ($value) { $value.Value } else { $null } plainLanguage = $kpi.plainLanguage exploreHint = $kpi.exploreHint learnMore = $catalog.learnMoreBase From fa57b3d735b77e0416f37d45a977a09885b7d551 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 19:54:18 -0600 Subject: [PATCH 096/142] Update FinOps Multitool --- .../Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index 1665e00a9..f217e9e4a 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -65,6 +65,7 @@ function Get-ScanField { # a string value or $null when it cannot be computed (stays informational). # Display is what a human reads; Value is the same figure as a number so scoring # never has to recompute (and diverge from) the displayed math. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'New-KpiValue builds an in-memory object and changes no state.')] function New-KpiValue { param([Parameter(Mandatory)][string]$Display, $Value = $null) [PSCustomObject]@{ Display = $Display; Value = $Value } From 5478627c2065a7c010d4627a329baaccd56a5f15 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 19:55:11 -0600 Subject: [PATCH 097/142] Update FinOps Multitool --- .../FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index f217e9e4a..81da3eaf1 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -65,8 +65,9 @@ function Get-ScanField { # a string value or $null when it cannot be computed (stays informational). # Display is what a human reads; Value is the same figure as a number so scoring # never has to recompute (and diverge from) the displayed math. -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'New-KpiValue builds an in-memory object and changes no state.')] function New-KpiValue { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory object and changes no state.')] + [CmdletBinding()] param([Parameter(Mandatory)][string]$Display, $Value = $null) [PSCustomObject]@{ Display = $Display; Value = $Value } } From c10ee786ea3885e6fe6b3999bf0e9670821fedcb Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 20:22:42 -0600 Subject: [PATCH 098/142] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 63 ++++++++++++++++++- .../FinOpsMultitool/kpi/kpi-catalog.json | 30 +++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index fe0bb7416..138bcb55e 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1364,6 +1364,9 @@ function Invoke-FinOpsMultitool { # Guidance is built per scan during this pass; the HTML report is written # later, so keep it here rather than recomputing the whole switch. $guidanceByFn = @{} + # KPIs are collected across every scan so the report can retell them by + # FinOps domain rather than scattered under the scan that produced them. + $kpiCollected = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($mod in ($Modules | Where-Object { $_.Selected })) { $data = $Results[$mod.Fn] if (-not $data -or @($data).Count -eq 0) { @@ -1918,6 +1921,11 @@ function Invoke-FinOpsMultitool { if (Get-Command Get-KpiInsightsForResult -ErrorAction SilentlyContinue) { $kpiInsights = @() try { $kpiInsights = @(Get-KpiInsightsForResult -FunctionName $mod.Fn -Output $data) } catch { $kpiInsights = @() } + foreach ($kpi in $kpiInsights) { + if (-not ($kpiCollected | Where-Object { $_.kpiId -eq $kpi.kpiId -and $_.status -eq 'computed' })) { + [void]$kpiCollected.Add($kpi) + } + } if ($kpiInsights.Count -gt 0) { Write-Host "" Write-Host " FinOps KPIs:" -ForegroundColor Cyan @@ -2528,6 +2536,14 @@ tr:hover td { background: var(--surface); } .guidance a { color: var(--blue); text-decoration: none; } .guidance a:hover { text-decoration: underline; } .table-note { color: var(--muted); font-size: 12px; font-style: italic; margin: -14px 0 22px 0; max-width: 74ch; } +.story-intro { font-size: 14px; color: var(--ink); max-width: 78ch; margin: 20px 0 4px 0; } +.story-summary { font-size: 15px; font-weight: 600; color: var(--navy); margin: 10px 0 4px 0; } +.story-detail { font-size: 13px; color: var(--muted); max-width: 78ch; margin: 0 0 14px 0; } +.story-caps { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 0 0 26px 0; } +.kpi { border-left: 4px solid var(--blue); background: var(--surface); border-radius: 0 4px 4px 0; padding: 10px 16px; margin: 0 0 10px 0; max-width: 78ch; } +.kpi-name { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); } +.kpi-value { font-size: 17px; font-weight: 600; color: var(--navy); margin: 2px 0; } +.kpi-plain { font-size: 13px; color: var(--muted); } .no-data { color: var(--muted); font-style: italic; padding: 8px 0; } .money { color: var(--success); font-weight: 600; } .tag-error { background: #FDF3F2; border: 1px solid #F1C9C4; border-left: 4px solid var(--danger); border-radius: 0 4px 4px 0; padding: 11px 16px; margin: 8px 0; } @@ -2570,21 +2586,64 @@ tr:hover td { background: var(--surface); } $presentCats = @($selectedMods | ForEach-Object { $_.Category } | Select-Object -Unique) # A category missing from $catOrder still gets a tab, after the known ones. $tabCats = @($catOrder | Where-Object { $presentCats -contains $_ }) + @($presentCats | Where-Object { $catOrder -notcontains $_ }) + + $storyCatalog = $null + if (Get-Command Get-KpiCatalog -ErrorAction SilentlyContinue) { + try { $storyCatalog = Get-KpiCatalog } catch { $storyCatalog = $null } + } + $hasStory = ($storyCatalog -and @($storyCatalog.domains).Count -gt 0 -and $kpiCollected.Count -gt 0) + [void]$htmlSb.Append('') + if ($hasStory) { + [void]$htmlSb.Append('
') + [void]$htmlSb.Append('

FinOps splits cloud cost work into four domains. Your results are retold here in that order, so the numbers arrive in the sequence a FinOps practice would work through them. Each measure below is a FinOps Foundation KPI.

') + foreach ($dom in $storyCatalog.domains) { + $domKpis = @($kpiCollected | Where-Object { $_.domain -eq $dom.id }) + if ($domKpis.Count -eq 0) { continue } + [void]$htmlSb.Append("

$([System.Net.WebUtility]::HtmlEncode([string]$dom.name))

") + [void]$htmlSb.Append("

$([System.Net.WebUtility]::HtmlEncode([string]$dom.summary))

") + [void]$htmlSb.Append("

$([System.Net.WebUtility]::HtmlEncode([string]$dom.detail))

") + + $measured = @($domKpis | Where-Object { $_.status -eq 'computed' -and $_.yourValue }) + foreach ($kpi in $measured) { + [void]$htmlSb.Append('
') + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$kpi.kpiName))
") + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$kpi.yourValue))
") + if ($kpi.plainLanguage) { + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$kpi.plainLanguage))
") + } + [void]$htmlSb.Append('
') + } + + $notMeasured = @($domKpis | Where-Object { $_.status -ne 'computed' -or -not $_.yourValue }) + if ($notMeasured.Count -gt 0) { + $names = ($notMeasured | ForEach-Object { $_.kpiName }) -join ', ' + [void]$htmlSb.Append("

Also in this domain, not measured by this scan: $([System.Net.WebUtility]::HtmlEncode($names)).

") + } + [void]$htmlSb.Append("

FinOps capabilities: $([System.Net.WebUtility]::HtmlEncode([string]$dom.capabilities))

") + } + $lm = [System.Net.WebUtility]::HtmlEncode([string]$storyCatalog.learnMoreBase) + [void]$htmlSb.Append("

KPI definitions come from the FinOps Foundation: $lm

") + [void]$htmlSb.Append('
') + } + $orderedMods = @(foreach ($c in $tabCats) { $selectedMods | Where-Object { $_.Category -eq $c } }) $currentCat = $null foreach ($mod in $orderedMods) { if ($mod.Category -ne $currentCat) { if ($null -ne $currentCat) { [void]$htmlSb.Append('') } $currentCat = $mod.Category - $paneCls = if ($tabCats[0] -eq $currentCat) { 'tabpane active' } else { 'tabpane' } + $paneCls = if (-not $hasStory -and $tabCats[0] -eq $currentCat) { 'tabpane active' } else { 'tabpane' } $paneId = 'tab-' + ($currentCat -replace '[^A-Za-z0-9]', '') [void]$htmlSb.Append("
") } diff --git a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json index c58c01350..2b4dbb753 100644 --- a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json +++ b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json @@ -2,6 +2,36 @@ "_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/", + "domains": [ + { + "id": "Understand", + "name": "Understand usage and cost", + "summary": "See what you are spending and who is spending it.", + "detail": "The starting point of FinOps. Cost data is brought together, attributed to the teams and workloads that caused it, and made visible enough that surprises get noticed. Without this, every later step is guesswork.", + "capabilities": "Data ingestion, allocation, reporting and analytics, anomaly management" + }, + { + "id": "Quantify", + "name": "Quantify business value", + "summary": "Connect that spend to something the business cares about.", + "detail": "Turns a bill into a unit of value: cost per customer, per transaction, per GB stored. Also where planning, forecasting and budgeting live, so spend can be compared against a plan instead of only against last month.", + "capabilities": "Planning and estimating, forecasting, budgeting, benchmarking, unit economics" + }, + { + "id": "Optimize", + "name": "Optimize usage and cost", + "summary": "Take action to pay less for the same outcome.", + "detail": "Two levers. Rate optimization means paying a lower price for what you already use, through reservations, savings plans and licence benefits. Usage optimization means using less, by removing waste and right-sizing what remains.", + "capabilities": "Architecting for cloud, rate optimization, workload optimization, cloud sustainability" + }, + { + "id": "Manage", + "name": "Manage the FinOps practice", + "summary": "Make it stick, rather than a one-off cleanup.", + "detail": "The operating discipline around the other three: policies that hold tagging and budgets in place, chargeback so costs land with their owners, and the habits that keep this running after the first review.", + "capabilities": "Practice operations, policy and governance, invoicing and chargeback, education and enablement" + } + ], "kpis": [ { "id": "cost-per-gb-stored", From 3c3b3b74939bd281c26d5255d6e0cc7bed175a3e Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 20:37:12 -0600 Subject: [PATCH 099/142] Update FinOps Multitool --- .../Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 138bcb55e..c3931a316 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1922,7 +1922,13 @@ function Invoke-FinOpsMultitool { $kpiInsights = @() try { $kpiInsights = @(Get-KpiInsightsForResult -FunctionName $mod.Fn -Output $data) } catch { $kpiInsights = @() } foreach ($kpi in $kpiInsights) { - if (-not ($kpiCollected | Where-Object { $_.kpiId -eq $kpi.kpiId -and $_.status -eq 'computed' })) { + # One entry per KPI. A computed value replaces an informational one. + $existingKpi = $kpiCollected | Where-Object { $_.kpiId -eq $kpi.kpiId } | Select-Object -First 1 + if (-not $existingKpi) { + [void]$kpiCollected.Add($kpi) + } + elseif ($existingKpi.status -ne 'computed' -and $kpi.status -eq 'computed') { + [void]$kpiCollected.Remove($existingKpi) [void]$kpiCollected.Add($kpi) } } From 3d1439396178014843bcb94f6e33440444d73842 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 20:45:07 -0600 Subject: [PATCH 100/142] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 17 +++++++++---- .../FinOpsMultitool/kpi/kpi-catalog.json | 24 +++++++++---------- .../modules/helpers/Get-KpiInsights.ps1 | 1 + 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index c3931a316..f67c4caa8 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -2550,6 +2550,8 @@ tr:hover td { background: var(--surface); } .kpi-name { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); } .kpi-value { font-size: 17px; font-weight: 600; color: var(--navy); margin: 2px 0; } .kpi-plain { font-size: 13px; color: var(--muted); } +.kpi-next { font-size: 13px; color: var(--ink); margin-top: 6px; } +.kpi-next-label { font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--blue); margin-right: 6px; } .no-data { color: var(--muted); font-style: italic; padding: 8px 0; } .money { color: var(--success); font-weight: 600; } .tag-error { background: #FDF3F2; border: 1px solid #F1C9C4; border-left: 4px solid var(--danger); border-radius: 0 4px 4px 0; padding: 11px 16px; margin: 8px 0; } @@ -2612,7 +2614,7 @@ tr:hover td { background: var(--surface); } if ($hasStory) { [void]$htmlSb.Append('
') - [void]$htmlSb.Append('

FinOps splits cloud cost work into four domains. Your results are retold here in that order, so the numbers arrive in the sequence a FinOps practice would work through them. Each measure below is a FinOps Foundation KPI.

') + [void]$htmlSb.Append('

The FinOps Framework organizes cloud cost management into four domains. This report presents your scan results in that order, so each measure appears alongside the FinOps capability it supports. Every measure below is a FinOps Foundation KPI.

') foreach ($dom in $storyCatalog.domains) { $domKpis = @($kpiCollected | Where-Object { $_.domain -eq $dom.id }) if ($domKpis.Count -eq 0) { continue } @@ -2623,23 +2625,28 @@ tr:hover td { background: var(--surface); } $measured = @($domKpis | Where-Object { $_.status -eq 'computed' -and $_.yourValue }) foreach ($kpi in $measured) { [void]$htmlSb.Append('
') - [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$kpi.kpiName))
") + # The formal FinOps definition sits on the title so the card stays readable. + $defAttr = if ($kpi.definition) { " title=`"$([System.Net.WebUtility]::HtmlEncode([string]$kpi.definition))`"" } else { '' } + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$kpi.kpiName))
") [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$kpi.yourValue))
") if ($kpi.plainLanguage) { [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$kpi.plainLanguage))
") } + if ($kpi.exploreHint) { + [void]$htmlSb.Append("
Next step $([System.Net.WebUtility]::HtmlEncode([string]$kpi.exploreHint))
") + } [void]$htmlSb.Append('
') } $notMeasured = @($domKpis | Where-Object { $_.status -ne 'computed' -or -not $_.yourValue }) if ($notMeasured.Count -gt 0) { $names = ($notMeasured | ForEach-Object { $_.kpiName }) -join ', ' - [void]$htmlSb.Append("

Also in this domain, not measured by this scan: $([System.Net.WebUtility]::HtmlEncode($names)).

") + [void]$htmlSb.Append("

Other KPIs in this domain that this scan didn't measure: $([System.Net.WebUtility]::HtmlEncode($names)). Run the scans that inform them to complete the picture.

") } - [void]$htmlSb.Append("

FinOps capabilities: $([System.Net.WebUtility]::HtmlEncode([string]$dom.capabilities))

") + [void]$htmlSb.Append("

FinOps capabilities in this domain: $([System.Net.WebUtility]::HtmlEncode([string]$dom.capabilities))

") } $lm = [System.Net.WebUtility]::HtmlEncode([string]$storyCatalog.learnMoreBase) - [void]$htmlSb.Append("

KPI definitions come from the FinOps Foundation: $lm

") + [void]$htmlSb.Append("

Domain and capability names follow the FinOps Framework. KPI definitions are published by the FinOps Foundation at $lm.

") [void]$htmlSb.Append('
') } diff --git a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json index 2b4dbb753..db5627a84 100644 --- a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json +++ b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json @@ -6,30 +6,30 @@ { "id": "Understand", "name": "Understand usage and cost", - "summary": "See what you are spending and who is spending it.", - "detail": "The starting point of FinOps. Cost data is brought together, attributed to the teams and workloads that caused it, and made visible enough that surprises get noticed. Without this, every later step is guesswork.", - "capabilities": "Data ingestion, allocation, reporting and analytics, anomaly management" + "summary": "Data acquisition, reporting, analysis, and alerting on top of your cost, usage, and carbon consumption.", + "detail": "This domain is about observability and business intelligence. It brings the data that stakeholders need together (ingestion) into a meaningful breakdown for the organization (allocation). That data can then be reported on (reporting) and monitored to proactively identify and react to issues (anomalies).", + "capabilities": "Data ingestion, Allocation, Reporting and analytics, Anomaly management" }, { "id": "Quantify", "name": "Quantify business value", - "summary": "Connect that spend to something the business cares about.", - "detail": "Turns a bill into a unit of value: cost per customer, per transaction, per GB stored. Also where planning, forecasting and budgeting live, so spend can be compared against a plan instead of only against last month.", - "capabilities": "Planning and estimating, forecasting, budgeting, benchmarking, unit economics" + "summary": "Identifying and breaking down cost, usage, and carbon emissions to stay aligned with organizational plans and measure the return on investment from cloud computing efforts.", + "detail": "This domain is about measuring and maximizing the business value each team and workload gets from the cloud to maximize future potential.", + "capabilities": "Planning and estimating, Forecasting, Budgeting, Benchmarking, Unit economics" }, { "id": "Optimize", "name": "Optimize usage and cost", - "summary": "Take action to pay less for the same outcome.", - "detail": "Two levers. Rate optimization means paying a lower price for what you already use, through reservations, savings plans and licence benefits. Usage optimization means using less, by removing waste and right-sizing what remains.", - "capabilities": "Architecting for cloud, rate optimization, workload optimization, cloud sustainability" + "summary": "Designing and optimizing solutions for efficiency to ensure you get the most out of your cloud investments.", + "detail": "Rate optimization lowers the price paid for resources already in use, through commitment discounts and licensing benefits. Usage optimization reduces what is consumed, by removing waste and rightsizing what remains.", + "capabilities": "Architecting for the cloud, Usage optimization, Rate optimization, Licensing and SaaS, Cloud sustainability" }, { "id": "Manage", "name": "Manage the FinOps practice", - "summary": "Make it stick, rather than a one-off cleanup.", - "detail": "The operating discipline around the other three: policies that hold tagging and budgets in place, chargeback so costs land with their owners, and the habits that keep this running after the first review.", - "capabilities": "Practice operations, policy and governance, invoicing and chargeback, education and enablement" + "summary": "Establishing a clear and consistent vision of FinOps and driving cultural adoption across your organization.", + "detail": "Unlike domains that focus on FinOps tasks to drive efficiency and maximize value, this domain emphasizes managing and supporting your FinOps practice.", + "capabilities": "FinOps education and enablement, FinOps practice operations, Onboarding workloads, Governance, policy, and risk, Invoicing and chargeback, FinOps assessment, FinOps tools and services, Intersecting frameworks" } ], "kpis": [ diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index 81da3eaf1..4a5981b9e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -297,6 +297,7 @@ function Add-KpiInsights { kpiId = $kpi.id kpiName = $kpi.name domain = $kpi.domain + definition = $kpi.definition status = $status yourValue = if ($value) { $value.Display } else { $null } numericValue = if ($value) { $value.Value } else { $null } From 82558ef6b81609b418788f5975235a4f1e20895c Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 24 Aug 2026 22:31:11 -0600 Subject: [PATCH 101/142] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 47 ++++++++++++++++--- .../modules/Get-TagInventory.ps1 | 47 +++++++++++++++++-- 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index f67c4caa8..358c49a32 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1637,12 +1637,23 @@ function Invoke-FinOpsMultitool { } } 'Get-TagInventory' { - Write-Host " Coverage: $($data.TagCoverage)% | $($data.TaggedCount) tagged / $($data.UntaggedCount) untagged | $($data.TagCount) unique tags" -ForegroundColor White + $tagCountText = if ($data.SpellingCount -and $data.SpellingCount -ne $data.TagCount) { "$($data.TagCount) unique tag keys ($($data.SpellingCount) spellings)" } else { "$($data.TagCount) unique tags" } + Write-Host " Coverage: $($data.TagCoverage)% | $($data.TaggedCount) tagged / $($data.UntaggedCount) untagged | $tagCountText" -ForegroundColor White + if ($data.CaseVariants -and @($data.CaseVariants).Count -gt 0) { + Write-Host " Case-variant keys (Azure treats these as one tag):" -ForegroundColor Yellow + foreach ($cv in @($data.CaseVariants)) { + Write-Host " $($cv.TagKey): $($cv.Detail)" -ForegroundColor DarkGray + } + } if ($data.TagNames -and $data.TagNames.Count -gt 0) { $rows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | Select-Object -First 15 | ForEach-Object { - [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; UniqueValues = @($_.Value.Values).Count } + $vals = @($_.Value.Values | Sort-Object ResourceCount -Descending) + $shown = @($vals | Select-Object -First 3 | ForEach-Object { "$($_.Value) ($($_.ResourceCount))" }) + $more = $vals.Count - $shown.Count + $valText = ($shown -join ', ') + $(if ($more -gt 0) { ", +$more more" } else { '' }) + [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; Values = $vals.Count; 'Top values' = $valText } } - $cols = @('Tag', 'Resources', 'UniqueValues') + $cols = @('Tag', 'Resources', 'Values', 'Top values') } } 'Get-TagRecommendations' { @@ -2064,6 +2075,17 @@ function Invoke-FinOpsMultitool { @{ Severity = 'Green'; Message = "Consider adding a 'Criticality' tag for incident response prioritization."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' } ) } + + # Case variants are independent of coverage, so append rather than replace. + if ($data.CaseVariants -and @($data.CaseVariants).Count -gt 0) { + $cvCount = @($data.CaseVariants).Count + $cvWord = if ($cvCount -eq 1) { 'tag key is' } else { 'tag keys are' } + $guidanceItems += @{ Severity = 'Yellow'; Message = "$cvCount $cvWord applied under more than one spelling. Azure resolves tag keys case-insensitively, so these are a single key to Azure, but Resource Graph and cost exports report each spelling separately." } + foreach ($cv in @($data.CaseVariants)) { + $guidanceItems += @{ Severity = 'Yellow'; Message = "$($cv.TagKey): $($cv.Detail). Standardize on one spelling, then retag the others." } + } + $guidanceItems += @{ Severity = 'Yellow'; Message = "Azure Policy 'Require a tag and its value' enforces the key name at deployment, which prevents new variants."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' } + } } 'Get-CostByTag' { if ($data.CostByTag -and $data.CostByTag.Count -gt 0) { @@ -2735,12 +2757,25 @@ tr:hover td { background: var(--surface); } $htmlCols = @('Type', 'Name', 'ResourceGroup', 'Size', 'License', 'Est Savings') } 'Get-TagInventory' { - [void]$htmlSb.Append("

Coverage: $($data.TagCoverage)%  |  $($data.TaggedCount) tagged / $($data.UntaggedCount) untagged  |  $($data.TagCount) unique tags

") + $tagCountHtml = if ($data.SpellingCount -and $data.SpellingCount -ne $data.TagCount) { "$($data.TagCount) unique tag keys ($($data.SpellingCount) spellings)" } else { "$($data.TagCount) unique tags" } + [void]$htmlSb.Append("

Coverage: $($data.TagCoverage)%  |  $($data.TaggedCount) tagged / $($data.UntaggedCount) untagged  |  $tagCountHtml

") + if ($data.CaseVariants -and @($data.CaseVariants).Count -gt 0) { + # Tag keys and values come from customer resources, so encode before emitting. + $cvText = (@($data.CaseVariants) | ForEach-Object { + "$([System.Net.WebUtility]::HtmlEncode([string]$_.TagKey)): $([System.Net.WebUtility]::HtmlEncode([string]$_.Detail))" + }) -join '  •  ' + [void]$htmlSb.Append("
Case-variant tag keys found. Azure resolves tag keys case-insensitively, so these spellings are a single key to Azure, but Resource Graph and cost exports report each one separately. $cvText
") + } if ($data.TagNames) { $htmlRows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | Select-Object -First 15 | ForEach-Object { - [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; UniqueValues = @($_.Value.Values).Count } + $vals = @($_.Value.Values | Sort-Object ResourceCount -Descending) + $shown = @($vals | Select-Object -First 5 | ForEach-Object { "$($_.Value) ($($_.ResourceCount))" }) + $more = $vals.Count - $shown.Count + $valText = ($shown -join ', ') + $(if ($more -gt 0) { ", +$more more" } else { '' }) + [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; Values = $vals.Count; 'Top values' = $valText } } - $htmlCols = @('Tag', 'Resources', 'UniqueValues') + $htmlCols = @('Tag', 'Resources', 'Values', 'Top values') + $tableNote = 'Values are the distinct tag values in use, with the resource count for each. A tag with a single value provides no allocation granularity; a tag with many near-identical values indicates inconsistent tagging.' } } 'Get-CostData' { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 index 9de999c94..54ef6e90c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 @@ -155,10 +155,18 @@ resources if (-not $tagNames.ContainsKey($name)) { $tagNames[$name] = @{ Values = @(); TotalResources = 0 } } - $tagNames[$name].Values += [PSCustomObject]@{ - Value = $row.tagValue - ResourceCount = $row.ResourceCount - ResourceTypes = $row.ResourceTypes + # Case-variant keys fold into one hashtable entry, so the same value can + # arrive twice. Merge on an exact match; a different casing is a different value. + $existingValue = $tagNames[$name].Values | Where-Object { [string]$_.Value -ceq [string]$row.tagValue } | Select-Object -First 1 + if ($existingValue) { + $existingValue.ResourceCount += $row.ResourceCount + } + else { + $tagNames[$name].Values += [PSCustomObject]@{ + Value = $row.tagValue + ResourceCount = $row.ResourceCount + ResourceTypes = $row.ResourceTypes + } } $tagNames[$name].TotalResources += $row.ResourceCount } @@ -229,9 +237,40 @@ resources $taggedCount = [math]::Max(0, $totalCount - $untaggedCount) $tagCoverage = if ($totalCount -gt 0) { [math]::Round(($taggedCount / $totalCount) * 100, 1) } else { 0 } + # -- Case-variant tag keys ------------------------------------------- + # Azure stores tag keys case-preserving but resolves them case-insensitively. + # PowerShell hashtables fold case too, so $tagNames has already merged the + # spellings; only the raw Resource Graph rows still carry them apart. Their + # values stay on separate rows, which is why one value can appear twice. + $caseVariants = @() + $bySpelling = @($allResults) | Group-Object -Property tagName -CaseSensitive + foreach ($fold in ($bySpelling | Group-Object { ([string]$_.Name).ToLowerInvariant() })) { + if ($fold.Count -le 1) { continue } + $variants = @($fold.Group | ForEach-Object { + [PSCustomObject]@{ + Spelling = $_.Name + ResourceCount = (@($_.Group) | Measure-Object -Property ResourceCount -Sum).Sum + } + } | Sort-Object ResourceCount -Descending) + $caseVariants += [PSCustomObject]@{ + TagKey = $fold.Name + VariantCount = $fold.Count + Spellings = @($variants | ForEach-Object { $_.Spelling }) + Variants = $variants + ResourceCount = ($variants | Measure-Object -Property ResourceCount -Sum).Sum + Detail = (($variants | ForEach-Object { "$($_.Spelling) ($($_.ResourceCount))" }) -join ', ') + } + } + $spellingCount = @($bySpelling).Count + if ($caseVariants.Count -gt 0) { + Write-Host " Case-variant tag keys: $($caseVariants.Count) ($spellingCount spellings across $($tagNames.Count) keys)" -ForegroundColor Yellow + } + return [PSCustomObject]@{ TagNames = $tagNames TagCount = $tagNames.Count + SpellingCount = $spellingCount + CaseVariants = @($caseVariants) TagLocations = $tagLocations TotalResources = $totalCount TaggedCount = $taggedCount From e5ab880b9ecaea2e2a7700732f08424e7a5eff91 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 25 Aug 2026 00:03:02 -0600 Subject: [PATCH 102/142] Update FinOps Multitool --- .../modules/helpers/Get-KpiInsights.ps1 | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index 4a5981b9e..87b69fccd 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -138,27 +138,32 @@ function Get-KpiComputedValue { } } 'budget-burn-rate' { - # Average percent of budget consumed across all budgets. + # Unweighted mean of per-budget percentages, so a large budget does not dominate. $budgets = Get-ScanField $Data 'Budgets' if ($budgets) { $pcts = @($budgets | ForEach-Object { $_.PctUsed } | Where-Object { $null -ne $_ }) if ($pcts.Count -gt 0) { $avg = [math]::Round(($pcts | Measure-Object -Average).Average, 1) - return (New-KpiValue "$avg% of budget consumed (avg across $($pcts.Count))" $avg) + $word = if ($pcts.Count -eq 1) { 'budget' } else { 'budgets' } + return (New-KpiValue "$avg% of budget consumed (average of $($pcts.Count) $word)" $avg) } } } 'variance-budget-vs-actual' { - # Total actual vs total budgeted across all budgets. + # Weighted by budget size, unlike budget-burn-rate which averages percentages. $budgets = Get-ScanField $Data 'Budgets' if ($budgets) { $totBudget = ($budgets | Measure-Object -Property Amount -Sum).Sum $totActual = ($budgets | Measure-Object -Property ActualSpend -Sum).Sum + $cur = Get-ScanField $Data 'Currency' + if (-not $cur) { $cur = 'USD' } if ($totBudget -and $totBudget -gt 0) { - $variance = [math]::Round(100 * ($totActual - $totBudget) / $totBudget, 1) - $sign = if ($variance -ge 0) { 'over' } else { 'under' } + $pctOfPlan = [math]::Round(100 * $totActual / $totBudget, 1) + $spend = '{0:N0}' -f [math]::Round([double]$totActual, 0) + $plan = '{0:N0}' -f [math]::Round([double]$totBudget, 0) # Score on distance from plan in either direction. - return (New-KpiValue "$([math]::Abs($variance))% $sign budget (actual vs planned)" ([math]::Abs($variance))) + $variance = [math]::Abs([math]::Round(100 * ($totActual - $totBudget) / $totBudget, 1)) + return (New-KpiValue "Actual is $pctOfPlan% of planned ($cur $spend of $cur $plan, all budgets combined)" $variance) } } } From 736bb279f864ac8c841fd17b826f65c6d27716c3 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 25 Aug 2026 15:34:43 -0600 Subject: [PATCH 103/142] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 50 ++++++- .../modules/helpers/Read-FinOpsHubData.ps1 | 127 ++++++++++++++++++ .../Tests/Unit/HubSizeProbe.Tests.ps1 | 102 ++++++++++++++ 3 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 358c49a32..67e53de5c 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -319,12 +319,11 @@ function Invoke-FinOpsMultitool { $choice = Read-FinOpsAnswer ' Select [1/2/3]: ' switch ($choice) { '1' { - # Large-hub heads-up: the [1] Hub choice uses the scalable - # Kusto engine when the hub has an ADX/Fabric cluster (auto- - # discovered) or FINOPS_HUB_KUSTO_URI is set (ftklocal). If - # neither exists, cost scans fall back to the STORAGE READER, - # which loads cost rows into PowerShell - slow / memory-heavy - # on large hubs. Warn and let the user switch to the live API. + # The [1] Hub choice uses the scalable Kusto engine when the + # hub has an ADX/Fabric cluster (auto-discovered) or + # FINOPS_HUB_KUSTO_URI is set (ftklocal). If neither exists, + # cost scans fall back to the STORAGE READER, which loads cost + # rows into PowerShell. $hubSubIds = @($Subscriptions | ForEach-Object { $_.Id }) $prov = $null try { $prov = Resolve-FOHubProvider -Subscriptions $hubSubIds } catch { } @@ -332,8 +331,47 @@ function Invoke-FinOpsMultitool { # A scalable Kusto path exists - no warning needed. return @{ Source = 'Hub'; HubStorage = $hubStorage } } + + # Size the hub before judging the reader. An unmeasurable hub + # is treated as large, so a failed probe never downgrades the + # warning. + $hubSize = @{ Known = $false; Reachable = $true; IsLarge = $true; Display = 'unknown size'; Issue = $null } + if ($hubStorage -and $hubStorage.name) { + try { $hubSize = Measure-FinOpsHubSize -StorageAccountName $hubStorage.name } catch { } + } + + if (-not $hubSize.Reachable) { + # Storage refused access, so the reader cannot run at all. + # Speed is not the problem here; reachability is. + Write-Host "" + Write-Host " This FinOps Hub's storage account is not reachable from here." -ForegroundColor Yellow + Write-Host " Hub cost scans need to read the ingestion container, so they will return nothing." -ForegroundColor DarkGray + Write-Host " Common causes: the storage firewall denies this network, public network access is" -ForegroundColor DarkGray + Write-Host " disabled, or the account is reachable only through a private endpoint." -ForegroundColor DarkGray + Write-Host "" + Write-Host " Use the live Cost Management API instead? " -ForegroundColor White -NoNewline + Write-Host "(N = continue with the Hub anyway)" -ForegroundColor DarkGray + $useApi = Read-FinOpsAnswer ' Select [Y/N]: ' + if ($useApi -notmatch '^(?i)(n|no)$') { + return @{ Source = 'API'; HubStorage = $hubStorage } + } + return @{ Source = 'Hub'; HubStorage = $hubStorage } + } + + if (-not $hubSize.IsLarge) { + # Small enough for the reader. Still name it the small-dataset + # path so it is never mistaken for the scalable engine. + Write-Host "" + Write-Host " Using the FinOps Hub storage reader (small-dataset path; $($hubSize.Display))." -ForegroundColor DarkGray + Write-Host " Larger hubs should query Kusto: deploy ADX/Fabric, or set FINOPS_HUB_KUSTO_URI (ftklocal)." -ForegroundColor DarkGray + return @{ Source = 'Hub'; HubStorage = $hubStorage } + } + Write-Host "" Write-Host " Note: this FinOps Hub has no Azure Data Explorer (Kusto) cluster." -ForegroundColor Yellow + if ($hubSize.Known) { + Write-Host " Ingestion data measured at $($hubSize.Display)." -ForegroundColor Yellow + } Write-Host " Cost scans will use the storage reader, which loads cost rows into" -ForegroundColor DarkGray Write-Host " memory. On a large hub (tens of GB) this can be slow or run out of" -ForegroundColor DarkGray Write-Host " memory before completing." -ForegroundColor DarkGray diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index b3690d85c..b2885cc7f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -333,6 +333,133 @@ function Get-FinOpsHubRowSubscriptionId { return ([string]$subId).ToLower() } +function Format-FinOpsByteSize { + param([long]$Bytes) + if ($Bytes -ge 1TB) { return "{0:N1} TB" -f ($Bytes / 1TB) } + if ($Bytes -ge 1GB) { return "{0:N1} GB" -f ($Bytes / 1GB) } + if ($Bytes -ge 1MB) { return "{0:N1} MB" -f ($Bytes / 1MB) } + if ($Bytes -ge 1KB) { return "{0:N1} KB" -f ($Bytes / 1KB) } + return "$Bytes bytes" +} + +function Test-FinOpsHubAccessDenied { + # Separates "storage refused us" from ordinary misses like a month with no + # data, which decides whether the caller reports unreachable or just unknown. + param([string]$Message) + return [bool]($Message -match 'not authorized|AuthorizationFailure|\(403\)|Forbidden|public access is not permitted|no longer allowed|denied') +} + +function Get-FinOpsHubSizeClass { + # Pure classification, deliberately free of I/O so every branch is testable + # without reaching storage. + [CmdletBinding()] + param( + [Parameter()] + [object[]]$Items, + + [Parameter()] + [long]$LargeThresholdBytes = 256MB, + + [Parameter()] + [int]$MaxFiles = 2000, + + # Set when the listing was cut short, which proves "large" on its own. + [Parameter()] + [switch]$Truncated + ) + + $bytes = 0L + $count = 0 + foreach ($item in @($Items)) { + if (-not $item) { continue } + if ($item.IsDirectory) { continue } + $bytes += [long]$item.Length + $count++ + } + + $partial = ($Truncated -or $count -ge $MaxFiles) + $atLeast = if ($partial) { 'at least ' } else { '' } + return @{ + Known = $true + Reachable = $true + Bytes = $bytes + FileCount = $count + IsLarge = ($bytes -ge $LargeThresholdBytes -or $partial) + Display = "$atLeast$(Format-FinOpsByteSize $bytes) across $atLeast$count file(s)" + Issue = $null + } +} + +function Measure-FinOpsHubSize { + # Size probe of the ingestion container, used to decide whether the storage + # reader is a reasonable choice before any data is downloaded. MaxCount bounds + # the listing itself, so a 40 GB hub costs no more to classify than a 40 MB one. + # + # Known = $false means the probe could not run (no permission, no container). + # Callers must treat that as "assume large" - an unknown hub is not a small one. + # Reachable = $false is stronger: storage denied access outright, so the reader + # cannot work at all and the caller should say so rather than warn about speed. + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$StorageAccountName, + + [Parameter()] + [int]$Months = 1, + + # Parquet is columnar and compressed; PSCustomObject rows are neither, so + # in-memory size is a large multiple of what is measured here. This stays + # deliberately conservative rather than modelling that expansion exactly. + [Parameter()] + [long]$LargeThresholdBytes = 256MB, + + # A hub can be large by file count as well as by bytes, and either shape + # makes the reader slow. + [Parameter()] + [int]$MaxFiles = 2000 + ) + + $result = @{ Known = $false; Reachable = $true; Bytes = 0L; FileCount = 0; IsLarge = $true; Display = 'unknown size'; Issue = $null } + + try { $ctx = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount -ErrorAction Stop } + catch { + $result.Reachable = $false + $result.Issue = $_.Exception.Message + return $result + } + + $collected = [System.Collections.Generic.List[object]]::new() + $enumerated = $false + $truncated = $false + $now = Get-Date + + for ($m = 0; $m -lt $Months -and -not $truncated; $m++) { + $d = $now.AddMonths(-$m) + $basePath = "Costs/$($d.ToString('yyyy'))/$($d.ToString('MM'))" + try { + # One over the cap is enough to prove the listing was truncated. + $items = @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'ingestion' -Path $basePath -Recurse -MaxCount ($MaxFiles + 1) -ErrorAction Stop) + $enumerated = $true + foreach ($item in $items) { [void]$collected.Add($item) } + if ($items.Count -gt $MaxFiles) { $truncated = $true } + } + catch { + # A missing month is normal; an auth or network denial is not. + $msg = [string]$_.Exception.Message + if (Test-FinOpsHubAccessDenied -Message $msg) { + $result.Reachable = $false + $result.Issue = $msg + return $result + } + continue + } + } + + if (-not $enumerated) { return $result } + + return Get-FinOpsHubSizeClass -Items $collected.ToArray() -LargeThresholdBytes $LargeThresholdBytes -MaxFiles $MaxFiles -Truncated:$truncated +} + function Read-FinOpsHubData { [CmdletBinding()] param( diff --git a/src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 b/src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 new file mode 100644 index 000000000..fd825ff9c --- /dev/null +++ b/src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'FinOps Hub size probe' { + + # 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 + + function New-TestItem { + param([string]$Name, [long]$Length, [bool]$IsDirectory = $false) + [PSCustomObject]@{ Name = $Name; Length = $Length; IsDirectory = $IsDirectory } + } + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Format-FinOpsByteSize' { + It 'Formats as ' -ForEach @( + @{ Bytes = 512; Expected = '512 bytes' } + @{ Bytes = 2048; Expected = '2.0 KB' } + @{ Bytes = 3355443; Expected = '3.2 MB' } + @{ Bytes = 268435456; Expected = '256.0 MB' } + @{ Bytes = 44989782425; Expected = '41.9 GB' } + ) { + Format-FinOpsByteSize -Bytes $Bytes | Should -Be $Expected + } + } + + Context 'Test-FinOpsHubAccessDenied' { + It 'Treats as denied' -ForEach @( + @{ Case = 'AuthorizationFailure'; Message = 'This request is not authorized to perform this operation.' } + @{ Case = 'explicit 403'; Message = 'Status: 403 (Forbidden)' } + @{ Case = 'public access disabled'; Message = 'Public access is not permitted on this storage account.' } + ) { + Test-FinOpsHubAccessDenied -Message $Message | Should -BeTrue + } + + It 'Does not treat an ordinary miss as denied' { + Test-FinOpsHubAccessDenied -Message 'PathNotFound: the specified path does not exist' | Should -BeFalse + Test-FinOpsHubAccessDenied -Message 'No such host is known.' | Should -BeFalse + } + } + + Context 'Get-FinOpsHubSizeClass' { + It 'Classifies a small hub as not large' { + $r = Get-FinOpsHubSizeClass -Items @( + (New-TestItem -Name 'a.parquet' -Length 1MB) + (New-TestItem -Name 'b.parquet' -Length 2MB) + ) + $r.Known | Should -BeTrue + $r.IsLarge | Should -BeFalse + $r.FileCount | Should -Be 2 + $r.Bytes | Should -Be 3MB + $r.Display | Should -Be '3.0 MB across 2 file(s)' + } + + It 'Classifies a hub over the byte threshold as large' { + $r = Get-FinOpsHubSizeClass -Items @(New-TestItem -Name 'big.parquet' -Length 512MB) + $r.IsLarge | Should -BeTrue + } + + It 'Classifies a hub at the file cap as large even when small in bytes' { + $items = 1..50 | ForEach-Object { New-TestItem -Name "f$_.parquet" -Length 1KB } + $r = Get-FinOpsHubSizeClass -Items $items -MaxFiles 50 + $r.IsLarge | Should -BeTrue + $r.Display | Should -Match 'at least' + } + + It 'Marks a truncated listing as large regardless of measured size' { + $r = Get-FinOpsHubSizeClass -Items @(New-TestItem -Name 'a.parquet' -Length 1KB) -Truncated + $r.IsLarge | Should -BeTrue + $r.Display | Should -Match 'at least' + } + + It 'Excludes directory entries from the size and count' { + $r = Get-FinOpsHubSizeClass -Items @( + (New-TestItem -Name 'folder' -Length 9999 -IsDirectory $true) + (New-TestItem -Name 'a.parquet' -Length 1MB) + ) + $r.FileCount | Should -Be 1 + $r.Bytes | Should -Be 1MB + } + + It 'Handles an empty listing without reporting it large' { + $r = Get-FinOpsHubSizeClass -Items @() + $r.FileCount | Should -Be 0 + $r.IsLarge | Should -BeFalse + } + + It 'Uses the exact threshold boundary' { + (Get-FinOpsHubSizeClass -Items @(New-TestItem -Name 'a' -Length 256MB)).IsLarge | Should -BeTrue + (Get-FinOpsHubSizeClass -Items @(New-TestItem -Name 'a' -Length ((256MB) - 1))).IsLarge | Should -BeFalse + } + } +} From 346c405dae2bf9300c8e188d656c89e5379194b8 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 25 Aug 2026 21:24:52 -0600 Subject: [PATCH 104/142] Update FinOps Multitool --- .../multitool/finops-multitool-overview.md | 6 ++--- .../multitool/start-finopsmultitool.md | 6 ++--- docs/multitool.md | 2 +- .../Private/FinOpsMultitool/README.md | 26 +++++++++---------- 4 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md index 5723f2269..f377d745a 100644 --- a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -3,7 +3,7 @@ title: FinOps multitool overview 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/22/2026 +ms.date: 08/25/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit @@ -13,7 +13,7 @@ ms.reviewer: micflan # 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. +FinOps multitool scans an Azure environment for cost optimization, governance, and FinOps insights, grounded in your live resource state. It reports on cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance. Run it from an interactive terminal, or call it as tools from an AI agent. ## How it works @@ -29,7 +29,7 @@ FinOps multitool runs 30 scan modules against the subscriptions you select and r ## 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. +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 select. ## Why FinOps multitool? diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md index f8664bdf6..a56db95b2 100644 --- a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -3,7 +3,7 @@ title: Start-FinOpsMultitool command 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: 08/24/2026 +ms.date: 08/25/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -13,7 +13,7 @@ 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. Scans cover 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. When you choose to export, the tool writes a CSV file per scan module, a `FinOpsReport.html` summary, and a `ScanSummary.txt` file. The scan modules are read-only. @@ -102,7 +102,7 @@ Use `-NonInteractive` when nothing can answer a prompt, such as a build agent. ## 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).
diff --git a/docs/multitool.md b/docs/multitool.md index 024ff8948..9c8258bc3 100644 --- a/docs/multitool.md +++ b/docs/multitool.md @@ -17,7 +17,7 @@ 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, grounded in your live resource state. It reports on cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance. Run it from an interactive terminal UI, or call it as tools from an AI agent.

New in the FinOps toolkitv15

diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index e54c3d009..2a5045830 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -1,8 +1,8 @@ -# 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. -## Quick Start +## Quick start ```powershell # From the FinOpsMultitool directory @@ -31,13 +31,13 @@ Install Az modules if needed: Install-Module Az.Accounts, Az.Resources, Az.ResourceGraph, Az.Storage -Scope CurrentUser ``` -## How It Works +## How it works ### 1. Authentication On launch, the TUI checks for an existing `Az.Accounts` session. If you're not logged in, it prompts you to run `Connect-AzAccount`. If your account has access to multiple Azure AD tenants, a tenant picker appears so you can select which tenant to scan. It then discovers all accessible subscriptions and lets you select which ones to scan. -### 2. Data Source Selection +### 2. Data source selection If a FinOps Hub is detected in any of your subscriptions, you'll be asked to choose a data source: @@ -49,7 +49,7 @@ If a FinOps Hub is detected in any of your subscriptions, you'll be asked to cho When the **FinOps Hub** source is chosen, the tool prefers the hub's **Kusto database** (Azure Data Explorer / Fabric, or a local ftklocal emulator) and pushes aggregation into the engine, returning only summarized results. This is the scalable path for large customer datasets — it never loads the raw cost rows into PowerShell. See [FinOps Hub data paths](#finops-hub-data-paths) below. The storage-export reader remains as a small-dataset fallback. -### 3. Scan Selection +### 3. Scan selection Arrow-key driven menu to toggle individual scans on/off. All scans are selected by default except Billing Structure. @@ -62,7 +62,7 @@ Arrow-key driven menu to toggle individual scans on/off. All scans are selected | `Enter` | Run selected scans | | `Q` | Quit | -### 4. Scan Execution +### 4. Scan execution Selected scans run sequentially with a progress bar. When a FinOps Hub is available, tag-related scans (Tag Inventory, Cost by Tag) use pre-loaded Hub data instead of API calls — completing in under a second. @@ -89,7 +89,7 @@ Guidance includes FinOps Foundation best practices, actionable next steps, and l Optional exports write to the output path: one CSV file per scan module, a `FinOpsReport.html` summary, and a `ScanSummary.txt` text summary. -## Required Permissions +## Required permissions Each scan module requires specific Azure RBAC roles. The TUI will tell you which role is needed if a scan fails due to missing permissions. @@ -104,7 +104,7 @@ Each scan module requires specific Azure RBAC roles. The TUI will tell you which | Account | Billing Structure, Contract Info | Billing Reader | Billing Account | | Hub (opt.) | All scans via Hub data | Storage Blob Data Reader | Hub Storage Account | -## Available Scans +## Available scans ### Optimization (Resource Graph) @@ -170,7 +170,7 @@ Each scan module requires specific Azure RBAC roles. The TUI will tell you which | Billing Structure | Account hierarchy and enrollment details | | Contract Info | Agreement type, offer, support plan | -## FinOps KPI Coverage +## FinOps KPI coverage The scan modules map directly to [FinOps Foundation KPIs](https://www.finops.org/finops-kpis/). Each scan answers a KPI question directly, and the `finops-multitool` agent skill routes a natural-language question to the matching investigation. A few examples of question → output: @@ -274,7 +274,7 @@ Untagged spend $ 88,200 (12.6%) ← KPI `% Costs from Untagged Resources` = 12.6%. -## FinOps Hub Integration +## FinOps hub integration When a Hub is detected, the tool reads FinOps Hub cost data. This enables: @@ -284,7 +284,7 @@ When a Hub is detected, the tool reads FinOps Hub cost data. This enables: - **Forecast enrichment** — Hub data contains actuals only, so the TUI calls the Cost Management Forecast API to project full-month costs and adds them to Hub actuals (storage path) - **Accurate tag coverage** — Hub only sees resources with cost data. The TUI queries Azure Resource Graph for the true total/untagged resource count and overrides the Hub-derived coverage percentage -### FinOps Hub data paths +### FinOps hub data paths The cost-family scans (Cost Data, Resource Costs, Cost by Tag) read from a FinOps Hub three ways, in priority order. The first two push aggregation **into the engine** and bring back only summarized results — they never load the raw cost rows into PowerShell, so they scale to large customer datasets (tens of GB / hundreds of millions of rows): @@ -305,7 +305,7 @@ Selection is automatic: `FINOPS_HUB_KUSTO_URI` (if set) wins, else a discovered Hub data is loaded once at startup and reused across all scans that need it. -## Scripting (Non-Interactive) +## Scripting (non-interactive) The scan modules can be called directly without the TUI: @@ -321,7 +321,7 @@ $tagInventory = ConvertTo-TagInventoryFromHub -HubData $hubData $costByTag = ConvertTo-CostByTagFromHub -HubData $hubData -ExistingTags $tagInventory.TagNames ``` -## File Structure +## File structure ```text FinOpsMultitool/ From 20c38f367f5b5fed709df68fa8664ffe7f20f4d1 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 25 Aug 2026 21:37:14 -0600 Subject: [PATCH 105/142] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 8 +- .../modules/Get-PolicyInventory.ps1 | 96 +++++++++++++++++++ .../Tests/Unit/PolicyEffect.Tests.ps1 | 92 ++++++++++++++++++ 3 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 67e53de5c..195463470 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -2650,10 +2650,12 @@ tr:hover td { background: var(--surface); } # Per-module sections, grouped into one tab per category $selectedMods = @($Modules | Where-Object { $_.Selected }) - $catOrder = @('Cost Analysis', 'Optimization', 'Commitments', 'Governance', 'Monitoring', 'Advisor', 'AI & ML', 'Sustainability', 'Account') $presentCats = @($selectedMods | ForEach-Object { $_.Category } | Select-Object -Unique) - # A category missing from $catOrder still gets a tab, after the known ones. - $tabCats = @($catOrder | Where-Object { $presentCats -contains $_ }) + @($presentCats | Where-Object { $catOrder -notcontains $_ }) + # Cost Analysis leads; the rest sort alphabetically so a new category + # lands in a predictable spot instead of being appended. + $leadCat = 'Cost Analysis' + $tabCats = @($presentCats | Where-Object { $_ -eq $leadCat }) + + @($presentCats | Where-Object { $_ -ne $leadCat } | Sort-Object) $storyCatalog = $null if (Get-Command Get-KpiCatalog -ErrorAction SilentlyContinue) { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 index fdea7c8f9..7f876eed3 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -14,6 +14,86 @@ # Falls back to per-sub only for small tenants if above fail. ########################################################################### +function Format-PolicyEffectName { + # Azure stores effect names inconsistently across definitions: some authored as + # "modify", others as "AuditIfNotExists". Capitalizing the first character keeps + # one column from mixing both conventions. + param([string]$Effect) + if ([string]::IsNullOrWhiteSpace($Effect)) { return $Effect } + $trimmed = $Effect.Trim() + return $trimmed.Substring(0, 1).ToUpperInvariant() + $trimmed.Substring(1) +} + +function Resolve-PolicyEffect { + # An assignment only carries an effect when it overrides the parameter, which + # is the exception rather than the rule. Everything else has to come from the + # definition, or the report understates what is actually enforced. + # + # Precedence: assignment override, definition literal, definition parameter default. + [CmdletBinding()] + param( + [Parameter()] + [string]$AssignmentEffect, + + [Parameter()] + [object]$Definition, + + # An initiative bundles policies with differing effects, so there is no + # single value to report. That is not the same as an unknown effect. + [Parameter()] + [switch]$IsInitiative + ) + + if (-not [string]::IsNullOrWhiteSpace($AssignmentEffect) -and $AssignmentEffect -ne '-') { + return (Format-PolicyEffectName $AssignmentEffect) + } + if ($IsInitiative) { return 'varies' } + if (-not $Definition) { return '-' } + + # "[parameters('effect')]" defers to the parameter default; a bare word is the effect. + $literal = [string]$Definition.policyRule.then.effect + if (-not [string]::IsNullOrWhiteSpace($literal) -and $literal -notmatch '^\s*\[') { + return (Format-PolicyEffectName $literal) + } + + $default = [string]$Definition.parameters.effect.defaultValue + if (-not [string]::IsNullOrWhiteSpace($default)) { return (Format-PolicyEffectName $default) } + + return '-' +} + +function Get-PolicyDefinitionMap { + # Fetched by resource ID rather than queried. Resource Graph's policyresources + # only returns definitions scoped to the subscriptions being queried, which + # excludes tenant-level built-ins and management-group definitions - and those + # are exactly where inherited assignments point. A GET against the definition + # ID works for all three scopes. + # + # Callers pass only the IDs they could not resolve, deduplicated, so this stays + # proportional to distinct definitions rather than to assignment count. + [CmdletBinding()] + param( + [Parameter()] + [string[]]$DefinitionIds + ) + + $map = @{} + foreach ($id in @($DefinitionIds | Where-Object { $_ } | Select-Object -Unique)) { + try { + $resp = Invoke-AzRestMethodWithRetry -Path "$($id)?api-version=2023-04-01" -Method GET + if ($resp.StatusCode -eq 200) { + $def = $resp.Content | ConvertFrom-Json + if ($def.properties) { $map[[string]$id] = $def.properties } + } + } + catch { + # An unreadable definition just leaves the effect unresolved. + continue + } + } + return $map +} + function Get-PolicyInventory { [CmdletBinding()] param( @@ -275,6 +355,22 @@ policyresources } } + # -- Resolve effects the assignment did not override --------------- + if ($unique.Count -gt 0) { + # Only single policies need a definition lookup; initiatives resolve to 'varies'. + $needsLookup = @($unique | + Where-Object { $_.Origin -ne 'Initiative' -and (-not $_.Effect -or $_.Effect -eq '-') } | + ForEach-Object { $_.PolicyDefId }) + $defMap = if ($needsLookup.Count -gt 0) { Get-PolicyDefinitionMap -DefinitionIds $needsLookup } else { @{} } + + foreach ($a in $unique) { + $override = if ($a.Effect -and $a.Effect -ne '-') { $a.Effect } else { '' } + $a.Effect = Resolve-PolicyEffect -AssignmentEffect $override -Definition $defMap[[string]$a.PolicyDefId] -IsInitiative:($a.Origin -eq 'Initiative') + } + $resolved = @($unique | Where-Object { $_.Effect -ne '-' }).Count + Write-Host " Effects resolved for $resolved of $($unique.Count) assignments." -ForegroundColor Green + } + # -- Compliance totals --------------------------------------------- $totalCompliant = 0 $totalNonCompliant = 0 diff --git a/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 b/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 new file mode 100644 index 000000000..4ce664f36 --- /dev/null +++ b/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 @@ -0,0 +1,92 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Policy effect resolution' { + + # 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 + + function New-TestDefinition { + param([string]$RuleEffect, [string]$ParameterDefault) + $def = [PSCustomObject]@{ + policyRule = [PSCustomObject]@{ then = [PSCustomObject]@{ effect = $RuleEffect } } + parameters = [PSCustomObject]@{ effect = [PSCustomObject]@{ defaultValue = $ParameterDefault } } + } + return $def + } + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Format-PolicyEffectName' { + # Named Raw, not Input: $Input is an automatic variable and silently + # resolves to the pipeline enumerator instead of the test data. + It 'Normalizes to ' -ForEach @( + @{ Raw = 'modify'; Expected = 'Modify' } + @{ Raw = 'deployIfNotExists'; Expected = 'DeployIfNotExists' } + @{ Raw = 'AuditIfNotExists'; Expected = 'AuditIfNotExists' } + @{ Raw = 'deny'; Expected = 'Deny' } + ) { + Format-PolicyEffectName -Effect $Raw | Should -Be $Expected + } + + It 'Leaves an empty value alone' { + Format-PolicyEffectName -Effect '' | Should -BeNullOrEmpty + } + } + + Context 'Precedence' { + It 'Prefers the assignment override over the definition' { + $def = New-TestDefinition -RuleEffect 'Deny' -ParameterDefault 'Disabled' + Resolve-PolicyEffect -AssignmentEffect 'Audit' -Definition $def | Should -Be 'Audit' + } + + It 'Falls back to the definition literal when the assignment is silent' { + $def = New-TestDefinition -RuleEffect 'modify' -ParameterDefault 'Disabled' + Resolve-PolicyEffect -AssignmentEffect '' -Definition $def | Should -Be 'Modify' + } + + It 'Falls back to the parameter default when the rule defers to a parameter' { + $def = New-TestDefinition -RuleEffect "[parameters('effect')]" -ParameterDefault 'AuditIfNotExists' + Resolve-PolicyEffect -AssignmentEffect '' -Definition $def | Should -Be 'AuditIfNotExists' + } + + It 'Treats a dash from the caller as no override' { + $def = New-TestDefinition -RuleEffect 'Deny' -ParameterDefault '' + Resolve-PolicyEffect -AssignmentEffect '-' -Definition $def | Should -Be 'Deny' + } + } + + Context 'Initiatives' { + It 'Reports varies rather than an unknown effect' { + Resolve-PolicyEffect -AssignmentEffect '' -Definition $null -IsInitiative | Should -Be 'varies' + } + + It 'Still honors an explicit override on an initiative assignment' { + Resolve-PolicyEffect -AssignmentEffect 'Deny' -Definition $null -IsInitiative | Should -Be 'Deny' + } + } + + Context 'Unresolvable' { + It 'Returns a dash when no definition is available' { + Resolve-PolicyEffect -AssignmentEffect '' -Definition $null | Should -Be '-' + } + + It 'Returns a dash when the rule defers and no default exists' { + $def = New-TestDefinition -RuleEffect "[parameters('effect')]" -ParameterDefault '' + Resolve-PolicyEffect -AssignmentEffect '' -Definition $def | Should -Be '-' + } + + It 'Does not mistake a parameter expression for a literal effect' { + $def = New-TestDefinition -RuleEffect "[parameters('effect')]" -ParameterDefault 'Deny' + Resolve-PolicyEffect -AssignmentEffect '' -Definition $def | Should -Not -Match '^\[' + } + } +} From f5c1d60836c03395233290988606d80798da7c73 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 25 Aug 2026 21:55:03 -0600 Subject: [PATCH 106/142] Update FinOps Multitool --- .../FinOpsMultitool/Invoke-FinOpsMultitool.ps1 | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 195463470..d645186ef 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -2147,6 +2147,7 @@ function Invoke-FinOpsMultitool { $maxUntaggedTag = '' $seenCost = $data.ResourceCostSeen $unallocCost = $data.UnallocatedCost + $haveCostData = (($seenCost -and [double]$seenCost -gt 0) -or ($data.AllocatedCost -and [double]$data.AllocatedCost -gt 0)) if ($seenCost -and [double]$seenCost -gt 0 -and $null -ne $unallocCost) { $maxUntaggedCost = [double]$unallocCost $maxUntaggedTag = 'any allocation tag' @@ -2162,8 +2163,14 @@ function Invoke-FinOpsMultitool { } } } - if (-not $maxUntaggedTag) { - # No CAF allocation tag present to measure against + if (-not $haveCostData) { + # Nothing to attribute. Blaming the tags here would be wrong: + # the tag inventory is fine, the cost side came back empty. + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "No cost data was returned for this period, so spend cannot be split by tag. Tag coverage itself is unaffected - check the data source, permissions, and that the period has usage." } + ) + } + elseif (@($data.AllocationTags | Where-Object { $_ }).Count -eq 0) { $guidanceItems = @( @{ Severity = 'Yellow'; Message = "No CAF allocation tag (CostCenter, Customer, Project, Environment, Owner, ...) is in use, so spend cannot be attributed. Add an allocation tag and deploy inheritance to make cost traceable." } ) @@ -2849,6 +2856,10 @@ tr:hover td { background: var(--surface); } $htmlRows = $data.Months | ForEach-Object { [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency } } $htmlCols = @('Month', 'Cost', 'Currency') } + else { + # Say why it is empty; the generic fallback reads like the scan failed. + [void]$htmlSb.Append('
No cost data was returned for the trend period. Check the data source, permissions, and that the period has usage.
') + } } 'Get-ReservationAdvice' { if ($data.EstimatedAnnualSavings) { [void]$htmlSb.Append("

Est. annual savings: `$$($data.EstimatedAnnualSavings.ToString('N0'))

") } From fcb24abdd69115ec7101e47a7540ee9999616f44 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 25 Aug 2026 22:10:54 -0600 Subject: [PATCH 107/142] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 19 +++++++-- .../modules/Get-PolicyInventory.ps1 | 6 +++ .../Tests/Unit/HubSizeProbe.Tests.ps1 | 22 +++++----- .../Tests/Unit/PolicyEffect.Tests.ps1 | 42 +++++++++++++++---- 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index d645186ef..5a03e1863 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1359,7 +1359,11 @@ function Invoke-FinOpsMultitool { [hashtable]$Results, [array]$Modules, [string]$ExportPath, - [array]$Subscriptions + [array]$Subscriptions, + + # The source that actually produced the numbers, which is not always the + # one requested: a Hub run that returns nothing falls back to the API. + [string]$DataSourceLabel ) # Build sub ID → name lookup for display functions @@ -2640,7 +2644,7 @@ tr:hover td { background: var(--surface); }
FinOps Toolkit

FinOps Multitool report

-

Generated: $timestamp  |  Subscriptions: $([System.Net.WebUtility]::HtmlEncode($subList))

+

Generated: $timestamp  |  Subscriptions: $([System.Net.WebUtility]::HtmlEncode($subList))$(if ($DataSourceLabel) { "  |  Cost data: $([System.Net.WebUtility]::HtmlEncode($DataSourceLabel))" })

"@) @@ -3248,7 +3252,16 @@ tr:hover td { background: var(--surface); } $results = Invoke-SelectedScans -Modules $finalModules -Subscriptions $subs -TenantId $tenantId -DataSource $sourceChoice # Step 5: Summary + export - $global:FinOpsResults = Show-ResultsSummary -Results $results -Modules $finalModules -ExportPath $OutputPath -Subscriptions $subs + # Re-read the source after the run: Invoke-SelectedScans downgrades Hub to API + # in place when the hub returns nothing, so this is the source that actually + # produced the numbers rather than the one requested. + $effectiveSource = switch ($sourceChoice.Source) { + 'Hub' { "FinOps Hub ($($sourceChoice.HubStorage.name))" } + 'API' { 'Cost Management API (real-time)' } + 'GraphOnly' { 'Resource Graph only (no cost data)' } + default { [string]$sourceChoice.Source } + } + $global:FinOpsResults = Show-ResultsSummary -Results $results -Modules $finalModules -ExportPath $OutputPath -Subscriptions $subs -DataSourceLabel $effectiveSource Write-Host " Done. Results available in `$FinOpsResults" -ForegroundColor Green Write-Host "" diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 index 7f876eed3..747e084e1 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -79,6 +79,12 @@ function Get-PolicyDefinitionMap { $map = @{} foreach ($id in @($DefinitionIds | Where-Object { $_ } | Select-Object -Unique)) { + # The ID is concatenated ahead of a query string, so anything carrying '?', + # '#' or '&' could rewrite the request. Accept only well-formed definition IDs. + # The scope prefix is optional: built-ins start at /providers directly. + if ($id -notmatch '^(/[A-Za-z0-9._\-()/]+)?/providers/Microsoft\.Authorization/policyDefinitions/[A-Za-z0-9._\-()]+$') { + continue + } try { $resp = Invoke-AzRestMethodWithRetry -Path "$($id)?api-version=2023-04-01" -Method GET if ($resp.StatusCode -eq 200) { diff --git a/src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 b/src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 index fd825ff9c..9d56a1b05 100644 --- a/src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 +++ b/src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 @@ -1,4 +1,4 @@ -# Copyright (c) Microsoft Corporation. +# Copyright (c) Microsoft Corporation. # Licensed under the MIT License. & "$PSScriptRoot/../Initialize-Tests.ps1" @@ -11,7 +11,7 @@ Describe 'FinOps Hub size probe' { $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' Import-Module $script:MultitoolModule -Force - function New-TestItem { + function Get-TestItem { param([string]$Name, [long]$Length, [bool]$IsDirectory = $false) [PSCustomObject]@{ Name = $Name; Length = $Length; IsDirectory = $IsDirectory } } @@ -51,8 +51,8 @@ Describe 'FinOps Hub size probe' { Context 'Get-FinOpsHubSizeClass' { It 'Classifies a small hub as not large' { $r = Get-FinOpsHubSizeClass -Items @( - (New-TestItem -Name 'a.parquet' -Length 1MB) - (New-TestItem -Name 'b.parquet' -Length 2MB) + (Get-TestItem -Name 'a.parquet' -Length 1MB) + (Get-TestItem -Name 'b.parquet' -Length 2MB) ) $r.Known | Should -BeTrue $r.IsLarge | Should -BeFalse @@ -62,27 +62,27 @@ Describe 'FinOps Hub size probe' { } It 'Classifies a hub over the byte threshold as large' { - $r = Get-FinOpsHubSizeClass -Items @(New-TestItem -Name 'big.parquet' -Length 512MB) + $r = Get-FinOpsHubSizeClass -Items @(Get-TestItem -Name 'big.parquet' -Length 512MB) $r.IsLarge | Should -BeTrue } It 'Classifies a hub at the file cap as large even when small in bytes' { - $items = 1..50 | ForEach-Object { New-TestItem -Name "f$_.parquet" -Length 1KB } + $items = 1..50 | ForEach-Object { Get-TestItem -Name "f$_.parquet" -Length 1KB } $r = Get-FinOpsHubSizeClass -Items $items -MaxFiles 50 $r.IsLarge | Should -BeTrue $r.Display | Should -Match 'at least' } It 'Marks a truncated listing as large regardless of measured size' { - $r = Get-FinOpsHubSizeClass -Items @(New-TestItem -Name 'a.parquet' -Length 1KB) -Truncated + $r = Get-FinOpsHubSizeClass -Items @(Get-TestItem -Name 'a.parquet' -Length 1KB) -Truncated $r.IsLarge | Should -BeTrue $r.Display | Should -Match 'at least' } It 'Excludes directory entries from the size and count' { $r = Get-FinOpsHubSizeClass -Items @( - (New-TestItem -Name 'folder' -Length 9999 -IsDirectory $true) - (New-TestItem -Name 'a.parquet' -Length 1MB) + (Get-TestItem -Name 'folder' -Length 9999 -IsDirectory $true) + (Get-TestItem -Name 'a.parquet' -Length 1MB) ) $r.FileCount | Should -Be 1 $r.Bytes | Should -Be 1MB @@ -95,8 +95,8 @@ Describe 'FinOps Hub size probe' { } It 'Uses the exact threshold boundary' { - (Get-FinOpsHubSizeClass -Items @(New-TestItem -Name 'a' -Length 256MB)).IsLarge | Should -BeTrue - (Get-FinOpsHubSizeClass -Items @(New-TestItem -Name 'a' -Length ((256MB) - 1))).IsLarge | Should -BeFalse + (Get-FinOpsHubSizeClass -Items @(Get-TestItem -Name 'a' -Length 256MB)).IsLarge | Should -BeTrue + (Get-FinOpsHubSizeClass -Items @(Get-TestItem -Name 'a' -Length ((256MB) - 1))).IsLarge | Should -BeFalse } } } diff --git a/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 b/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 index 4ce664f36..b8a8cfba7 100644 --- a/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 +++ b/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 @@ -1,4 +1,4 @@ -# Copyright (c) Microsoft Corporation. +# Copyright (c) Microsoft Corporation. # Licensed under the MIT License. & "$PSScriptRoot/../Initialize-Tests.ps1" @@ -11,7 +11,7 @@ Describe 'Policy effect resolution' { $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' Import-Module $script:MultitoolModule -Force - function New-TestDefinition { + function Get-TestDefinition { param([string]$RuleEffect, [string]$ParameterDefault) $def = [PSCustomObject]@{ policyRule = [PSCustomObject]@{ then = [PSCustomObject]@{ effect = $RuleEffect } } @@ -44,22 +44,22 @@ Describe 'Policy effect resolution' { Context 'Precedence' { It 'Prefers the assignment override over the definition' { - $def = New-TestDefinition -RuleEffect 'Deny' -ParameterDefault 'Disabled' + $def = Get-TestDefinition -RuleEffect 'Deny' -ParameterDefault 'Disabled' Resolve-PolicyEffect -AssignmentEffect 'Audit' -Definition $def | Should -Be 'Audit' } It 'Falls back to the definition literal when the assignment is silent' { - $def = New-TestDefinition -RuleEffect 'modify' -ParameterDefault 'Disabled' + $def = Get-TestDefinition -RuleEffect 'modify' -ParameterDefault 'Disabled' Resolve-PolicyEffect -AssignmentEffect '' -Definition $def | Should -Be 'Modify' } It 'Falls back to the parameter default when the rule defers to a parameter' { - $def = New-TestDefinition -RuleEffect "[parameters('effect')]" -ParameterDefault 'AuditIfNotExists' + $def = Get-TestDefinition -RuleEffect "[parameters('effect')]" -ParameterDefault 'AuditIfNotExists' Resolve-PolicyEffect -AssignmentEffect '' -Definition $def | Should -Be 'AuditIfNotExists' } It 'Treats a dash from the caller as no override' { - $def = New-TestDefinition -RuleEffect 'Deny' -ParameterDefault '' + $def = Get-TestDefinition -RuleEffect 'Deny' -ParameterDefault '' Resolve-PolicyEffect -AssignmentEffect '-' -Definition $def | Should -Be 'Deny' } } @@ -80,13 +80,39 @@ Describe 'Policy effect resolution' { } It 'Returns a dash when the rule defers and no default exists' { - $def = New-TestDefinition -RuleEffect "[parameters('effect')]" -ParameterDefault '' + $def = Get-TestDefinition -RuleEffect "[parameters('effect')]" -ParameterDefault '' Resolve-PolicyEffect -AssignmentEffect '' -Definition $def | Should -Be '-' } It 'Does not mistake a parameter expression for a literal effect' { - $def = New-TestDefinition -RuleEffect "[parameters('effect')]" -ParameterDefault 'Deny' + $def = Get-TestDefinition -RuleEffect "[parameters('effect')]" -ParameterDefault 'Deny' Resolve-PolicyEffect -AssignmentEffect '' -Definition $def | Should -Not -Match '^\[' } } + + Context 'Definition ID guard' { + # Get-PolicyDefinitionMap concatenates the ID ahead of a query string, so a + # malformed ID could rewrite the request. These assert the accepted shapes. + BeforeAll { + $script:IdPattern = '^(/[A-Za-z0-9._\-()/]+)?/providers/Microsoft\.Authorization/policyDefinitions/[A-Za-z0-9._\-()]+$' + } + + It 'Accepts ' -ForEach @( + @{ Case = 'a built-in definition'; Id = '/providers/Microsoft.Authorization/policyDefinitions/4f9dc7db-30c1-420c-b61a-e1d640128d26' } + @{ Case = 'a subscription-scoped definition'; Id = '/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Authorization/policyDefinitions/my-policy' } + @{ Case = 'a management-group definition'; Id = '/providers/Microsoft.Management/managementGroups/mg1/providers/Microsoft.Authorization/policyDefinitions/abc123' } + ) { + $Id -match $script:IdPattern | Should -BeTrue + } + + It 'Rejects ' -ForEach @( + @{ Case = 'a query-string injection'; Id = '/providers/Microsoft.Authorization/policyDefinitions/x?api-version=2015-01-01&evil=1' } + @{ Case = 'a fragment injection'; Id = '/providers/Microsoft.Authorization/policyDefinitions/x#frag' } + @{ Case = 'a different resource type'; Id = '/subscriptions/abc/providers/Microsoft.Authorization/roleAssignments/x' } + @{ Case = 'an initiative definition'; Id = '/providers/Microsoft.Authorization/policySetDefinitions/abc' } + @{ Case = 'an empty id'; Id = '' } + ) { + $Id -match $script:IdPattern | Should -BeFalse + } + } } From bf2d27ba5d22ca2b21d511dac64a0a2995cced55 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Thu, 27 Aug 2026 16:19:32 -0600 Subject: [PATCH 108/142] Update FinOps Multitool --- .../toolkit/powershell/multitool/start-finopsmultitool.md | 4 ++-- src/powershell/Private/FinOpsMultitool/README.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md index a56db95b2..afc08e3e6 100644 --- a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -3,7 +3,7 @@ title: Start-FinOpsMultitool command 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: 08/25/2026 +ms.date: 08/27/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -17,7 +17,7 @@ The **Start-FinOpsMultitool** command launches the FinOps multitool interactive Results are rendered in the terminal. When you choose to export, the tool writes a CSV file per scan module, a `FinOpsReport.html` summary, and a `ScanSummary.txt` file. The scan modules are read-only. -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 assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. +The command requires PowerShell 7 or later on Windows, macOS, and Linux. 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 assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. The tool prompts for each choice by default. To run it from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters. diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index 2a5045830..de4f5af29 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -20,7 +20,7 @@ Invoke-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' | Requirement | Details | | --------------------- | --------------------------------------------------------------- | -| PowerShell | 5.1+ (Windows) or 7+ (cross-platform) | +| PowerShell | 7.0 or later (Windows, macOS, Linux) | | Az modules | `Az.Accounts`, `Az.Resources`, `Az.ResourceGraph`, `Az.Storage` | | Azure RBAC | Reader + Cost Management Reader on target scope | | FinOps Hub (optional) | Storage Blob Data Reader on Hub storage account | From 215001fbfe15fdae92535c70322a24ac902f0e7d Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 7 Sep 2026 14:01:14 -0600 Subject: [PATCH 109/142] Update FinOps Multitool --- .../Invoke-FinOpsMultitool.ps1 | 3 +- .../Tests/Unit/AgentSkillRouting.Tests.ps1 | 57 +++++++++++++++++++ .../anomaly-investigation/SKILL.md | 2 +- .../azure-workbooks-finops/SKILL.md | 2 +- .../agent-skills/finops-multitool/SKILL.md | 7 +-- .../finops-multitool/references/allocation.md | 2 +- .../references/commitments.md | 7 +-- .../references/cost-analysis.md | 3 +- .../references/waste-detection.md | 20 +++---- .../rate-optimization-portfolio/SKILL.md | 5 +- 10 files changed, 80 insertions(+), 28 deletions(-) create mode 100644 src/powershell/Tests/Unit/AgentSkillRouting.Tests.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 5a03e1863..eb5e12a14 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -172,7 +172,8 @@ function Invoke-FinOpsMultitool { # 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' + # Nested Join-Path, not -AdditionalChildPath: that parameter is PowerShell 7+ only. + $verFile = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'Get-VersionNumber.ps1' if (Test-Path -Path $verFile) { . $verFile } } $verText = if (Get-Command -Name Get-VersionNumber -ErrorAction SilentlyContinue) { "v$(Get-VersionNumber)" } else { '' } diff --git a/src/powershell/Tests/Unit/AgentSkillRouting.Tests.ps1 b/src/powershell/Tests/Unit/AgentSkillRouting.Tests.ps1 new file mode 100644 index 000000000..97dd0794f --- /dev/null +++ b/src/powershell/Tests/Unit/AgentSkillRouting.Tests.ps1 @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Agent skill routing' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../../..')).Path + $script:SkillRoot = Join-Path $script:RepoRoot 'src/templates/agent-skills' + $script:PluginSkillRoot = Join-Path $script:RepoRoot 'src/templates/agent-plugin/skills' + + # A skill only counts as shipped when it has content. An empty directory + # survives on disk but is absent from git, so it reaches nobody. + function Get-ShippedSkill { + param([string]$Root) + if (-not (Test-Path $Root)) { return @() } + Get-ChildItem $Root -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName 'SKILL.md') } | + Select-Object -ExpandProperty Name + } + + $script:Shipped = @(Get-ShippedSkill $script:SkillRoot) + @(Get-ShippedSkill $script:PluginSkillRoot) + + $script:SkillDocs = @( + Get-ChildItem $script:SkillRoot -Recurse -Include '*.md' -File -ErrorAction SilentlyContinue + ) + } + + It 'Finds skill documentation to check' { + $script:SkillDocs.Count | Should -BeGreaterThan 0 + $script:Shipped.Count | Should -BeGreaterThan 0 + } + + It 'Does not route to a skill directory that ships no SKILL.md' { + # Backtick-quoted kebab-case names are how the docs reference sibling skills. + $referenced = $script:SkillDocs | + Select-String -Pattern '`([a-z][a-z0-9]*(?:-[a-z0-9]+)+)`' -AllMatches | + ForEach-Object { $_.Matches } | + ForEach-Object { $_.Value.Trim('`') } | + Sort-Object -Unique + + # Only names that look like a skill folder are routing targets; the same + # pattern also matches things like file names and CLI flags. + $candidates = @($referenced | Where-Object { $_ -match '^(azure|finops|cost|unit|anomaly|forecasting|focus|sustainability|power|rate)-' }) + + $dead = @($candidates | Where-Object { $script:Shipped -notcontains $_ }) + $dead | Should -BeNullOrEmpty -Because "every routed skill must ship a SKILL.md (dead: $($dead -join ', '))" + } + + It 'Does not reference the deprecated azure-cost-management skill' { + $hits = @($script:SkillDocs | Select-String -Pattern 'azure-cost-management' -SimpleMatch) + $hits | Should -BeNullOrEmpty -Because 'AgentPlugins.Tests.ps1 asserts that skill no longer ships' + } +} diff --git a/src/templates/agent-skills/anomaly-investigation/SKILL.md b/src/templates/agent-skills/anomaly-investigation/SKILL.md index cf1e4ab20..5334f260c 100644 --- a/src/templates/agent-skills/anomaly-investigation/SKILL.md +++ b/src/templates/agent-skills/anomaly-investigation/SKILL.md @@ -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 (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. +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` instead. ## Root-cause drill-down diff --git a/src/templates/agent-skills/azure-workbooks-finops/SKILL.md b/src/templates/agent-skills/azure-workbooks-finops/SKILL.md index 8095aac17..197ddd92f 100644 --- a/src/templates/agent-skills/azure-workbooks-finops/SKILL.md +++ b/src/templates/agent-skills/azure-workbooks-finops/SKILL.md @@ -43,7 +43,7 @@ Deploy via the toolkit's workbook template (ARM/Bicep) into a resource group, th ## Customizing - Workbook tiles are Resource Graph (KQL-for-ARG) or Azure Monitor queries — edit the query behind a tile to change what it shows. -- ARG query language overlaps with the patterns in `azure-orphaned-resources` (in the `azure-cost-management` skill) — reuse those queries for new waste tiles. +- ARG query language overlaps with the orphan patterns in the `finops-multitool` waste-detection reference — reuse those queries for new waste tiles. - Add a parameter for tag key/value to make governance tiles allocation-aware (ties into the `cost-allocation` skill). - Save customized workbooks as a new shared workbook so toolkit upgrades don't overwrite them. diff --git a/src/templates/agent-skills/finops-multitool/SKILL.md b/src/templates/agent-skills/finops-multitool/SKILL.md index d3d53e444..f08794608 100644 --- a/src/templates/agent-skills/finops-multitool/SKILL.md +++ b/src/templates/agent-skills/finops-multitool/SKILL.md @@ -46,7 +46,7 @@ Scope every query explicitly when the user only cares about one subscription. An | 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` | +| How is our MACC tracking? | Consumption commitment burn-down | [references/commitments.md](references/commitments.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) | @@ -59,11 +59,11 @@ Scope every query explicitly when the user only cares about one subscription. An | 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` | +| Advisor cost recommendations | Advisor query | [references/commitments.md](references/commitments.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. +The reference pages carry the API calls, the sequencing, and the interpretation rules together. ## Interpreting common results @@ -107,7 +107,6 @@ Once an investigation surfaces a finding, hand off to the skill that turns it in | 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 | diff --git a/src/templates/agent-skills/finops-multitool/references/allocation.md b/src/templates/agent-skills/finops-multitool/references/allocation.md index 9d31d1bc4..4b1591d3c 100644 --- a/src/templates/agent-skills/finops-multitool/references/allocation.md +++ b/src/templates/agent-skills/finops-multitool/references/allocation.md @@ -126,4 +126,4 @@ Then decide what happens to it: - `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 +- [cost-analysis.md](cost-analysis.md) for choosing the underlying cost data path diff --git a/src/templates/agent-skills/finops-multitool/references/commitments.md b/src/templates/agent-skills/finops-multitool/references/commitments.md index a757e6519..49b3ccc53 100644 --- a/src/templates/agent-skills/finops-multitool/references/commitments.md +++ b/src/templates/agent-skills/finops-multitool/references/commitments.md @@ -2,7 +2,7 @@ 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. +This page covers the sequencing for reservations and savings plans, and the places the raw data misleads. ## Purchase recommendations @@ -62,7 +62,7 @@ Low utilization has three usual causes, in order of frequency: the reservation i 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`). +Sources: cost data with `pricingModel` or `benefitId` populated, compared against retail rates from the Retail Prices API. 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. @@ -70,7 +70,7 @@ The FinOps multitool reports this figure as an estimate and labels it as one. It ## 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. +Microsoft Azure Consumption Commitment burn-down reads from the Consumption lots API. The critical detail: `closedBalance` is the **remaining** balance, not the consumed amount. Consumed is `originalAmount - closedBalance`. Reporting `closedBalance` as spend inverts the number. @@ -89,4 +89,3 @@ Leading with purchase recommendations before checking utilization is the most co - `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 index 05a5666b1..9b7a82c3a 100644 --- a/src/templates/agent-skills/finops-multitool/references/cost-analysis.md +++ b/src/templates/agent-skills/finops-multitool/references/cost-analysis.md @@ -2,7 +2,7 @@ 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. +This page covers choosing the data path and reading the results correctly. ## Choose the data path first @@ -91,4 +91,3 @@ Reporting "cost rose 40%" without identifying which of these is the driver isn't - `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/waste-detection.md b/src/templates/agent-skills/finops-multitool/references/waste-detection.md index 33ef1be37..4cfa62f6a 100644 --- a/src/templates/agent-skills/finops-multitool/references/waste-detection.md +++ b/src/templates/agent-skills/finops-multitool/references/waste-detection.md @@ -117,9 +117,9 @@ az monitor metrics list --resource \ Classification used by the terminal UI: -| Verdict | Criteria | -| ------- | -------- | -| Idle | average CPU < 5% **and** total network < 14 MB over 14 days | +| 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. @@ -193,16 +193,14 @@ resources 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` | +| 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. +**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/rate-optimization-portfolio/SKILL.md b/src/templates/agent-skills/rate-optimization-portfolio/SKILL.md index d1d7c2129..7b76d9388 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 skill 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. metadata: author: microsoft version: "1.0" @@ -14,7 +14,7 @@ Getting the best *rate* is a portfolio problem, not a one-off purchase. This ski ## When to use this skill -Use it when the user asks about reservations vs savings plans, commitment strategy, coverage gaps, utilization, expirations, or "are we paying the best rate." For a single instrument's mechanics defer to the `azure-cost-management` skill (azure-reservations, azure-savings-plans, azure-commitment-discount-decision); use *this* skill for the portfolio-level view across all of them. +Use it when the user asks about reservations vs savings plans, commitment strategy, coverage gaps, utilization, expirations, or "are we paying the best rate." For a single instrument's mechanics see the `finops-multitool` commitments reference; use *this* skill for the portfolio-level view across all of them. ## The instruments @@ -54,7 +54,6 @@ Layer them: AHB first (license), then RIs for the stable base, then a savings pl ## Hand-offs -- Single-instrument mechanics / decision criteria → `azure-cost-management` skill. - Coverage/utilization/AHB data → `finops-multitool` scans. - Express the result as ESR / coverage KPIs → `unit-economics`. - Recommendation breakdown from hub data → `finops-toolkit` (`reservation-recommendation-breakdown.kql`). From 4c5ec115bea74bdd67867be079a91f9f8dbef398 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 8 Sep 2026 21:06:28 -0600 Subject: [PATCH 110/142] FinOps Multitool Update - Sep 07-08 Review findings - Get-FOHubProvider: drop tolower() from comparison position; has_any is already case-insensitive (docs-wiki/Coding-guidelines.md) - Get-LegacyResources: rank Impact explicitly so High sorts before Low; a descending string sort placed 'Low' first - Get-ContractInfo: rename Currency to SoldToCountry; the field held a country code from soldTo, not a currency. Console and HTML updated. - Start-FinOpsMultitool: comment-based help no longer claims Excel, JSON, or Power BI exports (CSV/HTML/text only) or PowerShell 5.1 support --- .../Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 | 8 ++++---- .../Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 | 3 ++- .../FinOpsMultitool/modules/Get-LegacyResources.ps1 | 3 ++- .../FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 | 2 +- src/powershell/Public/Start-FinOpsMultitool.ps1 | 8 ++++---- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index eb5e12a14..b0d045d90 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1799,9 +1799,9 @@ function Invoke-FinOpsMultitool { } 'Get-ContractInfo' { $rows = @($data) | ForEach-Object { - [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Currency = $_.Currency; Status = $_.AccountStatus } + [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Country = $_.SoldToCountry; Status = $_.AccountStatus } } - $cols = @('Account', 'Agreement', 'Type', 'Currency', 'Status') + $cols = @('Account', 'Agreement', 'Type', 'Country', 'Status') } 'Get-MaccCommitment' { if (-not $data.Applicable) { @@ -2967,8 +2967,8 @@ tr:hover td { background: var(--surface); } $htmlCols = @('Account', 'Agreement', 'Type', 'Status') } 'Get-ContractInfo' { - $htmlRows = @($data) | ForEach-Object { [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Currency = $_.Currency; Status = $_.AccountStatus } } - $htmlCols = @('Account', 'Agreement', 'Type', 'Currency', 'Status') + $htmlRows = @($data) | ForEach-Object { [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Country = $_.SoldToCountry; Status = $_.AccountStatus } } + $htmlCols = @('Account', 'Agreement', 'Type', 'Country', 'Status') } 'Get-BudgetHistory' { $htmlRows = @($data) | Where-Object { $_ } | ForEach-Object { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 index 99eea7466..54b2cccba 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 @@ -130,7 +130,8 @@ function Get-ContractInfo { AgreementType = $props.agreementType FriendlyType = $friendlyType AccountStatus = $props.accountStatus - Currency = if ($props.soldTo) { $props.soldTo.country } else { 'Unknown' } + # soldTo is a billing mailing address, so this is a country, not a currency. + SoldToCountry = if ($props.soldTo) { $props.soldTo.country } else { 'Unknown' } }) } } catch { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 index 694e110fe..5a926ec6d 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 @@ -178,7 +178,8 @@ resources return [PSCustomObject]@{ HasData = ($allLegacy.Count -gt 0) TotalCount = $allLegacy.Count - LegacyResources = @($allLegacy | Sort-Object @{ Expression = 'Impact'; Descending = $true }, Category) + # Rank explicitly: a descending string sort puts 'Low' ahead of 'High'. + LegacyResources = @($allLegacy | Sort-Object @{ Expression = { switch ([string]$_.Impact) { 'High' { 0 } 'Medium' { 1 } 'Low' { 2 } default { 3 } } } }, Category) ByCategory = $byCategory ScannedSubs = $Subscriptions.Count } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 index b2a0b9143..5acc7234b 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 @@ -76,7 +76,7 @@ function Get-FOHubScopeClause { $guids = @($SubscriptionIds | Where-Object { $_ -match '^[0-9a-fA-F-]{36}$' } | ForEach-Object { $_.ToLower() }) if ($guids.Count -eq 0) { return '' } $arr = ($guids | ForEach-Object { '"' + $_ + '"' }) -join ', ' - return "| where tolower(SubAccountId) has_any (dynamic([$arr]))" + return "| where SubAccountId has_any (dynamic([$arr]))" } # -- Private: run a query through the resolved provider -------------------- diff --git a/src/powershell/Public/Start-FinOpsMultitool.ps1 b/src/powershell/Public/Start-FinOpsMultitool.ps1 index c6eb3e55f..2bdab0051 100644 --- a/src/powershell/Public/Start-FinOpsMultitool.ps1 +++ b/src/powershell/Public/Start-FinOpsMultitool.ps1 @@ -13,11 +13,11 @@ 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. + Results are rendered in the terminal. Exports are one CSV file per scan module, an + HTML report, and a text summary. - The scan modules are read-only. The TUI runs on PowerShell 5.1+ (Windows) or - PowerShell 7+ (cross-platform) and requires the Az modules (Az.Accounts, + The scan modules are read-only. The TUI requires PowerShell 7 or later on Windows, + macOS, and Linux, the Az modules (Az.Accounts, Az.ResourceGraph, Az.Storage) and Reader access on the target scope. Consoles that cannot drive the arrow-key menus, such as remoting sessions and some From ab1c3493fc68d0a5649a3e56319add2fe2177a0c Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 8 Sep 2026 21:12:47 -0600 Subject: [PATCH 111/142] FinOps Multitool Update - Sep 07-08 Review findings Cost Management query pagination: three scanners issued a query and read only the first page, so a subscription with a large resource footprint under-reported cost silently rather than erroring. - Add Get-CostQueryResponsePage helper: follows nextLink, warns (rather than staying silent) when a continuation page fails, caps the chain - Get-SharedCostAllocation: follow nextLink in the by-ResourceId query - Get-CostByTag: follow nextLink in the core cost-by-tag query - Get-OrphanedResources: follow nextLink in the cost-enrichment query - Add CostQueryPagination.Tests.ps1 covering the response-shape branches --- .../FinOpsMultitool/FinOpsMultitool.psm1 | 1 + .../FinOpsMultitool/modules/Get-CostByTag.ps1 | 6 +- .../modules/Get-OrphanedResources.ps1 | 21 +++--- .../modules/Get-SharedCostAllocation.ps1 | 6 +- .../helpers/Get-CostQueryResponsePage.ps1 | 71 +++++++++++++++++++ .../Tests/Unit/CostQueryPagination.Tests.ps1 | 63 ++++++++++++++++ 6 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 create mode 100644 src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 index 63c9e11f5..8fa22ccf6 100644 --- a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 +++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 @@ -38,6 +38,7 @@ foreach ($azMod in @('Az.Accounts', 'Az.Storage', 'Az.ResourceGraph')) { $helpersPath = Join-Path $PSScriptRoot 'modules\helpers' . (Join-Path $helpersPath 'Get-PlainAccessToken.ps1') . (Join-Path $helpersPath 'Invoke-AzRestMethodWithRetry.ps1') +. (Join-Path $helpersPath 'Get-CostQueryResponsePage.ps1') . (Join-Path $helpersPath 'Search-AzGraphSafe.ps1') . (Join-Path $helpersPath 'Resolve-BillingScope.ps1') . (Join-Path $helpersPath 'MgCostScope.ps1') diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 index dde1841d8..dedb64c28 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 @@ -439,7 +439,11 @@ function Get-CostByTag { $subResp = $pj.Result if ($subResp.StatusCode -eq 200) { $subsQueried++ - $rows = Parse-ResourceIdRows -ResponseContent $subResp.Content + # Follow nextLink: one page only would understate a large subscription. + $rows = @() + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $subResp -Context "cost-by-tag for $($pj.SubName)")) { + $rows += Parse-ResourceIdRows -ResponseContent $page.Content + } foreach ($row in $rows) { $cost = $row.Cost $grandTotal += $cost diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 index 400a2e080..9d3b8d446 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -345,15 +345,18 @@ resources if (-not $costPeriodLabel) { $costPeriodLabel = $usedLabel } $costQueried++ - $costResult = ($costResp.Content | ConvertFrom-Json) - $costCols = @{} - for ($cIdx = 0; $cIdx -lt $costResult.properties.columns.Count; $cIdx++) { - $costCols[$costResult.properties.columns[$cIdx].name] = $cIdx - } - foreach ($costRow in $costResult.properties.rows) { - $rid = [string]$costRow[$costCols['ResourceId']] - # Resource Graph and Cost Management disagree on ID casing. - if ($rid) { $costMap[$rid.ToLowerInvariant()] = [math]::Round([double]$costRow[$costCols['Cost']], 2) } + # Follow nextLink: one page only would leave later orphans uncosted. + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $costResp -Context "orphan cost for $($sub.Name)")) { + $costResult = ($page.Content | ConvertFrom-Json) + $costCols = @{} + for ($cIdx = 0; $cIdx -lt $costResult.properties.columns.Count; $cIdx++) { + $costCols[$costResult.properties.columns[$cIdx].name] = $cIdx + } + foreach ($costRow in $costResult.properties.rows) { + $rid = [string]$costRow[$costCols['ResourceId']] + # Resource Graph and Cost Management disagree on ID casing. + if ($rid) { $costMap[$rid.ToLowerInvariant()] = [math]::Round([double]$costRow[$costCols['Cost']], 2) } + } } } catch { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 index 610c9777a..c9857424d 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -222,13 +222,13 @@ function Get-AllocationCostMaps { $path = "/subscriptions/$sub/providers/Microsoft.CostManagement/query?api-version=2023-11-01" try { $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body - if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { - $data = $resp.Content | ConvertFrom-Json + if (-not $bySub.ContainsKey($sub)) { $bySub[$sub] = 0.0 } + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Context "shared cost for $sub")) { + $data = $page.Content | ConvertFrom-Json $cols = @($data.properties.columns.name) $iCost = [array]::IndexOf($cols, 'Cost') $iRes = [array]::IndexOf($cols, 'ResourceId') $iCur = [array]::IndexOf($cols, 'Currency') - if (-not $bySub.ContainsKey($sub)) { $bySub[$sub] = 0.0 } foreach ($row in @($data.properties.rows)) { $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 } $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 new file mode 100644 index 000000000..1e19d1ef1 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-COSTQUERYRESPONSEPAGE.PS1 +# COST MANAGEMENT QUERY PAGINATION +########################################################################### +# Purpose: Follow the Cost Management query API's nextLink and return every +# page, so a caller that sums rows sees the whole result set. +# Date: Created for FinOps Multitool +# +# Description: +# The Cost Management query API returns one page at a time. A subscription +# with a large resource footprint therefore reports only its first page +# unless nextLink is followed, and the shortfall looks like lower cost +# rather than like an error. +# +# Returns the raw response objects rather than parsed rows, because callers +# read the payload differently (column-index lookups, row parsers). +# +# ── Parameters ────────────────────────────────────────────── +# FirstResponse The already-issued first-page response +# Context Label used in warnings so a partial total is attributable +# MaxPages Bounds a pathological nextLink chain +# +# Prerequisites: +# - Invoke-AzRestMethodWithRetry.ps1 +########################################################################### + +function Get-CostQueryResponsePage { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object]$FirstResponse, + + [Parameter()] + [string]$Context = 'cost query', + + [Parameter()] + [int]$MaxPages = 50 + ) + + $pages = [System.Collections.Generic.List[object]]::new() + $resp = $FirstResponse + $pageCount = 0 + + while ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + [void]$pages.Add($resp) + $pageCount++ + + $next = $null + try { $next = ($resp.Content | ConvertFrom-Json).properties.nextLink } + catch { $next = $null } + if ([string]::IsNullOrWhiteSpace($next)) { break } + + if ($pageCount -ge $MaxPages) { + Write-Warning " $Context : stopped after $MaxPages pages; totals are incomplete." + break + } + + $resp = Invoke-AzRestMethodWithRetry -Path ([System.Uri]$next).PathAndQuery -Method GET + # A failed continuation must be reported: silence here reads as lower cost. + if (-not $resp -or $resp.StatusCode -ne 200) { + $code = if ($resp) { [string]$resp.StatusCode } else { 'no response' } + Write-Warning " $Context : continuation page failed ($code); totals are incomplete." + break + } + } + + return $pages +} diff --git a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 new file mode 100644 index 000000000..a8ba98eab --- /dev/null +++ b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 @@ -0,0 +1,63 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Cost Management query pagination' { + + # 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 + + function Get-FakeResponse { + param([int]$StatusCode = 200, [string]$NextLink, [int]$RowCount = 1) + $rows = @(1..$RowCount | ForEach-Object { , @("/subscriptions/x/r$_", 1.0, 'USD') }) + $payload = @{ + properties = @{ + columns = @(@{ name = 'ResourceId' }, @{ name = 'Cost' }, @{ name = 'Currency' }) + rows = $rows + } + } + if ($NextLink) { $payload.properties.nextLink = $NextLink } + [PSCustomObject]@{ StatusCode = $StatusCode; Content = ($payload | ConvertTo-Json -Depth 6) } + } + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + It 'Returns the single page when there is no nextLink' { + $pages = @(Get-CostQueryResponsePage -FirstResponse (Get-FakeResponse)) + $pages.Count | Should -Be 1 + } + + It 'Returns nothing when the first response is not 200' { + $pages = @(Get-CostQueryResponsePage -FirstResponse (Get-FakeResponse -StatusCode 403)) + $pages.Count | Should -Be 0 + } + + It 'Returns nothing when the response has no content' { + $pages = @(Get-CostQueryResponsePage -FirstResponse ([PSCustomObject]@{ StatusCode = 200; Content = $null })) + $pages.Count | Should -Be 0 + } + + It 'Returns nothing when the response is null' { + $pages = @(Get-CostQueryResponsePage -FirstResponse ([PSCustomObject]@{ StatusCode = 200; Content = '' })) + $pages.Count | Should -Be 0 + } + + It 'Tolerates a payload that is not valid JSON rather than throwing' { + $bad = [PSCustomObject]@{ StatusCode = 200; Content = 'not json at all' } + $pages = @(Get-CostQueryResponsePage -FirstResponse $bad -WarningAction SilentlyContinue) + $pages.Count | Should -Be 1 + } + + It 'Preserves the row payload so callers can parse it' { + $pages = @(Get-CostQueryResponsePage -FirstResponse (Get-FakeResponse -RowCount 3)) + $parsed = $pages[0].Content | ConvertFrom-Json + @($parsed.properties.rows).Count | Should -Be 3 + } +} From cc458b49abda1882c62c48ea9043af34e26dc6f8 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 8 Sep 2026 21:40:05 -0600 Subject: [PATCH 112/142] FinOps Multitool Update - Sep 07-08 Review findings Silent failure: six scanners treated a failed call as an empty result, so a throttled or unauthorized response was indistinguishable from a clean scan. Each now counts what it could not read and reports it. - Get-IdleVMs: empty catch on per-VM metrics now records the failure; adds EvaluatedVMs/MetricFailures, including on the no-running-VMs path - Get-StorageTierAdvice: same for per-account metrics; adds EvaluatedAccounts/MetricFailures - Get-CommitmentUtilization: per-reservation utilization failures no longer drop a reservation silently; adds UtilizationFailures. Also fixes an empty catch on the billing profile lookup. - Get-AIWorkloadMetrics: a failed detection query no longer reports "No AI workloads detected" as if verified; adds DetectionFailed and per-account MetricFailures - Get-BudgetStatus: large-tenant sampling now uses a random sample rather than the first 10, and no longer concludes "no budgets" when a probe failed - it queries the full tenant instead - Get-PolicyInventory: per-subscription try/catch so one failure cannot abandon the rest of the tenant scan; reports the partial count --- .../modules/Get-AIWorkloadMetrics.ps1 | 33 ++++++- .../modules/Get-BudgetStatus.ps1 | 17 +++- .../modules/Get-CommitmentUtilization.ps1 | 19 +++- .../FinOpsMultitool/modules/Get-IdleVMs.ps1 | 31 +++++-- .../modules/Get-PolicyInventory.ps1 | 88 +++++++++++-------- .../modules/Get-StorageTierAdvice.ps1 | 14 ++- 6 files changed, 149 insertions(+), 53 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 index 055f347d0..35c7fbf97 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -56,6 +56,8 @@ function Get-AIWorkloadMetrics { $mlWorkspaceCount = 0 $searchCount = 0 $gpuVmCount = 0 + $detectionFailed = $false + $metricFailures = [System.Collections.Generic.List[string]]::new() try { $gateQuery = @" @@ -92,6 +94,8 @@ resources } } catch { + # A failed probe cannot prove absence, so record it and say so below. + $detectionFailed = $true Write-Warning " AI detection query failed: $($_.Exception.Message)" } @@ -105,12 +109,23 @@ resources $anyAI = ($openAiAccounts.Count + $aiServiceCount + $mlWorkspaceCount + $searchCount + $gpuVmCount) -gt 0 if (-not $anyAI) { - Write-Host " No AI workloads detected - skipping AI KPIs." -ForegroundColor Gray + if ($detectionFailed) { + Write-Warning " AI detection did not complete - cannot confirm whether AI workloads exist." + } + else { + Write-Host " No AI workloads detected - skipping AI KPIs." -ForegroundColor Gray + } return [PSCustomObject]@{ HasData = $false AIFootprint = $footprint ScannedSubs = $Subscriptions.Count - Note = 'No AI workloads detected in the scanned subscriptions.' + DetectionFailed = $detectionFailed + Note = if ($detectionFailed) { + 'AI detection query failed, so the absence of AI workloads is unverified.' + } + else { + 'No AI workloads detected in the scanned subscriptions.' + } } } @@ -227,8 +242,9 @@ resources } } catch { - # Metrics unavailable for this account (no usage yet, or - # not an OpenAI-capable kind) - skip it. + # "No usage yet" and "the call failed" both land here, so record it + # rather than letting a throttled account read as zero tokens. + [void]$metricFailures.Add("$acctKey : $($_.Exception.Message)") } } } @@ -348,6 +364,11 @@ resources $note = 'AI footprint detected (ML/Search/GPU); no token-metered Azure OpenAI usage to price.' } + if ($metricFailures.Count -gt 0) { + Write-Warning " Metrics unavailable for $($metricFailures.Count) AI account(s); token and cost totals exclude them." + foreach ($f in ($metricFailures | Select-Object -First 3)) { Write-Verbose " $f" } + } + return [PSCustomObject]@{ HasData = $hasData AIFootprint = $footprint @@ -364,6 +385,10 @@ resources Period = 'MonthToDate' Source = if ($fromHub) { 'FinOpsHub' } else { 'API' } ScannedSubs = $Subscriptions.Count + DetectionFailed = $detectionFailed + # Accounts whose metrics could not be read; token totals exclude them. + MetricFailures = $metricFailures.Count + MetricFailureDetail = @($metricFailures) Note = $note } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 index 95f444743..2e3ff3c70 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -39,8 +39,11 @@ function Get-BudgetStatus { if ($subCount -gt 50) { $sampleSize = [math]::Min(10, $subCount) Write-Host " Large tenant: sampling $sampleSize of $subCount subs for budgets..." -ForegroundColor Yellow - $sampleSubs = $Subscriptions | Select-Object -First $sampleSize + # Random rather than the first N: subscription order is not arbitrary, so + # the head of the list is not a representative sample. + $sampleSubs = @($Subscriptions | Get-Random -Count $sampleSize) $sampleHits = 0 + $sampleErrors = 0 foreach ($sub in $sampleSubs) { try { $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01" @@ -49,16 +52,24 @@ function Get-BudgetStatus { $sampleBudgets = ($resp.Content | ConvertFrom-Json).value if ($sampleBudgets -and $sampleBudgets.Count -gt 0) { $sampleHits++ } } + else { $sampleErrors++ } + } + catch { + $sampleErrors++ + Write-Verbose "Budget sample failed for $($sub.Name): $($_.Exception.Message)" } - catch { } } - if ($sampleHits -eq 0) { + if ($sampleHits -eq 0 -and $sampleErrors -eq 0) { + # Only skip the tenant when every probe actually answered. Write-Host " No budgets found in sample of $sampleSize subs - skipping remaining" -ForegroundColor Yellow $sampled = $true $subsWithoutBudget = $subCount $subsToQuery = @() # Skip the main loop } + elseif ($sampleHits -eq 0) { + Write-Warning " Budget sample inconclusive ($sampleErrors of $sampleSize probes failed); querying all $subCount subs instead of assuming none." + } else { Write-Host " Budgets found in sample ($sampleHits/$sampleSize), querying all $subCount subs..." -ForegroundColor Cyan } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 index 5455643c1..c300def60 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 @@ -23,6 +23,7 @@ function Get-CommitmentUtilization { Write-Host " Querying commitment utilization..." -ForegroundColor Cyan $reservations = @() + $utilFailures = [System.Collections.Generic.List[string]]::new() $savingsPlans = @() $subIds = $Subscriptions | ForEach-Object { $_.Id } @@ -57,7 +58,9 @@ function Get-CommitmentUtilization { $bpResult = ($bpResp.Content | ConvertFrom-Json) foreach ($bp in $bpResult.value) { $billingProfileIds += $bp.id } } - } catch { } + } catch { + Write-Verbose "Billing profile lookup failed: $($_.Exception.Message)" + } } } } catch { @@ -171,7 +174,11 @@ function Get-CommitmentUtilization { } } } - } catch { } + } catch { + # Dropping this reservation silently would understate + # the count and read as better coverage than reality. + [void]$utilFailures.Add("$($ri.id.Split('/')[-1]): $($_.Exception.Message)") + } } } } @@ -275,6 +282,11 @@ function Get-CommitmentUtilization { "$riCount reservation(s) avg $riAvgUtil% util; $spCount savings plan(s) avg $spAvgUtil% util." } + if ($utilFailures.Count -gt 0) { + Write-Warning " Utilization unavailable for $($utilFailures.Count) reservation(s); counts below exclude them." + foreach ($f in ($utilFailures | Select-Object -First 3)) { Write-Verbose " $f" } + } + return [PSCustomObject]@{ Reservations = $reservations SavingsPlans = $savingsPlans @@ -286,5 +298,8 @@ function Get-CommitmentUtilization { HasData = ($riCount -gt 0 -or $spCount -gt 0) AccessDenied = $denied Note = $note + # Reservations excluded because their utilization could not be read. + UtilizationFailures = $utilFailures.Count + UtilizationFailureDetail = @($utilFailures) } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 index 29988986b..5eae2de78 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 @@ -21,6 +21,7 @@ function Get-IdleVMs { $subIds = $Subscriptions | ForEach-Object { $_.Id } $results = [System.Collections.Generic.List[PSCustomObject]]::new() + $metricFailures = [System.Collections.Generic.List[string]]::new() # -- 1: Find all running VMs ------------------------------------------ # Pull every VM with its power state so we can distinguish "no VMs at all" @@ -64,6 +65,10 @@ resources ScannedVMs = 0 TotalVMs = $totalVMs DeallocatedVMs = $deallocatedCount + # Same shape as the main return so callers can read these on either path. + EvaluatedVMs = 0 + MetricFailures = 0 + MetricFailureDetail = @() Note = $note } } @@ -151,18 +156,30 @@ resources } } catch { - # Metrics not available — skip this VM + # A throttled or unauthorized metrics call is not the same as a busy VM, + # so count it rather than letting it read as "nothing to report". + [void]$metricFailures.Add("$($vm.name): $($_.Exception.Message)") } } + if ($metricFailures.Count -gt 0) { + Write-Warning " Metrics unavailable for $($metricFailures.Count) of $($runningVMs.Count) VM(s); those VMs were not evaluated." + foreach ($f in ($metricFailures | Select-Object -First 3)) { Write-Verbose " $f" } + } + Write-Host " Idle/underutilized VMs: $($results.Count)" -ForegroundColor Gray [PSCustomObject]@{ - IdleVMs = @($results) - Count = $results.Count - HasData = ($results.Count -gt 0) - ScannedVMs = $runningVMs.Count - TotalVMs = $totalVMs - DeallocatedVMs = $deallocatedCount + IdleVMs = @($results) + Count = $results.Count + HasData = ($results.Count -gt 0) + ScannedVMs = $runningVMs.Count + TotalVMs = $totalVMs + DeallocatedVMs = $deallocatedCount + # Evaluated excludes VMs whose metrics could not be read, so a caller can + # tell a clean result from a partial one. + EvaluatedVMs = ($runningVMs.Count - $metricFailures.Count) + MetricFailures = $metricFailures.Count + MetricFailureDetail = @($metricFailures) } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 index 747e084e1..61abc07d1 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -126,46 +126,62 @@ function Get-PolicyInventory { try { Write-Host " Querying policy assignments via ARM REST API..." -ForegroundColor Cyan $seenIds = @{} + $subFailures = [System.Collections.Generic.List[string]]::new() foreach ($sub in $Subscriptions) { - $subName = $sub.Name - $nextLink = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01" - while ($nextLink) { - $resp = Invoke-AzRestMethodWithRetry -Path $nextLink -Method GET - if ($resp.StatusCode -ne 200) { break } - $body = $resp.Content | ConvertFrom-Json - foreach ($a in $body.value) { - # De-duplicate (same MG assignment appears under each sub) - if ($seenIds.ContainsKey($a.id)) { continue } - $seenIds[$a.id] = $true - - $props = $a.properties - $defId = $props.policyDefinitionId - $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' } - elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } - else { 'Custom' } - $scope = if ($a.id -match '^(.*)/providers/Microsoft\.Authorization/policyAssignments/') { - $Matches[1] + # Scoped per subscription: a transient failure on one must not abandon + # the rest of the tenant and leave a partial result looking complete. + try { + $subName = $sub.Name + $nextLink = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01" + while ($nextLink) { + $resp = Invoke-AzRestMethodWithRetry -Path $nextLink -Method GET + if ($resp.StatusCode -ne 200) { + [void]$subFailures.Add("$($sub.Name): HTTP $($resp.StatusCode)") + break } - else { '' } - - [void]$allAssignments.Add([PSCustomObject]@{ - AssignmentName = if ($props.displayName) { $props.displayName } else { $a.name } - AssignmentId = $a.id - PolicyDefId = $defId - Scope = $scope - Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' } - EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' } - Origin = $origin - Subscription = $subName - Description = if ($props.description) { $props.description } else { '' } - }) - } - # Handle pagination via nextLink - $nextLink = if ($body.nextLink) { - $body.nextLink -replace '^https://management\.azure\.com', '' + $body = $resp.Content | ConvertFrom-Json + foreach ($a in $body.value) { + # De-duplicate (same MG assignment appears under each sub) + if ($seenIds.ContainsKey($a.id)) { continue } + $seenIds[$a.id] = $true + + $props = $a.properties + $defId = $props.policyDefinitionId + $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' } + elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } + else { 'Custom' } + $scope = if ($a.id -match '^(.*)/providers/Microsoft\.Authorization/policyAssignments/') { + $Matches[1] + } + else { '' } + + [void]$allAssignments.Add([PSCustomObject]@{ + AssignmentName = if ($props.displayName) { $props.displayName } else { $a.name } + AssignmentId = $a.id + PolicyDefId = $defId + Scope = $scope + Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' } + EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' } + Origin = $origin + Subscription = $subName + Description = if ($props.description) { $props.description } else { '' } + }) + } + # Handle pagination via nextLink + $nextLink = if ($body.nextLink) { + $body.nextLink -replace '^https://management\.azure\.com', '' + } + else { $null } } - else { $null } } + catch { + [void]$subFailures.Add("$($sub.Name): $($_.Exception.Message)") + } + } + + if ($subFailures.Count -gt 0) { + Write-Warning " Policy assignments could not be read for $($subFailures.Count) of $subCount subscription(s); the inventory below is partial." + foreach ($f in ($subFailures | Select-Object -First 3)) { Write-Verbose " $f" } } if ($allAssignments.Count -gt 0) { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 index 5826bde7f..ee93ab7de 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 @@ -21,6 +21,7 @@ function Get-StorageTierAdvice { $subIds = $Subscriptions | ForEach-Object { $_.Id } $results = [System.Collections.Generic.List[PSCustomObject]]::new() + $metricFailures = [System.Collections.Generic.List[string]]::new() # -- 1: Find all storage accounts on Hot default tier ----------------- try { @@ -116,10 +117,17 @@ resources } } catch { - # Metrics not available (classic account, no blob service, etc.) — skip + # A classic account with no blob service is expected; a throttled or + # unauthorized call is not. Count both rather than reporting neither. + [void]$metricFailures.Add("$($sa.name): $($_.Exception.Message)") } } + if ($metricFailures.Count -gt 0) { + Write-Warning " Metrics unavailable for $($metricFailures.Count) of $($hotAccounts.Count) storage account(s); those accounts were not evaluated." + foreach ($f in ($metricFailures | Select-Object -First 3)) { Write-Verbose " $f" } + } + Write-Host " Storage tier recommendations: $($results.Count)" -ForegroundColor Gray [PSCustomObject]@{ @@ -127,5 +135,9 @@ resources TotalHotAccounts = $hotAccounts.Count Count = $results.Count HasData = ($results.Count -gt 0) + # Evaluated excludes accounts whose metrics could not be read. + EvaluatedAccounts = ($hotAccounts.Count - $metricFailures.Count) + MetricFailures = $metricFailures.Count + MetricFailureDetail = @($metricFailures) } } From 595ca0ab8b9362a155f1dae1b5624c27797e1414 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 8 Sep 2026 22:32:06 -0600 Subject: [PATCH 113/142] FinOps Multitool Update - Sep 07-08 Review findings Cross-currency summation: three scanners summed cost across rows while labelling the total with whichever row set Currency last, so a tenant billing in more than one currency produced a meaningless number that still looked authoritative. - Add Resolve-CurrencyLabel helper: records every currency seen and reports 'Mixed' rather than picking one; casing and padding normalized - Get-CostTrend: track currencies per month in Set-TrendFromGrouped - Get-UnitEconomics: Add-MeterCosts collects currencies instead of overwriting a single ref. Also fixes its empty catch. - Get-AIWorkloadMetrics: same for the AI cost aggregation - Add CurrencyLabel.Tests.ps1 (10 cases) covering the label, casing normalization, blank handling, and the mixed-tenant path --- .../FinOpsMultitool/FinOpsMultitool.psm1 | 1 + .../modules/Get-AIWorkloadMetrics.ps1 | 6 +- .../FinOpsMultitool/modules/Get-CostTrend.ps1 | 6 +- .../modules/Get-UnitEconomics.ps1 | 17 ++-- .../modules/helpers/Resolve-CurrencyLabel.ps1 | 61 ++++++++++++++ .../Tests/Unit/CurrencyLabel.Tests.ps1 | 79 +++++++++++++++++++ 6 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 create mode 100644 src/powershell/Tests/Unit/CurrencyLabel.Tests.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 index 8fa22ccf6..7bfcb29fa 100644 --- a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 +++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 @@ -39,6 +39,7 @@ $helpersPath = Join-Path $PSScriptRoot 'modules\helpers' . (Join-Path $helpersPath 'Get-PlainAccessToken.ps1') . (Join-Path $helpersPath 'Invoke-AzRestMethodWithRetry.ps1') . (Join-Path $helpersPath 'Get-CostQueryResponsePage.ps1') +. (Join-Path $helpersPath 'Resolve-CurrencyLabel.ps1') . (Join-Path $helpersPath 'Search-AzGraphSafe.ps1') . (Join-Path $helpersPath 'Resolve-BillingScope.ps1') . (Join-Path $helpersPath 'MgCostScope.ps1') diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 index 35c7fbf97..96d0f2782 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -150,6 +150,8 @@ resources $aiCost = 0.0 $currency = 'USD' + # A tenant can bill subscriptions in different currencies; keep them all. + $currenciesSeen = @{} $costByAcct = @{} # resourceId(lower) -> cost $costOk = $false @@ -292,7 +294,7 @@ resources foreach ($row in $cdata.properties.rows) { $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { [double]$row[0] } $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } - if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] } + if ($iCur -ge 0 -and $row[$iCur]) { Add-CurrencySeen -Seen $currenciesSeen -Currency ([string]$row[$iCur]) } $aiCost += $amount if ($rid) { $costByAcct[$rid.ToLowerInvariant()] = $amount } } @@ -377,7 +379,7 @@ resources TotalTokens = [long]$totalTokens TotalRequests = [long]$totalReq TotalAICost = [math]::Round($aiCost, 2) - Currency = $currency + Currency = if (@($currenciesSeen.Keys).Count -gt 0) { Resolve-CurrencyLabel -Seen $currenciesSeen } else { $currency } CostPer1KTokens = $costPer1kTokens CostPerRequest = $costPerRequest ByModel = $byModel diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 index 4caeb7158..0862f8981 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 @@ -173,8 +173,10 @@ function Get-CostTrend { } $key = $e.MonthDate.ToString('yyyy-MM') if (-not $agg.ContainsKey($key)) { - $agg[$key] = @{ Cost = 0; Date = $e.MonthDate; Currency = $e.Currency } + # Track every currency in the month, not just the first seen. + $agg[$key] = @{ Cost = 0; Date = $e.MonthDate; Currencies = @{} } } + Add-CurrencySeen -Seen $agg[$key].Currencies -Currency $e.Currency $agg[$key].Cost += $e.Cost } foreach ($k in @($bySubscription.Keys)) { @@ -185,7 +187,7 @@ function Get-CostTrend { Month = $entry.Value.Date.ToString('MMM yyyy') MonthDate = $entry.Value.Date Cost = [math]::Round($entry.Value.Cost, 2) - Currency = $entry.Value.Currency + Currency = Resolve-CurrencyLabel -Seen $entry.Value.Currencies }) } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 index 806ca9f51..c50bdb9cd 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -79,7 +79,7 @@ function Add-MeterCosts { [string]$Content, [ref]$ComputeRef, [ref]$StorageRef, - [ref]$CurrencyRef + [hashtable]$CurrencySeen ) $any = $false try { @@ -89,7 +89,7 @@ function Add-MeterCosts { $any = $true $amount = [double]$row[0] $category = [string]$row[1] - if ($row.Count -ge 3 -and $row[2]) { $CurrencyRef.Value = [string]$row[2] } + if ($row.Count -ge 3 -and $row[2]) { Add-CurrencySeen -Seen $CurrencySeen -Currency ([string]$row[2]) } switch -Wildcard ($category) { 'Virtual Machines*' { $ComputeRef.Value += $amount } 'Storage*' { $StorageRef.Value += $amount } @@ -98,7 +98,9 @@ function Add-MeterCosts { } } } - catch { } + catch { + Write-Verbose "Meter cost parse failed: $($_.Exception.Message)" + } return $any } @@ -246,7 +248,8 @@ resources # -- 3: Amortized cost by meter category (sub-scoped, with fallback) -- $computeCost = 0.0 $storageCost = 0.0 - $currency = 'USD' + # A tenant can bill subscriptions in different currencies; keep them all. + $currenciesSeen = @{} $costOk = $false $costScope = 'none' $mgFailed = $false @@ -276,7 +279,7 @@ resources Set-MgCostScopeFailed } elseif ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { - if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencyRef ([ref]$currency)) { + if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencySeen $currenciesSeen) { $costOk = $true $costScope = "mg:$mgScopeId" } @@ -309,7 +312,7 @@ resources $path = "/subscriptions/$sid/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { - if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencyRef ([ref]$currency)) { + if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencySeen $currenciesSeen) { $costOk = $true $costScope = 'per-sub' } @@ -356,7 +359,7 @@ resources return [PSCustomObject]@{ HasData = $hasData - Currency = $currency + Currency = Resolve-CurrencyLabel -Seen $currenciesSeen ComputeCost = [math]::Round($computeCost, 2) StorageCost = [math]::Round($storageCost, 2) ComputeSharePct = $computeSharePct diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 new file mode 100644 index 000000000..df3cbf47e --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# RESOLVE-CURRENCYLABEL.PS1 +# MIXED-CURRENCY DETECTION FOR COST AGGREGATION +########################################################################### +# Purpose: Track the currencies seen while summing cost rows and label the +# total honestly when more than one appears. +# Date: Created for FinOps Multitool +# +# Description: +# A tenant can bill different subscriptions in different currencies. Summing +# those rows produces a number that means nothing, and labelling it with +# whichever row happened to come last makes the result look authoritative. +# Callers add each row's currency, then ask for a label: a single currency +# is reported as-is, several are reported as 'Mixed'. +# +# ── Functions ─────────────────────────────────────────────────── +# Add-CurrencySeen Record one row's currency +# Resolve-CurrencyLabel Currency code, or 'Mixed' when several were seen +# Test-CurrencyMixed True when the total spans more than one currency +########################################################################### + +function Add-CurrencySeen { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Seen, + + [Parameter()] + [string]$Currency + ) + if (-not [string]::IsNullOrWhiteSpace($Currency)) { + $Seen[$Currency.Trim().ToUpperInvariant()] = $true + } +} + +function Resolve-CurrencyLabel { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Seen, + + [Parameter()] + [string]$Fallback = 'USD' + ) + $keys = @($Seen.Keys) + if ($keys.Count -eq 0) { return $Fallback } + if ($keys.Count -eq 1) { return [string]$keys[0] } + return 'Mixed' +} + +function Test-CurrencyMixed { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Seen + ) + return (@($Seen.Keys).Count -gt 1) +} diff --git a/src/powershell/Tests/Unit/CurrencyLabel.Tests.ps1 b/src/powershell/Tests/Unit/CurrencyLabel.Tests.ps1 new file mode 100644 index 000000000..6d63d9ae5 --- /dev/null +++ b/src/powershell/Tests/Unit/CurrencyLabel.Tests.ps1 @@ -0,0 +1,79 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Mixed currency detection' { + + # 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 + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Resolve-CurrencyLabel' { + It 'Falls back when nothing was recorded' { + Resolve-CurrencyLabel -Seen @{} | Should -Be 'USD' + } + + It 'Honors an explicit fallback' { + Resolve-CurrencyLabel -Seen @{} -Fallback 'EUR' | Should -Be 'EUR' + } + + It 'Reports the currency when only one was seen' { + $seen = @{} + Add-CurrencySeen -Seen $seen -Currency 'USD' + Resolve-CurrencyLabel -Seen $seen | Should -Be 'USD' + } + + It 'Reports Mixed when several were seen' { + $seen = @{} + Add-CurrencySeen -Seen $seen -Currency 'USD' + Add-CurrencySeen -Seen $seen -Currency 'EUR' + Resolve-CurrencyLabel -Seen $seen | Should -Be 'Mixed' + } + } + + Context 'Add-CurrencySeen' { + It 'Treats casing and padding as the same currency' { + $seen = @{} + Add-CurrencySeen -Seen $seen -Currency 'usd' + Add-CurrencySeen -Seen $seen -Currency 'USD' + Add-CurrencySeen -Seen $seen -Currency ' Usd ' + @($seen.Keys).Count | Should -Be 1 + Test-CurrencyMixed -Seen $seen | Should -BeFalse + } + + It 'Ignores a null or blank currency rather than counting it' { + $seen = @{} + Add-CurrencySeen -Seen $seen -Currency $null + Add-CurrencySeen -Seen $seen -Currency '' + Add-CurrencySeen -Seen $seen -Currency ' ' + @($seen.Keys).Count | Should -Be 0 + } + } + + Context 'Test-CurrencyMixed' { + It 'Is false for ' -ForEach @( + @{ Case = 'no currencies'; Currencies = @() } + @{ Case = 'one currency'; Currencies = @('USD') } + @{ Case = 'one currency repeated'; Currencies = @('USD', 'USD', 'usd') } + ) { + $seen = @{} + foreach ($c in $Currencies) { Add-CurrencySeen -Seen $seen -Currency $c } + Test-CurrencyMixed -Seen $seen | Should -BeFalse + } + + It 'Is true when a tenant bills in more than one currency' { + $seen = @{} + foreach ($c in @('USD', 'EUR', 'GBP')) { Add-CurrencySeen -Seen $seen -Currency $c } + Test-CurrencyMixed -Seen $seen | Should -BeTrue + Resolve-CurrencyLabel -Seen $seen | Should -Be 'Mixed' + } + } +} From 940c0273b9b57964edc901c601011fc55ae85224 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 8 Sep 2026 23:25:14 -0600 Subject: [PATCH 114/142] FinOps Multitool Update - Sep 07-08 Review findings Final should-fix batch, plus a security hardening found during the pass. - Get-SavingsRealized: the per-subscription fallback multiplied cost by the raw discount rate (0.4 / 0.25) instead of the savings factor the same file documents and the main path uses (d / (1 - d)), understating RI/SP savings - Get-PolicyRecommendations: removed a duplicate catalog entry; two entries shared definition ID 7433c107-6db4-4ad1-b57a-a76dce0154a1 and double-counted it. Kept the authoritative display name confirmed against the Azure API. - Get-CarbonMetrics: the window probe accepted any HTTP 200, so a month that published no data locked the window and older windows were never tried. It now requires a non-empty value array. - Get-CostQueryResponsePage: validate nextLink before following it. A relative or malformed value silently produced an empty path, and an absolute URL on another host would have been rewritten onto the ARM endpoint. Now accepts a rooted relative path or an https URL on the signed-in cloud's ARM host. - Extend CostQueryPagination.Tests.ps1 with 8 nextLink validation cases --- .../modules/Get-CarbonMetrics.ps1 | 11 ++++-- .../modules/Get-PolicyRecommendations.ps1 | 16 +-------- .../modules/Get-SavingsRealized.ps1 | 6 ++-- .../helpers/Get-CostQueryResponsePage.ps1 | 35 ++++++++++++++++++- .../Tests/Unit/CostQueryPagination.Tests.ps1 | 27 ++++++++++++++ 5 files changed, 75 insertions(+), 20 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 index f8de8d949..e0bc20471 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 @@ -70,8 +70,15 @@ function Get-CarbonMetrics { } if ($probe -and $probe.StatusCode -eq 200) { - $window = @{ Start = $startMonth; End = $endMonth } - break + # A 200 with an empty value array means the window published no data. + # Accepting it would lock onto an empty month and never try older ones. + $probeRows = 0 + try { $probeRows = @(($probe.Content | ConvertFrom-Json).value).Count } + catch { $probeRows = 0 } + if ($probeRows -gt 0) { + $window = @{ Start = $startMonth; End = $endMonth } + break + } } # 404/400 here usually means "no data for that window" or the diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 index c622f0916..3c8e19233 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 @@ -123,7 +123,7 @@ function Get-PolicyRecommendations { # === ALLOWED STORAGE ACCOUNT SKUS (CAF) === [PSCustomObject]@{ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1' - DisplayName = 'Allowed storage account SKUs' + DisplayName = 'Storage accounts should be limited by allowed SKUs' Category = 'Storage' Pillar = 'Optimize' Priority = 'Recommended' @@ -183,20 +183,6 @@ function Get-PolicyRecommendations { @{ Name = 'listOfResourceTypesNotAllowed'; Label = 'Resource types to block (comma-separated, e.g. Microsoft.Sql/servers,Microsoft.HDInsight/clusters)'; Required = $true; IsArray = $true } ) } - [PSCustomObject]@{ - PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1' - DisplayName = 'Storage accounts should be limited by allowed SKUs' - Category = 'Storage' - Pillar = 'Optimize' - Priority = 'Recommended' - DefaultEffect = 'Deny' - AllowedEffects = @('Audit','Deny','Disabled') - Purpose = 'Prevent Premium storage where Standard suffices to reduce storage costs' - Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#storage' - Parameters = @( - @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed storage SKUs (comma-separated, e.g. Standard_LRS,Standard_GRS)'; Required = $true; IsArray = $true } - ) - } [PSCustomObject]@{ PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/013e242c-8828-4970-87b3-ab247555486d' DisplayName = 'Azure Backup should be enabled for Virtual Machines' diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 index e81e6cef5..85eb54159 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -301,7 +301,9 @@ function Get-SavingsRealized { if ($pricingModel -match 'Reservation') { # Amortized RI cost — the actual RI spend - $riSavings += $cost * 0.4 # Approximate: RIs typically save ~40% vs PAYG + # Same factor as the main path: savings is the gap up + # to PAYG, not a share of what was paid. + $riSavings += $cost * $script:FinOpsRiSavingsFactor $committedAmort += $cost [void]$details.Add([PSCustomObject]@{ Subscription = $sub.Name @@ -311,7 +313,7 @@ function Get-SavingsRealized { }) } elseif ($pricingModel -match 'SavingsPlan') { - $spSavings += $cost * 0.25 # Approximate: SPs save ~25% on average + $spSavings += $cost * $script:FinOpsSpSavingsFactor $committedAmort += $cost [void]$details.Add([PSCustomObject]@{ Subscription = $sub.Name diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 index 1e19d1ef1..734deb3bc 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 @@ -27,6 +27,33 @@ # - Invoke-AzRestMethodWithRetry.ps1 ########################################################################### +function Resolve-NextLinkPath { + # nextLink comes from the service, so it is not trusted input. A relative or + # malformed value silently yields an empty PathAndQuery rather than throwing, + # and an absolute URL on another host would be rewritten onto the ARM host. + # Accept a rooted relative path, or an absolute https URL on the ARM endpoint + # for the cloud the caller is signed in to. + [CmdletBinding()] + param( + [Parameter()] + [string]$NextLink + ) + + if ([string]::IsNullOrWhiteSpace($NextLink)) { return $null } + $trimmed = $NextLink.Trim() + if ($trimmed.StartsWith('/')) { return $trimmed } + + $uri = $null + if (-not [System.Uri]::TryCreate($trimmed, [System.UriKind]::Absolute, [ref]$uri)) { return $null } + if ($uri.Scheme -ne 'https') { return $null } + + $armHost = $null + try { $armHost = ([System.Uri](Get-FinOpsArmEndpoint)).Host } catch { $armHost = 'management.azure.com' } + if ($uri.Host -ne $armHost) { return $null } + + return $uri.PathAndQuery +} + function Get-CostQueryResponsePage { [CmdletBinding()] param( @@ -53,12 +80,18 @@ function Get-CostQueryResponsePage { catch { $next = $null } if ([string]::IsNullOrWhiteSpace($next)) { break } + $nextPath = Resolve-NextLinkPath -NextLink $next + if (-not $nextPath) { + Write-Warning " $Context : ignoring an unexpected nextLink; totals may be incomplete." + break + } + if ($pageCount -ge $MaxPages) { Write-Warning " $Context : stopped after $MaxPages pages; totals are incomplete." break } - $resp = Invoke-AzRestMethodWithRetry -Path ([System.Uri]$next).PathAndQuery -Method GET + $resp = Invoke-AzRestMethodWithRetry -Path $nextPath -Method GET # A failed continuation must be reported: silence here reads as lower cost. if (-not $resp -or $resp.StatusCode -ne 200) { $code = if ($resp) { [string]$resp.StatusCode } else { 'no response' } diff --git a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 index a8ba98eab..adeab989a 100644 --- a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 +++ b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 @@ -60,4 +60,31 @@ Describe 'Cost Management query pagination' { $parsed = $pages[0].Content | ConvertFrom-Json @($parsed.properties.rows).Count | Should -Be 3 } + + Context 'nextLink validation' { + # nextLink is service-supplied. A relative or malformed value yields an + # empty PathAndQuery rather than throwing, and a foreign host would be + # rewritten onto the ARM host, so both are rejected. + It 'Accepts ' -ForEach @( + @{ Case = 'an absolute ARM url'; Link = 'https://management.azure.com/subscriptions/x/q?api-version=2023-11-01' } + @{ Case = 'a rooted relative path'; Link = '/subscriptions/x/q?api-version=2023-11-01' } + ) { + Resolve-NextLinkPath -NextLink $Link | Should -Not -BeNullOrEmpty + } + + It 'Rejects ' -ForEach @( + @{ Case = 'a foreign host'; Link = 'https://evil.example.com/steal?a=1' } + @{ Case = 'a non-https scheme'; Link = 'http://management.azure.com/x' } + @{ Case = 'a malformed value'; Link = 'not a url' } + @{ Case = 'an empty value'; Link = '' } + @{ Case = 'a null value'; Link = $null } + ) { + Resolve-NextLinkPath -NextLink $Link | Should -BeNullOrEmpty + } + + It 'Strips the host so the request stays on the ARM endpoint' { + Resolve-NextLinkPath -NextLink 'https://management.azure.com/subscriptions/x/q?a=1' | + Should -Be '/subscriptions/x/q?a=1' + } + } } From 97a71590e1dc29751febbbf207254d6d9a179b2e Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 9 Sep 2026 00:15:05 -0600 Subject: [PATCH 115/142] Update FinOps Multitool --- .../FinOpsMultitool/Invoke-FinOpsMultitool.ps1 | 16 ++++++++++++---- src/powershell/Private/FinOpsMultitool/README.md | 4 ++-- .../modules/Get-BillingAccount.ps1 | 8 ++++++-- .../FinOpsMultitool/modules/Get-ContractInfo.ps1 | 12 +++++++++--- .../FinOpsMultitool/modules/Get-CostByTag.ps1 | 4 +++- .../FinOpsMultitool/modules/Get-CostData.ps1 | 2 ++ .../modules/Get-ResourceCosts.ps1 | 1 + .../FinOpsMultitool/modules/Get-TagInventory.ps1 | 8 ++++++-- .../modules/Get-UnitEconomics.ps1 | 8 ++++++-- .../modules/helpers/Get-CostExport.ps1 | 8 ++++++-- .../modules/helpers/Get-FOHubProvider.ps1 | 1 + .../modules/helpers/Get-PlainAccessToken.ps1 | 4 +++- .../modules/helpers/Invoke-FOHubKustoQuery.ps1 | 8 ++++++-- .../modules/helpers/MgCostScope.ps1 | 4 +++- .../modules/helpers/Read-FinOpsHubData.ps1 | 9 +++++++-- .../modules/helpers/Resolve-BillingScope.ps1 | 8 ++++++-- .../modules/helpers/Resolve-CostDataSource.ps1 | 15 ++++++++++++--- 17 files changed, 91 insertions(+), 29 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index b0d045d90..ded6e3dad 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -280,7 +280,9 @@ function Invoke-FinOpsMultitool { break } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } if ($Preselected) { @@ -327,7 +329,9 @@ function Invoke-FinOpsMultitool { # rows into PowerShell. $hubSubIds = @($Subscriptions | ForEach-Object { $_.Id }) $prov = $null - try { $prov = Resolve-FOHubProvider -Subscriptions $hubSubIds } catch { } + try { $prov = Resolve-FOHubProvider -Subscriptions $hubSubIds } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } if ($prov -and $prov.Found) { # A scalable Kusto path exists - no warning needed. return @{ Source = 'Hub'; HubStorage = $hubStorage } @@ -338,7 +342,9 @@ function Invoke-FinOpsMultitool { # warning. $hubSize = @{ Known = $false; Reachable = $true; IsLarge = $true; Display = 'unknown size'; Issue = $null } if ($hubStorage -and $hubStorage.name) { - try { $hubSize = Measure-FinOpsHubSize -StorageAccountName $hubStorage.name } catch { } + try { $hubSize = Measure-FinOpsHubSize -StorageAccountName $hubStorage.name } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } if (-not $hubSize.Reachable) { @@ -1032,7 +1038,9 @@ function Invoke-FinOpsMultitool { } } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } if ($fctHits -gt 0) { Write-Host " Forecast data loaded for $fctHits subscription(s)" -ForegroundColor Green } } diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index de4f5af29..8a00da877 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -21,14 +21,14 @@ Invoke-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' | Requirement | Details | | --------------------- | --------------------------------------------------------------- | | PowerShell | 7.0 or later (Windows, macOS, Linux) | -| Az modules | `Az.Accounts`, `Az.Resources`, `Az.ResourceGraph`, `Az.Storage` | +| Az modules | `Az.Accounts`, `Az.ResourceGraph`, `Az.Storage` | | Azure RBAC | Reader + Cost Management Reader on target scope | | FinOps Hub (optional) | Storage Blob Data Reader on Hub storage account | Install Az modules if needed: ```powershell -Install-Module Az.Accounts, Az.Resources, Az.ResourceGraph, Az.Storage -Scope CurrentUser +Install-Module Az.Accounts, Az.ResourceGraph, Az.Storage -Scope CurrentUser ``` ## How it works diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 index 6282f274f..2af799d0c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 @@ -52,7 +52,9 @@ function Get-BillingAccount { if ($status -ne 200) { $msg = "Could not list billing accounts (HTTP $status)." if ($resp -and $resp.Content) { - try { $e = $resp.Content | ConvertFrom-Json -ErrorAction Stop; if ($e.error.message) { $msg += " $($e.error.message)" } } catch { } + try { $e = $resp.Content | ConvertFrom-Json -ErrorAction Stop; if ($e.error.message) { $msg += " $($e.error.message)" } } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } return [PSCustomObject]@{ HasData = $false @@ -62,7 +64,9 @@ function Get-BillingAccount { } $payload = $null - try { $payload = $resp.Content | ConvertFrom-Json -ErrorAction Stop } catch { } + try { $payload = $resp.Content | ConvertFrom-Json -ErrorAction Stop } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } $raw = @() if ($payload -and $payload.value) { $raw = @($payload.value) } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 index 54b2cccba..db7177a5b 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 @@ -31,7 +31,9 @@ function Get-ContractInfo { # QuotaId is always scoped to the correct tenant when using passed subs $subsToCheck = if ($Subscriptions) { @($Subscriptions | Select-Object -First 3) } else { @() } if ($subsToCheck.Count -eq 0) { - try { $subsToCheck = @(Get-AzSubscription -ErrorAction SilentlyContinue | Select-Object -First 3) } catch { } + try { $subsToCheck = @(Get-AzSubscription -ErrorAction SilentlyContinue | Select-Object -First 3) } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } foreach ($sub in $subsToCheck) { @@ -63,7 +65,9 @@ function Get-ContractInfo { break } } - } catch { } + } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } # -- Step 2: Try billing accounts API, filtered by inferred type ----- @@ -107,7 +111,9 @@ function Get-ContractInfo { } if ($owns) { $matchedAccount = $cand; break } } - } catch { } + } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } # No account could be confirmed to own the scanned subscription - # fall through to the subscription-accurate quotaId inference. diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 index dedb64c28..2c7751087 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 @@ -346,7 +346,9 @@ function Get-CostByTag { # Cleanup any timed-out jobs foreach ($job in $pendingJobs) { if ($null -eq $job.Result) { - try { $job.PS.Stop() } catch { } + try { $job.PS.Stop() } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } $job.PS.Dispose() $job.Result = [PSCustomObject]@{ StatusCode = 408; Content = '{"error":{"message":"Timeout"}}'; Headers = @{} } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 index 147413072..ddeb58406 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 @@ -259,6 +259,7 @@ function Get-CostData { } catch { # Forecast not available for this sub + Write-Verbose "Non-fatal: $($_.Exception.Message)" } } Write-Host " Per-sub forecast: got data for $hitCount of $subCount subscriptions" -ForegroundColor $(if ($hitCount -gt 0) { 'Green' } else { 'Yellow' }) @@ -363,6 +364,7 @@ function Get-CostDataPerSubscription { } catch { # Forecast not available for all account types + Write-Verbose "Non-fatal: $($_.Exception.Message)" } } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 index d7c18a23f..9c5299aaa 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 @@ -354,6 +354,7 @@ function Get-ResourceCosts { } catch { # Forecast not available for all account types + Write-Verbose "Non-fatal: $($_.Exception.Message)" } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 index 54ef6e90c..ab1478897 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 @@ -139,7 +139,9 @@ resources if ($tcRows.Count -gt 0 -and $null -ne $tcRows[0].TotalCount) { $totalCount = [int]$tcRows[0].TotalCount } - } catch { } + } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } # Fallback: derive counts from detail data if REST queries failed @@ -227,7 +229,9 @@ resources $tagCountRows = @(($tagCountResp.Content | ConvertFrom-Json).data) if ($tagCountRows.Count -gt 0) { $taggedFromArg = [int]$tagCountRows[0].TaggedCount } } - } catch { } + } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } if ($untaggedCount -eq 0 -and $untaggedResources.Count -gt 0) { $untaggedCount = $untaggedResources.Count } if (($taggedFromArg + $untaggedCount) -gt 0) { $totalCount = $taggedFromArg + $untaggedCount diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 index c50bdb9cd..04a36c93e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -60,7 +60,9 @@ function Get-VmSizeCapability { $pages++ } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } $Cache[$Location] = $map } $m = $Cache[$Location] @@ -152,7 +154,9 @@ resources } } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } return [math]::Round($totalBytes / 1GB, 1) } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 index b4b29b22b..c1876bcab 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 @@ -153,7 +153,9 @@ function Get-StorageBlobList { } foreach ($b in $nodes) { $lm = $null - if ($b.Properties.'Last-Modified') { try { $lm = [datetime]$b.Properties.'Last-Modified' } catch { } } + if ($b.Properties.'Last-Modified') { try { $lm = [datetime]$b.Properties.'Last-Modified' } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } [void]$out.Add([PSCustomObject]@{ Name = $b.Name; LastModified = $lm }) } $marker = $null @@ -338,7 +340,9 @@ function Find-CostExport { } } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } [void]$found.Add([PSCustomObject]@{ Name = $exp.name diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 index 5acc7234b..9b69ded0b 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 @@ -158,6 +158,7 @@ resources } catch { # Discovery failed - fall through to None (storage fallback). + Write-Verbose "Non-fatal: $($_.Exception.Message)" } return @{ Found = $false; Mode = 'None'; ClusterUri = $null; Database = $null; UseAuth = $false; HubVersion = $null; Source = 'None' } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 index 76e5870cc..19213a47f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 @@ -5,7 +5,9 @@ # public URL breaks Azure Government and Azure China. function Get-FinOpsArmEndpoint { $url = $null - try { $url = (Get-AzContext).Environment.ResourceManagerUrl } catch { } + try { $url = (Get-AzContext).Environment.ResourceManagerUrl } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } if ([string]::IsNullOrWhiteSpace($url)) { $url = 'https://management.azure.com' } return $url.TrimEnd('/') } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 index 01c85e016..241a624d3 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 @@ -112,14 +112,18 @@ function Invoke-FOHubKustoQuery { $reader.Dispose() } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } if ($detail) { try { $err = $detail | ConvertFrom-Json -ErrorAction Stop if ($err.error -and $err.error.'@message') { $msg = $err.error.'@message' } elseif ($err.error -and $err.error.message) { $msg = $err.error.message } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } return @{ Ok = $false; Rows = @(); RowCount = 0; Error = "Kusto query failed: $msg" } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 index 2987dadf7..8dfa81ee9 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 @@ -56,7 +56,9 @@ function Resolve-CostMgId { } } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } # Tenant root as a last-resort candidate (covers orgs where the cost role # is assigned at the root management group). diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index b2885cc7f..b4d91dc49 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -532,6 +532,7 @@ function Read-FinOpsHubData { } catch { # Path doesn't exist yet — that's OK + Write-Verbose "Non-fatal: $($_.Exception.Message)" } } @@ -939,7 +940,9 @@ function ConvertTo-TagInventoryFromHub { $tagsJson = if ($props -contains 'Tags') { $row.Tags } else { $null } $tagDict = $null if ($tagsJson -and $tagsJson.Trim() -ne '' -and $tagsJson.Trim() -ne '{}') { - try { $tagDict = ConvertTo-HashtableFromJson -Json $tagsJson } catch { } + try { $tagDict = ConvertTo-HashtableFromJson -Json $tagsJson } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } if ($tagDict -and $tagDict.Count -gt 0) { @@ -1041,7 +1044,9 @@ function ConvertTo-CostByTagFromHub { $tagsJson = if ($props -contains 'Tags') { $row.Tags } else { $null } $tagDict = $null if ($tagsJson -and $tagsJson.Trim() -ne '' -and $tagsJson.Trim() -ne '{}') { - try { $tagDict = ConvertTo-HashtableFromJson -Json $tagsJson } catch { } + try { $tagDict = ConvertTo-HashtableFromJson -Json $tagsJson } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } if ($targetTags.Count -eq 0 -and $tagDict -and $tagDict.Count -gt 0) { diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-BillingScope.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-BillingScope.ps1 index 6939f6b86..957e07496 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-BillingScope.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-BillingScope.ps1 @@ -80,7 +80,9 @@ function Get-FinOpsBillingScope { } } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } foreach ($ba in $accounts) { @@ -104,7 +106,9 @@ function Get-FinOpsBillingScope { } } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 index 68b6d4855..29a648ac7 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 @@ -86,6 +86,7 @@ function Resolve-CostDataSource { } catch { # Non-fatal — fall back to a subscription-only estimate + Write-Verbose "Non-fatal: $($_.Exception.Message)" } # ~10s base per subscription (MG attempt + per-sub fallback + throttle @@ -108,6 +109,7 @@ Resources } catch { # Detection failed — treat as no hub + Write-Verbose "Non-fatal: $($_.Exception.Message)" } if (-not $hub) { @@ -395,10 +397,14 @@ function Get-HubCoverage { $d = [datetime]$end if (-not $latestDate -or $d -gt $latestDate) { $latestDate = $d } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } finally { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue } @@ -409,6 +415,7 @@ function Get-HubCoverage { } catch { # Coverage stays empty (unknown) on any failure. + Write-Verbose "Non-fatal: $($_.Exception.Message)" } return $out @@ -473,7 +480,9 @@ function Resolve-GenericExportSource { $storageExports = @(Find-CostExportFromStorage -Subscriptions $subObjs -KnownKeys $knownKeys) if ($storageExports.Count -gt 0) { $exports = @($exports) + $storageExports } } - catch { } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } } if ($exports.Count -eq 0) { return $out } From 45f6a4e52e9c258102a141d97d6076389e7ad7e6 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 9 Sep 2026 07:58:07 -0600 Subject: [PATCH 116/142] Update FinOps Multitool --- .../FinOpsMultitool/FinOpsMultitool.psm1 | 1 + .../Private/FinOpsMultitool/README.md | 12 ++-- .../modules/Get-SharedCostAllocation.ps1 | 2 + .../modules/Get-TagRecommendations.ps1 | 7 +-- .../modules/Get-VmCostBreakdown.ps1 | 2 + .../modules/helpers/Get-JitteredDelay.ps1 | 48 +++++++++++++++ .../helpers/Invoke-AzRestMethodWithRetry.ps1 | 11 ++-- .../modules/helpers/Search-AzGraphSafe.ps1 | 7 ++- .../Tests/Unit/RetryJitter.Tests.ps1 | 59 +++++++++++++++++++ 9 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 src/powershell/Private/FinOpsMultitool/modules/helpers/Get-JitteredDelay.ps1 create mode 100644 src/powershell/Tests/Unit/RetryJitter.Tests.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 index 7bfcb29fa..733bce896 100644 --- a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 +++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 @@ -37,6 +37,7 @@ foreach ($azMod in @('Az.Accounts', 'Az.Storage', 'Az.ResourceGraph')) { # -- Helpers (runspace pool, REST retry, ARG wrapper, MG-scope state) ---- $helpersPath = Join-Path $PSScriptRoot 'modules\helpers' . (Join-Path $helpersPath 'Get-PlainAccessToken.ps1') +. (Join-Path $helpersPath 'Get-JitteredDelay.ps1') . (Join-Path $helpersPath 'Invoke-AzRestMethodWithRetry.ps1') . (Join-Path $helpersPath 'Get-CostQueryResponsePage.ps1') . (Join-Path $helpersPath 'Resolve-CurrencyLabel.ps1') diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index 8a00da877..bb8af3164 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -18,12 +18,12 @@ Invoke-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' ## Requirements -| Requirement | Details | -| --------------------- | --------------------------------------------------------------- | -| PowerShell | 7.0 or later (Windows, macOS, Linux) | -| Az modules | `Az.Accounts`, `Az.ResourceGraph`, `Az.Storage` | -| Azure RBAC | Reader + Cost Management Reader on target scope | -| FinOps Hub (optional) | Storage Blob Data Reader on Hub storage account | +| Requirement | Details | +| --------------------- | ----------------------------------------------- | +| PowerShell | 7.0 or later (Windows, macOS, Linux) | +| Az modules | `Az.Accounts`, `Az.ResourceGraph`, `Az.Storage` | +| Azure RBAC | Reader + Cost Management Reader on target scope | +| FinOps Hub (optional) | Storage Blob Data Reader on Hub storage account | Install Az modules if needed: diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 index c9857424d..b03b9cd18 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -308,6 +308,8 @@ function Get-SharedCostAllocation { Note = 'Provide spokes - the subscription IDs that share the hub resources.' } } + # A repeated subscription ID would inflate $nSpokes and dilute every spoke's fixed share. + $Spokes = @($Spokes | Select-Object -Unique) if ($FixedRatio -lt 0) { $FixedRatio = 0 } if ($FixedRatio -gt 1) { $FixedRatio = 1 } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 index c04833dfe..570734faf 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 @@ -159,14 +159,13 @@ function Get-TagRecommendations { } } - $missingRequired = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Required' }) - $missingRecommended = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Recommended' }) - $present = @($analysis | Where-Object { $_.Status -ne 'Missing' }) + # Every catalog entry is Required, so there is no Recommended tier to report. + $missingRequired = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Required' }) + $present = @($analysis | Where-Object { $_.Status -ne 'Missing' }) return [PSCustomObject]@{ Analysis = $analysis MissingRequired = $missingRequired - MissingRecommended = $missingRecommended Present = $present CompliancePercent = [math]::Round(($present.Count / $analysis.Count) * 100, 0) } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 index 012b84192..10caba848 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 @@ -105,6 +105,7 @@ resources osDiskId, dataDisks = properties.storageProfile.dataDisks, nics = properties.networkProfile.networkInterfaces +| order by id asc "@ $res = Search-AzGraphSafe -Query $vmQuery -Subscription $SubscriptionIds -First 50 @@ -112,6 +113,7 @@ resources if ($rows.Count -eq 0) { return $null } $ambiguous = $rows.Count -gt 1 + # Rows are ordered by id, so an ambiguous name resolves to the same VM every run. $vm = $rows[0] $assoc = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-JitteredDelay.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-JitteredDelay.ps1 new file mode 100644 index 000000000..2b47b7029 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-JitteredDelay.ps1 @@ -0,0 +1,48 @@ +########################################################################### +# GET-JITTEREDDELAY.PS1 +# RETRY BACKOFF JITTER +########################################################################### +# Purpose: Spread retry wake-ups so concurrent scans stop retrying in lockstep +# Author: Zac Larsen +# Date: Created for FinOps Multitool +# +# Description: +# Pure exponential backoff is deterministic, so several scans that are +# throttled by the same Azure endpoint at the same moment will all sleep for +# the same interval and retry together, re-triggering the throttle. This +# helper adds randomness to break that synchronization: +# 1. Computed backoff uses equal jitter - half the delay is fixed, half is +# random - which keeps a sensible floor while decorrelating callers. +# 2. A server-supplied Retry-After is treated as a hard floor and only ever +# extended, never shortened, so the service's instruction is respected. +# +# -- Parameters ------------------------------------------------------------- +# BaseSeconds Computed backoff to jitter, in seconds +# RetryAfterSeconds Server-supplied Retry-After floor, in seconds +# +# Usage: Get-JitteredDelay -BaseSeconds 8 +########################################################################### + +function Get-JitteredDelay { + [CmdletBinding(DefaultParameterSetName = 'Computed')] + [OutputType([double])] + param( + [Parameter(Mandatory, ParameterSetName = 'Computed')] + [double]$BaseSeconds, + + [Parameter(Mandatory, ParameterSetName = 'RetryAfter')] + [double]$RetryAfterSeconds + ) + + if ($PSCmdlet.ParameterSetName -eq 'RetryAfter') { + # Never sleep less than the service asked for; add up to 1s of spread. + if ($RetryAfterSeconds -lt 0) { $RetryAfterSeconds = 0 } + return $RetryAfterSeconds + (Get-Random -Minimum 0.0 -Maximum 1.0) + } + + if ($BaseSeconds -le 0) { return 0.0 } + + # Equal jitter: floor at half the backoff, randomize the other half. + $half = $BaseSeconds / 2 + return $half + (Get-Random -Minimum 0.0 -Maximum $half) +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 index 5541da051..94ed7ed64 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 @@ -160,19 +160,20 @@ function Invoke-AzRestMethodWithRetry { if (-not ($isThrottled -or $isServerErr)) { return $resp } if ($attempt -eq $MaxRetries) { return $resp } - # Parse Retry-After header or default to exponential backoff - $retryAfter = 10 + # Parse Retry-After header or default to exponential backoff. Both paths + # are jittered so parallel scans do not retry in lockstep. + $retryAfter = Get-JitteredDelay -BaseSeconds 10 if ($isThrottled -and $resp.Headers -and $resp.Headers['Retry-After']) { $parsed = 0 if ([int]::TryParse($resp.Headers['Retry-After'], [ref]$parsed)) { - $retryAfter = [math]::Max($parsed, 5) + $retryAfter = Get-JitteredDelay -RetryAfterSeconds ([math]::Max($parsed, 5)) } } elseif ($isServerErr) { - $retryAfter = [math]::Min(2 * [math]::Pow(2, $attempt), 30) + $retryAfter = Get-JitteredDelay -BaseSeconds ([math]::Min(2 * [math]::Pow(2, $attempt), 30)) } else { - $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 60) + $retryAfter = Get-JitteredDelay -BaseSeconds ([math]::Min(10 * [math]::Pow(2, $attempt), 60)) } $friendly = if ($isThrottled) { Get-NextThrottleMessage } else { "Azure returned $($resp.StatusCode) - retrying..." } Write-Host " $friendly" -ForegroundColor Yellow diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 index e621ae092..182892a17 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 @@ -83,12 +83,13 @@ function Search-AzGraphSafe { if (-not ($is429 -or $isTransient)) { return $result } if ($attempt -eq $MaxRetries) { return $result } - # Throttling backs off harder than a transient server error. + # Throttling backs off harder than a transient server error. Both are + # jittered so parallel scans do not retry in lockstep. $retryAfter = if ($is429) { - [math]::Min(10 * [math]::Pow(2, $attempt), 30) + Get-JitteredDelay -BaseSeconds ([math]::Min(10 * [math]::Pow(2, $attempt), 30)) } else { - [math]::Min(2 * [math]::Pow(2, $attempt), 15) + Get-JitteredDelay -BaseSeconds ([math]::Min(2 * [math]::Pow(2, $attempt), 15)) } $friendly = if ($is429) { if (Get-Command Get-NextThrottleMessage -ErrorAction SilentlyContinue) { Get-NextThrottleMessage } else { 'Fetching numbers......' } diff --git a/src/powershell/Tests/Unit/RetryJitter.Tests.ps1 b/src/powershell/Tests/Unit/RetryJitter.Tests.ps1 new file mode 100644 index 000000000..7755fdd79 --- /dev/null +++ b/src/powershell/Tests/Unit/RetryJitter.Tests.ps1 @@ -0,0 +1,59 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Retry backoff jitter' { + + # 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 + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Computed backoff' { + It 'Stays within the equal-jitter band for s' -ForEach @( + @{ Base = 2 } + @{ Base = 10 } + @{ Base = 30 } + @{ Base = 60 } + ) { + foreach ($i in 1..200) { + $d = Get-JitteredDelay -BaseSeconds $Base + $d | Should -BeGreaterOrEqual ($Base / 2) + $d | Should -BeLessOrEqual $Base + } + } + + It 'Never returns a negative delay' { + Get-JitteredDelay -BaseSeconds 0 | Should -Be 0 + Get-JitteredDelay -BaseSeconds -5 | Should -Be 0 + } + + It 'Actually decorrelates - repeated calls are not all identical' { + $seen = 1..50 | ForEach-Object { Get-JitteredDelay -BaseSeconds 10 } + (@($seen | Select-Object -Unique)).Count | Should -BeGreaterThan 1 + } + } + + Context 'Server-supplied Retry-After' { + It 'Treats Retry-After as a floor and never sleeps less' { + foreach ($i in 1..200) { + $d = Get-JitteredDelay -RetryAfterSeconds 5 + $d | Should -BeGreaterOrEqual 5 + $d | Should -BeLessOrEqual 6 + } + } + + It 'Clamps a nonsensical negative Retry-After to zero' { + $d = Get-JitteredDelay -RetryAfterSeconds -10 + $d | Should -BeGreaterOrEqual 0 + $d | Should -BeLessOrEqual 1 + } + } +} From 4c99844076723e8fd66be9b5c89f0bb973a6803b Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 14 Sep 2026 17:33:01 -0600 Subject: [PATCH 117/142] FinOps Multitool Update - Sep 09-14 Review findings - Search-AzGraphSafe: added -All to follow the Resource Graph continuation token; applied to the 21 query sites that stopped at 1000 rows - Get-CostTrend: follow the Cost Management nextLink on all three query paths - Get-CommitmentUtilization: query every selected subscription instead of the first 10 or 5; keep one row per commitment so counts and averages are not inflated by monthly records - Get-BudgetStatus: report sampled coverage as unverified instead of asserting no budgets tenant-wide - Read-FinOpsHubData: use the dotnet SDK instead of nuget.exe on macOS and Linux, and state the reason when neither is available - Read-FinOpsHubData: stage native libraries per runtime identifier and hash .so and .dylib in the integrity manifest - Added tests for Resource Graph pagination, commitment de-duplication and package client resolution --- .../Invoke-FinOpsMultitool.ps1 | 19 +- .../modules/Get-AHBOpportunities.ps1 | 6 +- .../modules/Get-AIWorkloadMetrics.ps1 | 2 +- .../modules/Get-BudgetStatus.ps1 | 41 ++- .../modules/Get-CommitmentUtilization.ps1 | 115 +++++++-- .../FinOpsMultitool/modules/Get-CostTrend.ps1 | 33 ++- .../FinOpsMultitool/modules/Get-IdleVMs.ps1 | 2 +- .../modules/Get-LegacyResources.ps1 | 10 +- .../modules/Get-OrphanedResources.ps1 | 12 +- .../modules/Get-PolicyInventory.ps1 | 2 +- .../modules/Get-SavingsRealized.ps1 | 2 +- .../modules/Get-SharedCostAllocation.ps1 | 2 +- .../modules/Get-StorageTierAdvice.ps1 | 2 +- .../modules/Get-UnitEconomics.ps1 | 4 +- .../modules/helpers/Read-FinOpsHubData.ps1 | 241 ++++++++++++++---- .../modules/helpers/Search-AzGraphSafe.ps1 | 65 +++++ .../Tests/Unit/AzGraphPagination.Tests.ps1 | 107 ++++++++ .../CommitmentUtilizationDedupe.Tests.ps1 | 108 ++++++++ .../Tests/Unit/ParquetPackageClient.Tests.ps1 | 127 +++++++++ 19 files changed, 780 insertions(+), 120 deletions(-) create mode 100644 src/powershell/Tests/Unit/AzGraphPagination.Tests.ps1 create mode 100644 src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 create mode 100644 src/powershell/Tests/Unit/ParquetPackageClient.Tests.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index ded6e3dad..dba81d5f9 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1759,7 +1759,12 @@ function Invoke-FinOpsMultitool { Write-Host "At risk: $($data.AtRiskCount)" -ForegroundColor $(if ($data.AtRiskCount -gt 0) { 'Yellow' } else { 'Green' }) -NoNewline Write-Host " | " -ForegroundColor White -NoNewline Write-Host "Over budget: $($data.OverBudgetCount)" -ForegroundColor $(if ($data.OverBudgetCount -gt 0) { 'Red' } else { 'Green' }) -NoNewline - Write-Host " | Coverage: $($data.BudgetCoverage)%" -ForegroundColor White + if ($data.CoverageIncomplete) { + Write-Host " | Coverage: unverified (sampled $($data.ScannedSubs) of $($data.TotalSubs) subs)" -ForegroundColor Yellow + } + else { + Write-Host " | Coverage: $($data.BudgetCoverage)%" -ForegroundColor White + } $rows = $data.Budgets | ForEach-Object { [PSCustomObject]@{ Budget = $_.BudgetName @@ -2342,6 +2347,12 @@ function Invoke-FinOpsMultitool { @{ Severity = 'Yellow'; Message = "FinOps Practice: Proactive budget monitoring prevents end-of-period surprises. Consider cost reduction now." } ) } + elseif ($data.CoverageIncomplete) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "No budgets found in a sample of $($data.ScannedSubs) of $($data.TotalSubs) subscriptions. Coverage across the rest is unverified." } + @{ Severity = 'Yellow'; Message = "Re-run against a narrower subscription set to measure budget coverage exactly."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' } + ) + } elseif ($bCoverage -lt 50) { $guidanceItems = @( @{ Severity = 'Red'; Message = "Budget coverage is only $bCoverage%. Most subscriptions have no budget — spending is untracked." } @@ -2936,7 +2947,11 @@ tr:hover td { background: var(--surface); } [void]$htmlSb.Append("

RI: $($data.RISavingsMonthly.ToString('C0'))  |  SP: $($data.SPSavingsMonthly.ToString('C0'))  |  AHB: $($data.AHBSavingsMonthly.ToString('C0'))  |  Total: $($data.TotalMonthly.ToString('C0'))/mo

") } 'Get-BudgetStatus' { - [void]$htmlSb.Append("

Budgets: $($data.TotalBudgets)  |  At risk: $($data.AtRiskCount)  |  Over budget: $($data.OverBudgetCount)  |  Coverage: $($data.BudgetCoverage)%

") + $htmlCoverage = if ($data.CoverageIncomplete) { + "unverified (sampled $($data.ScannedSubs) of $($data.TotalSubs) subs)" + } + else { "$($data.BudgetCoverage)%" } + [void]$htmlSb.Append("

Budgets: $($data.TotalBudgets)  |  At risk: $($data.AtRiskCount)  |  Over budget: $($data.OverBudgetCount)  |  Coverage: $htmlCoverage

") $htmlRows = $data.Budgets | ForEach-Object { $riskClass = switch ($_.Risk) { 'Over Budget' { 'severity-red' } 'Forecast Over' { 'severity-yellow' } 'At Risk' { 'severity-yellow' } 'Watch' { 'severity-yellow' } default { 'severity-green' } } [PSCustomObject]@{ Budget = $_.BudgetName; Amount = '{0:C0}' -f [double]$_.Amount; Spent = '{0:C0}' -f [double]$_.ActualSpend; PctUsed = "$($_.PctUsed)%"; Risk = $_.Risk; _riskClass = $riskClass } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 index 86c80ec03..33130124a 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 @@ -41,7 +41,7 @@ resources osType = tostring(properties.storageProfile.imageReference.offer) | order by subscriptionId asc, name asc "@ - $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 -All $windowsVMs = if ($result) { @($result.Data) } else { @() } } catch { Write-Warning "Windows VM AHB scan failed: $($_.Exception.Message)" @@ -70,7 +70,7 @@ resources sqlEdition = tostring(properties.sqlImageSku) | order by subscriptionId asc, name asc "@ - $result = Search-AzGraphSafe -Query $sqlVMQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $sqlVMQuery -Subscription $subIds -First 1000 -All $sqlVMs = if ($result) { @($result.Data) } else { @() } } catch { Write-Warning "SQL VM AHB scan failed: $($_.Exception.Message)" @@ -91,7 +91,7 @@ resources maxSizeGB = tolong(properties.maxSizeBytes) / 1073741824 | order by subscriptionId asc, name asc "@ - $result = Search-AzGraphSafe -Query $sqlDBQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $sqlDBQuery -Subscription $subIds -First 1000 -All $sqlDBs = if ($result) { @($result.Data) } else { @() } } catch { Write-Warning "SQL Database AHB scan failed: $($_.Exception.Message)" diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 index 96d0f2782..0737ec310 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -70,7 +70,7 @@ resources and tostring(properties.hardwareProfile.vmSize) matches regex @'(?i)^Standard_N') | project id, name, type, lkind, subscriptionId, location "@ - $result = Search-AzGraphSafe -Query $gateQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $gateQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 index 2e3ff3c70..a184c4e7c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -33,6 +33,8 @@ function Get-BudgetStatus { $subsWithBudget = 0 $subsWithoutBudget = 0 $sampled = $false + $scannedSubs = $subCount + $coverageIncomplete = $false # -- For large tenants, sample first to see if budgets exist -------- $subsToQuery = $Subscriptions @@ -61,10 +63,15 @@ function Get-BudgetStatus { } if ($sampleHits -eq 0 -and $sampleErrors -eq 0) { - # Only skip the tenant when every probe actually answered. - Write-Host " No budgets found in sample of $sampleSize subs - skipping remaining" -ForegroundColor Yellow + # Every probe answered and none held a budget. That is evidence about + # the sampled subscriptions only - budgets can still exist in the ones + # never queried - so the result is marked incomplete rather than + # reported as "no budgets" for the whole tenant. + Write-Host " No budgets found in sample of $sampleSize subs - skipping remaining (coverage unverified)" -ForegroundColor Yellow $sampled = $true - $subsWithoutBudget = $subCount + $coverageIncomplete = $true + $scannedSubs = $sampleSize + $subsWithoutBudget = $sampleSize $subsToQuery = @() # Skip the main loop } elseif ($sampleHits -eq 0) { @@ -200,15 +207,25 @@ function Get-BudgetStatus { $atRisk = @($budgets | Where-Object { $_.Risk -in @('Forecast Over', 'At Risk') }).Count return [PSCustomObject]@{ - Budgets = @($budgets) - TotalBudgets = $budgets.Count - SubsWithBudget = $subsWithBudget - SubsWithoutBudget = $subsWithoutBudget - OverBudgetCount = $overBudget - AtRiskCount = $atRisk - HasData = ($budgets.Count -gt 0) - Sampled = $sampled - BudgetCoverage = if ($Subscriptions.Count -gt 0) { + Budgets = @($budgets) + TotalBudgets = $budgets.Count + SubsWithBudget = $subsWithBudget + SubsWithoutBudget = $subsWithoutBudget + OverBudgetCount = $overBudget + AtRiskCount = $atRisk + HasData = ($budgets.Count -gt 0) + Sampled = $sampled + ScannedSubs = $scannedSubs + TotalSubs = $subCount + CoverageIncomplete = $coverageIncomplete + Note = if ($coverageIncomplete) { + "Sampled $scannedSubs of $subCount subscriptions and none had a budget. Budgets may still exist in the subscriptions that were not queried, so coverage is unverified." + } + else { $null } + # Left null when incomplete: a percentage derived from a sample would be + # read as a measured coverage figure for the whole tenant. + BudgetCoverage = if ($coverageIncomplete) { $null } + elseif ($Subscriptions.Count -gt 0) { [math]::Round(($subsWithBudget / $Subscriptions.Count) * 100, 1) } else { 0 } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 index c300def60..0f28897e4 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 @@ -10,6 +10,23 @@ # CFO question: "Are we wasting what we already bought?" ########################################################################### +# Compares two usageDate values so only the newest period per commitment is +# kept. The API returns an ISO string; an unparsable value falls back to an +# ordinal compare, and a missing existing value always loses. +function Test-UsageDateIsNewer { + param($Candidate, $Existing) + + if ($null -eq $Existing) { return $true } + if ($null -eq $Candidate) { return $false } + + $c = [datetime]::MinValue + $e = [datetime]::MinValue + if ([datetime]::TryParse([string]$Candidate, [ref]$c) -and [datetime]::TryParse([string]$Existing, [ref]$e)) { + return ($c -gt $e) + } + return ([string]$Candidate -gt [string]$Existing) +} + function Get-CommitmentUtilization { [CmdletBinding()] param( @@ -106,7 +123,27 @@ function Get-CommitmentUtilization { # For EA / fallback: query at subscription scope if ($reservations.Count -eq 0) { - foreach ($sub in $Subscriptions | Select-Object -First 10) { + # Every selected subscription is queried. Stopping at the first one that + # answers, or at an arbitrary first N, hides reservations that only the + # remaining subscriptions can see. + # + # Two different duplications have to be collapsed before the summary + # stats are computed. A reservation is reported by every subscription + # that consumed it, and reservationSummaries returns one record per + # usage period. Keying on the reservation alone - and keeping only its + # newest period - leaves exactly one row per commitment, so RICount + # counts reservations rather than API records and the average is not + # weighted towards whichever reservation happens to span more months. + $latestReservation = @{} + $subTotal = @($Subscriptions).Count + $subIdx = 0 + foreach ($sub in $Subscriptions) { + $subIdx++ + if ($subIdx -eq 1 -or $subIdx -eq $subTotal -or ($subTotal -gt 5 -and $subIdx % [math]::Max(1, [int]($subTotal / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying reservations ($subIdx/$subTotal subs)..." + } + } try { $summaryPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'" $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET @@ -115,7 +152,11 @@ function Get-CommitmentUtilization { if ($data.value) { foreach ($item in $data.value) { $p = $item.properties - $reservations += [PSCustomObject]@{ + $key = "$($p.reservationOrderId)/$($p.reservationId)" + if ([string]::IsNullOrWhiteSpace(($key -replace '/', ''))) { continue } + $existing = $latestReservation[$key] + if ($existing -and -not (Test-UsageDateIsNewer -Candidate $p.usageDate -Existing $existing.UsageDate)) { continue } + $latestReservation[$key] = [PSCustomObject]@{ ReservationOrderId = $p.reservationOrderId ReservationId = $p.reservationId SkuName = $p.skuName @@ -128,7 +169,6 @@ function Get-CommitmentUtilization { UsageDate = $p.usageDate } } - break # Got data from one sub, don't repeat } } elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } @@ -137,6 +177,7 @@ function Get-CommitmentUtilization { Write-Warning " Reservation summaries query failed for $($sub.Name): $($_.Exception.Message)" } } + $reservations += @($latestReservation.Values) } # -- Step 2: Try the Reservation Orders API at billing scope -- @@ -205,7 +246,8 @@ function Get-CommitmentUtilization { $p = $item.properties if ($p.benefitType -eq 'SavingsPlan') { $savingsPlans += [PSCustomObject]@{ - BenefitId = $p.benefitOrderId + BenefitId = $p.benefitId + BenefitOrderId = $p.benefitOrderId BenefitType = $p.benefitType AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) UsageDate = $p.usageDate @@ -224,33 +266,56 @@ function Get-CommitmentUtilization { # Fallback: subscription scope (EA, PAYG, etc.) if ($savingsPlans.Count -eq 0) { - try { - foreach ($sub in $Subscriptions | Select-Object -First 5) { - $spPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly" - $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET - if ($spResp.StatusCode -eq 200) { - $spData = ($spResp.Content | ConvertFrom-Json) - if ($spData.value) { - foreach ($item in $spData.value) { - $p = $item.properties - if ($p.benefitType -eq 'SavingsPlan') { - $savingsPlans += [PSCustomObject]@{ - BenefitId = $p.benefitOrderId - BenefitType = $p.benefitType - AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) - UsageDate = $p.usageDate + # Same coverage rule as reservations: query every selected subscription + # and de-duplicate, rather than sampling a few and stopping at the first + # hit. The try sits inside the loop so one unreadable subscription does + # not abandon the ones after it. + # + # Keyed on benefitId, not benefitOrderId: one order can contain several + # savings plans, so ordering alone would collapse distinct plans into + # one and drop real commitments. Only the newest period per plan is + # kept, so SPCount counts plans rather than monthly records. + $latestSavingsPlan = @{} + $spTotal = @($Subscriptions).Count + $spIdx = 0 + foreach ($sub in $Subscriptions) { + $spIdx++ + if ($spIdx -eq 1 -or $spIdx -eq $spTotal -or ($spTotal -gt 5 -and $spIdx % [math]::Max(1, [int]($spTotal / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying savings plans ($spIdx/$spTotal subs)..." + } + } + try { + $spPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly" + $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET + if ($spResp.StatusCode -eq 200) { + $spData = ($spResp.Content | ConvertFrom-Json) + if ($spData.value) { + foreach ($item in $spData.value) { + $p = $item.properties + if ($p.benefitType -eq 'SavingsPlan') { + $key = if ($p.benefitId) { [string]$p.benefitId } else { [string]$p.benefitOrderId } + if ([string]::IsNullOrWhiteSpace($key)) { continue } + $existing = $latestSavingsPlan[$key] + if ($existing -and -not (Test-UsageDateIsNewer -Candidate $p.usageDate -Existing $existing.UsageDate)) { continue } + $latestSavingsPlan[$key] = [PSCustomObject]@{ + BenefitId = $p.benefitId + BenefitOrderId = $p.benefitOrderId + BenefitType = $p.benefitType + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + UsageDate = $p.usageDate + } } } } - if ($savingsPlans.Count -gt 0) { break } } + elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Savings plan utilization query failed for $($sub.Name): $($_.Exception.Message)" } - elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true } - } - } catch { - if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } - Write-Warning " Savings plan utilization query failed: $($_.Exception.Message)" } + $savingsPlans += @($latestSavingsPlan.Values) } # -- Step 4: Calculate summary stats -- diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 index 0862f8981..ca3629c29 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 @@ -116,6 +116,21 @@ function Get-CostTrend { return $entries } + # Cost Management answers one page at a time. Reading only the first page + # under-reports a large scope as lower spend rather than as an error, so + # every page is collected before the rows are parsed. + function Get-AllCostRow { + param($FirstResponse, [string]$Context) + $rows = [System.Collections.Generic.List[object]]::new() + $columns = $null + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $FirstResponse -Context $Context)) { + $parsed = ($page.Content | ConvertFrom-Json) + if (-not $columns) { $columns = $parsed.properties.columns } + foreach ($row in @($parsed.properties.rows)) { [void]$rows.Add($row) } + } + return [PSCustomObject]@{ Rows = @($rows); Columns = $columns } + } + try { # Parse a SubscriptionId-grouped Monthly response into per-sub entries. function Parse-GroupedCostRows { @@ -203,9 +218,9 @@ function Get-CostTrend { $subPath = "/subscriptions/$($only.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body if ($subResp.StatusCode -eq 200) { - $subResult = ($subResp.Content | ConvertFrom-Json) - if ($subResult.properties.rows) { - $months = Parse-CostRows -Rows $subResult.properties.rows -Columns $subResult.properties.columns + $paged = Get-AllCostRow -FirstResponse $subResp -Context "cost trend for $($only.Name)" + if ($paged.Rows.Count -gt 0) { + $months = Parse-CostRows -Rows $paged.Rows -Columns $paged.Columns $bySubscription[$only.Id] = @($months | Sort-Object MonthDate) } } else { @@ -227,9 +242,9 @@ function Get-CostTrend { $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $groupedBody if ($response.StatusCode -eq 200) { - $result = ($response.Content | ConvertFrom-Json) - if ($result.properties.rows) { - $entries = Parse-GroupedCostRows -Rows $result.properties.rows -Columns $result.properties.columns + $paged = Get-AllCostRow -FirstResponse $response -Context 'management-group cost trend' + if ($paged.Rows.Count -gt 0) { + $entries = Parse-GroupedCostRows -Rows $paged.Rows -Columns $paged.Columns Set-TrendFromGrouped -Entries $entries $groupedOk = ($months.Count -gt 0) } @@ -259,9 +274,9 @@ function Get-CostTrend { $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body if ($subResp.StatusCode -eq 200) { - $subResult = ($subResp.Content | ConvertFrom-Json) - if ($subResult.properties.rows) { - $subMonths = Parse-CostRows -Rows $subResult.properties.rows -Columns $subResult.properties.columns + $paged = Get-AllCostRow -FirstResponse $subResp -Context "cost trend for $($sub.Name)" + if ($paged.Rows.Count -gt 0) { + $subMonths = Parse-CostRows -Rows $paged.Rows -Columns $paged.Columns $bySubscription[$sub.Id] = @($subMonths | Sort-Object MonthDate) foreach ($sm in $subMonths) { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 index 5eae2de78..1b8aaa685 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 @@ -39,7 +39,7 @@ resources osType = properties.storageProfile.osDisk.osType, powerState "@ - $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -All $allVMs = if ($result) { @($result.Data) } else { @() } $totalVMs = $allVMs.Count $runningVMs = @($allVMs | Where-Object { $_.powerState -eq 'PowerState/running' }) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 index 5a926ec6d..a5623b784 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 @@ -39,7 +39,7 @@ resources | where vmSize matches regex @'(?i)^(Basic_A[0-9]+|Standard_A[0-7]|Standard_D[0-9]+|Standard_DS[0-9]+|Standard_F[0-9]+|Standard_G[0-9]+|Standard_GS[0-9]+)$' | project name, resourceGroup, subscriptionId, location, vmSize "@ - $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allLegacy.Add([PSCustomObject]@{ @@ -67,7 +67,7 @@ resources | project name, resourceGroup, subscriptionId, location, vhd = tostring(properties.storageProfile.osDisk.vhd.uri) "@ - $result = Search-AzGraphSafe -Query $vhdQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $vhdQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allLegacy.Add([PSCustomObject]@{ @@ -96,7 +96,7 @@ resources | project name, resourceGroup, subscriptionId, location, diskSizeGb = properties.diskSizeGB, sku = sku.name "@ - $result = Search-AzGraphSafe -Query $hddQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $hddQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allLegacy.Add([PSCustomObject]@{ @@ -123,7 +123,7 @@ resources | where tostring(sku.name) =~ 'Basic' | project name, resourceGroup, subscriptionId, location, sku = sku.name "@ - $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allLegacy.Add([PSCustomObject]@{ @@ -150,7 +150,7 @@ resources | where tostring(sku.name) =~ 'Basic' | project name, resourceGroup, subscriptionId, location, sku = sku.name "@ - $result = Search-AzGraphSafe -Query $lbQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $lbQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allLegacy.Add([PSCustomObject]@{ diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 index 9d3b8d446..402b02c60 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -35,7 +35,7 @@ resources sku = sku.name, diskState = properties.diskState, type = 'Orphaned Disk' "@ - $result = Search-AzGraphSafe -Query $diskQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $diskQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allOrphans.Add([PSCustomObject]@{ @@ -87,7 +87,7 @@ resources | project id, name, resourceGroup, subscriptionId, location, sku, allocationMethod, ipAddress, ipConfigId, natGw, nicName, vmName, vmPower "@ - $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } $pipUnattached = 0 $pipStoppedVm = 0 @@ -143,7 +143,7 @@ resources enableAcceleratedNetworking = properties.enableAcceleratedNetworking, type = 'Unattached NIC' "@ - $result = Search-AzGraphSafe -Query $nicQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $nicQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allOrphans.Add([PSCustomObject]@{ @@ -177,7 +177,7 @@ resources dataDisks = properties.storageProfile.dataDisks, type = 'Deallocated VM' "@ - $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { # A stopped VM bills nothing itself; the spend sits on its managed disks. @@ -217,7 +217,7 @@ resources workers = properties.numberOfWorkers, type = 'Empty App Service Plan' "@ - $result = Search-AzGraphSafe -Query $aspQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $aspQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allOrphans.Add([PSCustomObject]@{ @@ -249,7 +249,7 @@ resources timeCreated = properties.timeCreated, type = 'Old Snapshot' "@ - $result = Search-AzGraphSafe -Query $snapQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $snapQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { [void]$allOrphans.Add([PSCustomObject]@{ diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 index 61abc07d1..dbf8134a6 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -262,7 +262,7 @@ policyresources by subscriptionId "@ $subIds = $Subscriptions | ForEach-Object { $_.Id } - $compResult = Search-AzGraphSafe -Query $compQuery -Subscription $subIds -First 1000 + $compResult = Search-AzGraphSafe -Query $compQuery -Subscription $subIds -First 1000 -All if ($compResult -and $compResult.Data -and $compResult.Data.Count -gt 0) { foreach ($row in $compResult.Data) { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 index 85eb54159..6b5636c07 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -343,7 +343,7 @@ resources | project vmSize = tostring(properties.hardwareProfile.vmSize), location "@ $subIds = $Subscriptions | ForEach-Object { $_.Id } - $ahbResult = Search-AzGraphSafe -Query $ahbQuery -Subscription $subIds + $ahbResult = Search-AzGraphSafe -Query $ahbQuery -Subscription $subIds -All $ahbVMs = if ($ahbResult.Data) { @($ahbResult.Data) } else { @() } if ($ahbVMs.Count -gt 0) { $ahbSavings = 0 diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 index b03b9cd18..d0e8bd5b7 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -141,7 +141,7 @@ resources | where subscriptionId in~ ($spokeList) | summarize c = count() by subscriptionId "@ - $res = Search-AzGraphSafe -Query $query -Subscription $Spokes -First 1000 + $res = Search-AzGraphSafe -Query $query -Subscription $Spokes -First 1000 -All foreach ($r in @($res.Data)) { $sid = [string]$r.subscriptionId if ($weights.ContainsKey($sid)) { $weights[$sid] = [double]$r.c } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 index ee93ab7de..7575085c2 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 @@ -35,7 +35,7 @@ resources creationTime = properties.creationTime, blobCount = properties.primaryEndpoints.blob "@ - $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -All $hotAccounts = if ($result) { @($result.Data) } else { @() } Write-Host " Hot-tier storage accounts: $($hotAccounts.Count)" -ForegroundColor Gray } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 index 04a36c93e..32678ddb4 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -122,7 +122,7 @@ resources | where type =~ 'microsoft.storage/storageaccounts' | project id "@ - $result = Search-AzGraphSafe -Query $saQuery -Subscription $SubIds -First 1000 + $result = Search-AzGraphSafe -Query $saQuery -Subscription $SubIds -First 1000 -All $accounts = if ($result) { @($result.Data) } else { @() } } catch { @@ -189,7 +189,7 @@ resources | extend vmSize = tostring(properties.hardwareProfile.vmSize), loc = tostring(location), subId = tostring(subscriptionId) | summarize cnt = count() by vmSize, loc, subId "@ - $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 -All $rows = if ($result) { @($result.Data) } else { @() } foreach ($r in $rows) { $count = [int]$r.cnt diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index b4d91dc49..e36a0ca93 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -12,7 +12,9 @@ # Reads FOCUS-schema cost data from a Hub storage account. # Prefers parquet from the ingestion container (normalized FOCUS); # falls back to CSV from msexports if ingestion is empty. -# Parquet.Net + all transitive deps are auto-installed via nuget.exe. +# Parquet.Net + all transitive deps are restored with whichever NuGet client the +# host has (nuget.exe on Windows, the dotnet SDK elsewhere), honouring the +# machine's configured feeds. # # 1. Checks ingestion container for parquet (preferred) # 2. Falls back to msexports CSV if no parquet found @@ -52,7 +54,10 @@ function Get-ParquetPayloadFile { $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 + # Native payloads are .dll on Windows but .so/.dylib elsewhere, and a + # .dll-only filter would leave those unhashed on Linux and macOS. + Get-ChildItem -LiteralPath $r -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '\.(dll|dylib|so)(\.\d+)*$' } } } return @($files | Sort-Object FullName) @@ -108,7 +113,7 @@ function Test-ParquetManifest { # TLS alone only proves who we talked to, not that the payload is authentic. function Assert-NuGetPackageSignature { param( - [Parameter(Mandatory)][string]$NuGetExe, + [Parameter(Mandatory)][object]$Client, [Parameter(Mandatory)][string]$PackageDir ) $nupkgs = @(Get-ChildItem -LiteralPath $PackageDir -Filter '*.nupkg' -Recurse -File -ErrorAction SilentlyContinue) @@ -116,13 +121,144 @@ function Assert-NuGetPackageSignature { 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 + $output = if ($Client.Kind -eq 'dotnet') { + & $Client.Path nuget verify $pkg.FullName --all 2>&1 + } + else { + & $Client.Path verify -Signatures $pkg.FullName 2>&1 + } if ($LASTEXITCODE -ne 0) { throw "NuGet signature verification failed for $($pkg.Name): $($output -join ' ')" } } } +# Runtime identifier for the native payload: IronCompress ships a separate +# native library per RID, so the host's own RID decides which one to stage. +function Get-FinOpsNativeRid { + $isWin = if ($null -ne $IsWindows) { $IsWindows } else { $true } + $os = if ($isWin) { 'win' } elseif ($IsMacOS) { 'osx' } else { 'linux' } + $arch = try { + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + } + catch { + if ([Environment]::Is64BitOperatingSystem) { 'x64' } else { 'x86' } + } + return "$os-$arch" +} + +# Picks a package client that reads the machine's NuGet configuration, so a +# corporate feed proxy, mirror or credential provider keeps working. nuget.exe +# is a Windows binary, so on macOS and Linux it is neither downloaded nor run. +function Resolve-NuGetClient { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$CachePath) + + $isWin = if ($null -ne $IsWindows) { $IsWindows } else { $true } + + if (-not $isWin) { + $dotnet = Get-Command dotnet -CommandType Application -ErrorAction SilentlyContinue + if (-not $dotnet) { + return [PSCustomObject]@{ Kind = $null; Path = $null; Reason = 'the .NET SDK is not installed (dotnet is not on PATH)' } + } + # A runtime-only install still answers 'dotnet' but cannot restore, and + # an SDK older than the restore project's target framework fails late + # with a confusing error, so both are rejected up front. + $sdks = @(& $dotnet.Source --list-sdks 2>$null) + if ($sdks.Count -eq 0) { + return [PSCustomObject]@{ Kind = $null; Path = $null; Reason = 'only the .NET runtime is present and restoring packages needs the .NET SDK' } + } + $hasSupportedSdk = $false + foreach ($line in $sdks) { + if ([string]$line -match '^\s*(\d+)\.' -and [int]$matches[1] -ge 8) { $hasSupportedSdk = $true; break } + } + if (-not $hasSupportedSdk) { + return [PSCustomObject]@{ Kind = $null; Path = $null; Reason = 'the installed .NET SDK is older than 8.0, which the Parquet packages target' } + } + return [PSCustomObject]@{ Kind = 'dotnet'; Path = $dotnet.Source; Reason = $null } + } + + $nugetExe = Join-Path $CachePath 'nuget.exe' + if (-not (Test-Path -LiteralPath $nugetExe)) { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetExe -UseBasicParsing + } + + # Validated on every use, not just on download: a cached copy in a writable + # path can be replaced between runs. The download URL is mutable ('/latest/') + # too, so a tampered or unsigned binary is deleted and refused rather than + # executed. The subject is matched as a whole RDN so a crafted value such as + # 'O=Not Microsoft Corporation Ltd' cannot satisfy it. + $sig = Get-AuthenticodeSignature -FilePath $nugetExe + $signerSubject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' } + $signerOk = $sig.SignerCertificate -and ($signerSubject -match '(^|,\s*)O=Microsoft Corporation(\s*,|$)') + if ($sig.Status -ne 'Valid' -or -not $signerOk) { + Remove-Item $nugetExe -Force -ErrorAction SilentlyContinue + throw "nuget.exe failed Authenticode validation (status: $($sig.Status); signer: $signerSubject). Refusing to execute it." + } + + return [PSCustomObject]@{ Kind = 'nuget.exe'; Path = $nugetExe; Reason = $null } +} + +# Restores a package and its transitive dependencies with whichever client was +# resolved. Neither branch names a feed: the host's NuGet configuration decides. +function Invoke-NuGetRestore { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$Client, + [Parameter(Mandatory)][string]$PackageId, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$PackageDir, + [Parameter(Mandatory)][string]$WorkingPath + ) + + if ($Client.Kind -eq 'dotnet') { + $projDir = Join-Path $WorkingPath 'restore' + New-Item -ItemType Directory -Path $projDir -Force | Out-Null + $proj = Join-Path $projDir 'parquet-restore.csproj' + @" + + + net8.0 + false + + + + + +"@ | Set-Content -LiteralPath $proj -Encoding UTF8 + $output = & $Client.Path restore $proj --packages $PackageDir 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet restore failed for $PackageId $Version : $($output -join ' ')" + } + } + else { + $output = & $Client.Path install $PackageId -Version $Version -OutputDirectory $PackageDir -Framework net8.0 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "nuget.exe install failed for $PackageId $Version : $($output -join ' ')" + } + } +} + +# nuget.exe stages ./, dotnet restore stages //, so a +# package root is any directory that directly holds lib/ or runtimes/. +function Get-RestoredPackageRoot { + param([Parameter(Mandatory)][string]$PackageDir) + $roots = [System.Collections.Generic.List[string]]::new() + foreach ($d in @(Get-ChildItem -LiteralPath $PackageDir -Directory -ErrorAction SilentlyContinue)) { + if ((Test-Path -LiteralPath (Join-Path $d.FullName 'lib')) -or (Test-Path -LiteralPath (Join-Path $d.FullName 'runtimes'))) { + [void]$roots.Add($d.FullName) + continue + } + foreach ($v in @(Get-ChildItem -LiteralPath $d.FullName -Directory -ErrorAction SilentlyContinue)) { + if ((Test-Path -LiteralPath (Join-Path $v.FullName 'lib')) -or (Test-Path -LiteralPath (Join-Path $v.FullName 'runtimes'))) { + [void]$roots.Add($v.FullName) + } + } + } + return @($roots) +} + function Install-ParquetReader { [CmdletBinding()] param() @@ -154,71 +290,72 @@ function Install-ParquetReader { } } - Write-Host " Installing Parquet reader (one-time setup)..." -ForegroundColor DarkGray - + $script:FinOpsParquetUnavailableReason = $null + $client = $null try { New-Item -ItemType Directory -Path $parquetDir -Force | Out-Null + $client = Resolve-NuGetClient -CachePath $parquetDir + } + catch { + $script:FinOpsParquetUnavailableReason = $_.Exception.Message + Write-Warning "Failed to prepare the Parquet reader: $($_.Exception.Message)" + return $false + } - # Download nuget.exe if needed - $nugetExe = Join-Path $parquetDir 'nuget.exe' - if (-not (Test-Path $nugetExe)) { - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 - Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetExe -UseBasicParsing - - # Verify the downloaded nuget.exe is Authenticode-signed by Microsoft - # and the signature is Valid BEFORE executing it. The download URL is - # mutable ('/latest/'), so a tampered or unsigned binary (hijacked - # endpoint, MITM past TLS, cache poisoning) is deleted and refused - # rather than run. On non-Windows, Authenticode is not applicable and - # nuget.exe is not executed, so the check is skipped. - $isWin = if ($null -ne $IsWindows) { $IsWindows } else { $true } - if ($isWin) { - $sig = Get-AuthenticodeSignature -FilePath $nugetExe - $signerSubject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' } - $signerOk = $sig.SignerCertificate -and ($signerSubject -match 'O=Microsoft Corporation') - if ($sig.Status -ne 'Valid' -or -not $signerOk) { - Remove-Item $nugetExe -Force -ErrorAction SilentlyContinue - throw "Downloaded nuget.exe failed Authenticode validation (status: $($sig.Status); signer: $signerSubject). Refusing to execute it." - } - } - } + # No usable client is a reportable outcome, not a silent downgrade: the + # caller states the reason before it changes where the numbers come from. + if (-not $client.Kind) { + $script:FinOpsParquetUnavailableReason = $client.Reason + return $false + } + + Write-Host " Installing Parquet reader (one-time setup)..." -ForegroundColor DarkGray - # Use nuget.exe to resolve ALL transitive dependencies + try { $pkgDir = Join-Path $parquetDir 'packages' - & $nugetExe install Parquet.Net -Version 4.24.0 -OutputDirectory $pkgDir -Framework net8.0 2>&1 | Out-Null + Invoke-NuGetRestore -Client $client -PackageId 'Parquet.Net' -Version '4.24.0' -PackageDir $pkgDir -WorkingPath $parquetDir # 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 + Assert-NuGetPackageSignature -Client $client -PackageDir $pkgDir # Copy managed DLLs to flat directory (prefer net8.0 > net6.0 > netstandard2.0) $libDir = Join-Path $parquetDir 'lib' New-Item -ItemType Directory -Path $libDir -Force | Out-Null $fxPriority = @('net8.0', 'net6.0', 'netstandard2.1', 'netstandard2.0') - $packages = Get-ChildItem $pkgDir -Directory - foreach ($pkg in $packages) { - $libRoot = Join-Path $pkg.FullName 'lib' - if (-not (Test-Path $libRoot)) { continue } - $copied = $false - foreach ($fx in $fxPriority) { - $fxDir = Join-Path $libRoot $fx - if (Test-Path $fxDir) { - Get-ChildItem $fxDir -Filter '*.dll' | ForEach-Object { - Copy-Item $_.FullName $libDir -Force + $rid = Get-FinOpsNativeRid + $ridCandidates = @($rid, "$($rid.Split('-')[0])-x64") | Select-Object -Unique + # Matches what the integrity manifest hashes, including versioned names + # such as libfoo.so.1 - a plain *.so filter would skip those and stage + # an incomplete set. + $nativePattern = if ($rid.StartsWith('win')) { '\.dll$' } elseif ($rid.StartsWith('osx')) { '\.dylib(\.\d+)*$' } else { '\.so(\.\d+)*$' } + + foreach ($pkgRoot in (Get-RestoredPackageRoot -PackageDir $pkgDir)) { + $libRoot = Join-Path $pkgRoot 'lib' + if (Test-Path -LiteralPath $libRoot) { + foreach ($fx in $fxPriority) { + $fxDir = Join-Path $libRoot $fx + if (Test-Path -LiteralPath $fxDir) { + Get-ChildItem -LiteralPath $fxDir -Filter '*.dll' -File | ForEach-Object { + Copy-Item $_.FullName $libDir -Force + } + break } - $copied = $true - break } } - # Copy native runtimes (IronCompress needs nironcompress.dll) - $nativeDir = Join-Path $pkg.FullName 'runtimes\win-x64\native' - if (Test-Path $nativeDir) { - $targetNative = Join-Path $parquetDir 'runtimes\win-x64\native' + + # IronCompress ships one native library per RID; stage the one this + # host can actually load rather than assuming win-x64. + foreach ($candidate in $ridCandidates) { + $nativeDir = Join-Path (Join-Path (Join-Path $pkgRoot 'runtimes') $candidate) 'native' + if (-not (Test-Path -LiteralPath $nativeDir)) { continue } + $targetNative = Join-Path (Join-Path (Join-Path $parquetDir 'runtimes') $candidate) 'native' New-Item -ItemType Directory -Path $targetNative -Force | Out-Null - Get-ChildItem $nativeDir -Filter '*.dll' | ForEach-Object { + Get-ChildItem -LiteralPath $nativeDir -File | Where-Object { $_.Name -match $nativePattern } | ForEach-Object { Copy-Item $_.FullName $targetNative -Force } + break } } @@ -231,6 +368,7 @@ function Install-ParquetReader { return $true } catch { + $script:FinOpsParquetUnavailableReason = $_.Exception.Message Write-Warning "Failed to install Parquet reader: $($_.Exception.Message)" return $false } @@ -508,7 +646,10 @@ function Read-FinOpsHubData { if ($allData.Count -eq 0) { $hasParquet = Install-ParquetReader if (-not $hasParquet) { - Write-Warning "Parquet reader failed — falling back to CSV exports" + # Name the cause and the change of source: a bare + # "falling back" line reads like a clean scan. + $why = if ($script:FinOpsParquetUnavailableReason) { $script:FinOpsParquetUnavailableReason } else { 'the Parquet reader could not be installed' } + Write-Warning "Reading Hub CSV exports instead of Parquet because $why. Figures come from msexports rather than normalized ingestion." break } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 index 182892a17..07a8a0ce5 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 @@ -11,6 +11,70 @@ function ConvertTo-KqlLiteral { } function Search-AzGraphSafe { + param( + [Parameter(Mandatory)][string]$Query, + [string[]]$Subscription, + [int]$First = 1000, + [string]$SkipToken, + [int]$TimeoutSeconds = 60, + [int]$MaxRetries = 2, + [switch]$All, + [int]$MaxPages = 100 + ) + + if (-not $All) { + return Invoke-AzGraphQueryPage -Query $Query -Subscription $Subscription -First $First ` + -SkipToken $SkipToken -TimeoutSeconds $TimeoutSeconds -MaxRetries $MaxRetries + } + + # Resource Graph caps a response at $First rows and hands back a continuation + # token. A caller that totals rows without following that token reports a + # truncated set as a complete one, which reads as "fewer resources" rather + # than as an error. -All follows the token and returns every row. + $rows = [System.Collections.Generic.List[object]]::new() + $token = $SkipToken + $pageCount = 0 + + do { + $usedToken = $token + $page = Invoke-AzGraphQueryPage -Query $Query -Subscription $Subscription -First $First ` + -SkipToken $usedToken -TimeoutSeconds $TimeoutSeconds -MaxRetries $MaxRetries + + if (-not $page) { + # A first-page failure keeps the existing contract: callers treat + # $null as "the query failed". Losing a later page is different - + # we have partial data, so say so rather than total it silently. + if ($pageCount -eq 0) { return $null } + Write-Warning " Resource Graph continuation failed after $pageCount page(s); results are incomplete." + break + } + + if ($page.Data) { $rows.AddRange(@($page.Data)) } + $token = $page.SkipToken + $pageCount++ + + # A token that does not advance would re-request the page we just added. + if ($token -and $usedToken -and $token -eq $usedToken) { + Write-Warning " Resource Graph returned the same continuation token twice; stopping rather than repeating a page. Results are incomplete." + break + } + + if ($token -and $pageCount -ge $MaxPages) { + Write-Warning " Resource Graph query stopped after $MaxPages pages ($($rows.Count) rows); results are incomplete." + break + } + } while ($token) + + return [PSCustomObject]@{ + Data = @($rows) + SkipToken = $null + Count = $rows.Count + } +} + +# A single Resource Graph request with retry and backoff. Kept separate from the +# paging loop so that loop can be exercised without issuing live queries. +function Invoke-AzGraphQueryPage { param( [Parameter(Mandatory)][string]$Query, [string[]]$Subscription, @@ -19,6 +83,7 @@ function Search-AzGraphSafe { [int]$TimeoutSeconds = 60, [int]$MaxRetries = 2 ) + for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) { $ps = [powershell]::Create() $ps.RunspacePool = $script:RunspacePool diff --git a/src/powershell/Tests/Unit/AzGraphPagination.Tests.ps1 b/src/powershell/Tests/Unit/AzGraphPagination.Tests.ps1 new file mode 100644 index 000000000..00ee52af9 --- /dev/null +++ b/src/powershell/Tests/Unit/AzGraphPagination.Tests.ps1 @@ -0,0 +1,107 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Resource Graph query pagination' { + + # 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 + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + It 'Combines rows from every page when -All is used' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + if (-not $SkipToken) { [PSCustomObject]@{ Data = @('a', 'b'); SkipToken = 'page2'; Count = 2 } } + elseif ($SkipToken -eq 'page2') { [PSCustomObject]@{ Data = @('c'); SkipToken = $null; Count = 1 } } + } + + $result = Search-AzGraphSafe -Query 'resources' -All + + $result.Count | Should -Be 3 + @($result.Data) | Should -Be @('a', 'b', 'c') + $result.SkipToken | Should -BeNullOrEmpty + Should -Invoke Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool -Times 2 -Exactly + } + + It 'Reads only the first page when -All is not used' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + [PSCustomObject]@{ Data = @('a', 'b'); SkipToken = 'page2'; Count = 2 } + } + + $result = Search-AzGraphSafe -Query 'resources' + + @($result.Data).Count | Should -Be 2 + $result.SkipToken | Should -Be 'page2' + Should -Invoke Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool -Times 1 -Exactly + } + + It 'Returns null when the very first page fails' { + # Callers treat $null as "the query failed", so that contract must survive. + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { $null } + + $result = Search-AzGraphSafe -Query 'resources' -All + + $result | Should -BeNullOrEmpty + } + + It 'Warns and keeps partial rows when a later page fails' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + if (-not $SkipToken) { [PSCustomObject]@{ Data = @('a'); SkipToken = 'page2'; Count = 1 } } + else { $null } + } + + $warnings = @() + $result = Search-AzGraphSafe -Query 'resources' -All -WarningVariable warnings -WarningAction SilentlyContinue + + @($result.Data).Count | Should -Be 1 + "$warnings" | Should -Match 'incomplete' + } + + It 'Stops at MaxPages rather than following an endless token chain' { + # Each page hands back a token that differs from the one just used, so + # the cap - not the same-token guard - is what ends the loop. + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + $next = if ($SkipToken) { "$SkipToken+" } else { 'page+' } + [PSCustomObject]@{ Data = @('row'); SkipToken = $next; Count = 1 } + } + + $warnings = @() + $result = Search-AzGraphSafe -Query 'resources' -All -MaxPages 3 -WarningVariable warnings -WarningAction SilentlyContinue + + @($result.Data).Count | Should -Be 3 + Should -Invoke Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool -Times 3 -Exactly + "$warnings" | Should -Match 'incomplete' + } + + It 'Stops when the continuation token does not advance' { + # A token that repeats would re-request the page already collected and + # silently duplicate its rows. + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + [PSCustomObject]@{ Data = @('row'); SkipToken = 'stuck'; Count = 1 } + } + + $warnings = @() + $result = Search-AzGraphSafe -Query 'resources' -All -WarningVariable warnings -WarningAction SilentlyContinue + + @($result.Data).Count | Should -Be 2 + Should -Invoke Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool -Times 2 -Exactly + "$warnings" | Should -Match 'same continuation token' + } + + It 'Starts from a caller-supplied skip token' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + [PSCustomObject]@{ Data = @($SkipToken); SkipToken = $null; Count = 1 } + } + + $result = Search-AzGraphSafe -Query 'resources' -All -SkipToken 'resume-here' + + @($result.Data)[0] | Should -Be 'resume-here' + } +} diff --git a/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 b/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 new file mode 100644 index 000000000..9b73270cd --- /dev/null +++ b/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Commitment utilization de-duplication' { + + # 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 + + $script:TwoSubs = @( + [PSCustomObject]@{ Id = '00000000-0000-0000-0000-000000000001'; Name = 'Sub one' } + [PSCustomObject]@{ Id = '00000000-0000-0000-0000-000000000002'; Name = 'Sub two' } + ) + + # One reservation, two usage periods. Both subscriptions consumed it, so + # both report the identical pair of records. + $script:ReservationPayload = @{ + value = @( + @{ properties = @{ reservationOrderId = 'order-1'; reservationId = 'res-1'; skuName = 'Standard_D2s_v5'; kind = 'Compute' + avgUtilizationPercentage = 50; minUtilizationPercentage = 40; maxUtilizationPercentage = 60 + reservedHours = 100; usedHours = 50; usageDate = '2026-08-01T00:00:00Z' + } + } + @{ properties = @{ reservationOrderId = 'order-1'; reservationId = 'res-1'; skuName = 'Standard_D2s_v5'; kind = 'Compute' + avgUtilizationPercentage = 90; minUtilizationPercentage = 80; maxUtilizationPercentage = 95 + reservedHours = 100; usedHours = 90; usageDate = '2026-09-01T00:00:00Z' + } + } + ) + } | ConvertTo-Json -Depth 8 + + # Two DIFFERENT savings plans that share one benefit order. + $script:SavingsPlanPayload = @{ + value = @( + @{ properties = @{ benefitType = 'SavingsPlan'; benefitId = 'plan-a'; benefitOrderId = 'order-1' + avgUtilizationPercentage = 70; usageDate = '2026-09-01T00:00:00Z' + } + } + @{ properties = @{ benefitType = 'SavingsPlan'; benefitId = 'plan-b'; benefitOrderId = 'order-1' + avgUtilizationPercentage = 30; usageDate = '2026-09-01T00:00:00Z' + } + } + ) + } | ConvertTo-Json -Depth 8 + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + It 'Counts one reservation when every subscription reports it for several months' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match 'reservationSummaries') { + [PSCustomObject]@{ StatusCode = 200; Content = $script:ReservationPayload } + } + else { + [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } + } + } + + $result = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + # 2 subscriptions x 2 months = 4 API records for a single commitment. + $result.RICount | Should -Be 1 + # The newest period wins, so the average is not dragged down by August. + $result.RIAvgUtilization | Should -Be 90 + } + + It 'Keeps distinct savings plans that share one benefit order' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match 'benefitUtilizationSummaries') { + [PSCustomObject]@{ StatusCode = 200; Content = $script:SavingsPlanPayload } + } + else { + [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } + } + } + + $result = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + # De-duplicating on benefitOrderId alone would collapse these to 1. + $result.SPCount | Should -Be 2 + $result.SPAvgUtilization | Should -Be 50 + } + + Context 'Usage date comparison' { + + It 'Treats a newer ISO date as newer' { + Test-UsageDateIsNewer -Candidate '2026-09-01T00:00:00Z' -Existing '2026-08-01T00:00:00Z' | Should -BeTrue + } + + It 'Treats an older ISO date as not newer' { + Test-UsageDateIsNewer -Candidate '2026-07-01T00:00:00Z' -Existing '2026-08-01T00:00:00Z' | Should -BeFalse + } + + It 'Accepts any candidate when nothing was recorded yet' { + Test-UsageDateIsNewer -Candidate '2026-07-01T00:00:00Z' -Existing $null | Should -BeTrue + } + + It 'Keeps the recorded row when the candidate has no date' { + Test-UsageDateIsNewer -Candidate $null -Existing '2026-08-01T00:00:00Z' | Should -BeFalse + } + } +} diff --git a/src/powershell/Tests/Unit/ParquetPackageClient.Tests.ps1 b/src/powershell/Tests/Unit/ParquetPackageClient.Tests.ps1 new file mode 100644 index 000000000..6627f03cf --- /dev/null +++ b/src/powershell/Tests/Unit/ParquetPackageClient.Tests.ps1 @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Parquet package acquisition' { + + # 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 + + # A script block rather than a New-* function: the analyzer would demand + # ShouldProcess support on a state-changing verb for a test fixture. + $script:NewTempTree = { + $root = Join-Path ([System.IO.Path]::GetTempPath()) "ftk-pkg-$([guid]::NewGuid().ToString('N').Substring(0,8))" + New-Item -ItemType Directory -Path $root -Force | Out-Null + return $root + } + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Package root discovery' { + + It 'Finds the nuget.exe layout of id.version then lib' { + $root = & $script:NewTempTree + try { + New-Item -ItemType Directory -Path (Join-Path $root 'Parquet.Net.4.24.0\lib\net8.0') -Force | Out-Null + + $found = @(Get-RestoredPackageRoot -PackageDir $root) + + $found.Count | Should -Be 1 + $found[0] | Should -BeLike '*Parquet.Net.4.24.0' + } + finally { Remove-Item $root -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'Finds the dotnet restore layout of id then version then lib' { + # dotnet nests the version a level deeper than nuget.exe does; missing + # this layout would stage zero assemblies on macOS and Linux. + $root = & $script:NewTempTree + try { + New-Item -ItemType Directory -Path (Join-Path $root 'parquet.net\4.24.0\lib\net8.0') -Force | Out-Null + + $found = @(Get-RestoredPackageRoot -PackageDir $root) + + $found.Count | Should -Be 1 + $found[0] | Should -BeLike '*4.24.0' + } + finally { Remove-Item $root -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'Finds a package that ships only native runtimes' { + $root = & $script:NewTempTree + try { + New-Item -ItemType Directory -Path (Join-Path $root 'ironcompress\1.5.2\runtimes\linux-x64\native') -Force | Out-Null + + $found = @(Get-RestoredPackageRoot -PackageDir $root) + + $found.Count | Should -Be 1 + } + finally { Remove-Item $root -Recurse -Force -ErrorAction SilentlyContinue } + } + + It 'Ignores directories that hold neither lib nor runtimes' { + $root = & $script:NewTempTree + try { + New-Item -ItemType Directory -Path (Join-Path $root 'some.tool\1.0.0\tools') -Force | Out-Null + + @(Get-RestoredPackageRoot -PackageDir $root).Count | Should -Be 0 + } + finally { Remove-Item $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Context 'Payload hashing covers native libraries' { + + It 'Includes .so and .dylib alongside .dll' { + # The integrity manifest must cover native payloads too; a .dll-only + # filter left the Linux and macOS native libraries unverified. + $root = & $script:NewTempTree + try { + $lib = Join-Path $root 'lib' + $native = Join-Path $root 'runtimes\linux-x64\native' + New-Item -ItemType Directory -Path $lib -Force | Out-Null + New-Item -ItemType Directory -Path $native -Force | Out-Null + Set-Content -LiteralPath (Join-Path $lib 'Parquet.dll') -Value 'x' + Set-Content -LiteralPath (Join-Path $native 'libironcompress.so') -Value 'x' + Set-Content -LiteralPath (Join-Path $native 'libironcompress.dylib') -Value 'x' + Set-Content -LiteralPath (Join-Path $native 'notes.txt') -Value 'x' + + $names = @(Get-ParquetPayloadFile -BasePath $root | ForEach-Object { $_.Name }) + + $names | Should -Contain 'Parquet.dll' + $names | Should -Contain 'libironcompress.so' + $names | Should -Contain 'libironcompress.dylib' + $names | Should -Not -Contain 'notes.txt' + } + finally { Remove-Item $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } + + Context 'Client resolution' { + + It 'Reports a runtime identifier shaped os-arch' { + Get-FinOpsNativeRid | Should -Match '^(win|osx|linux)-(x64|x86|arm64|arm)$' + } + + It 'Rejects and deletes a cached nuget.exe that is not validly signed' -Skip:(-not $IsWindows) { + # A cached copy lives in a writable path, so it is re-validated on + # every use rather than trusted because it already exists. + $root = & $script:NewTempTree + try { + $planted = Join-Path $root 'nuget.exe' + Set-Content -LiteralPath $planted -Value 'not a signed binary' + + { Resolve-NuGetClient -CachePath $root } | Should -Throw '*Authenticode*' + Test-Path -LiteralPath $planted | Should -BeFalse + } + finally { Remove-Item $root -Recurse -Force -ErrorAction SilentlyContinue } + } + } +} From 435e895ad1630954092e507a150e7acf6ba25e3e Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 14 Sep 2026 18:02:55 -0600 Subject: [PATCH 118/142] FinOps Multitool Update - Sep 14 Review findings - Moved the 13 agent skill links into agent-plugin/skills so they ship with the plugin that is already built, manifested and tested - Deleted src/templates/claude-plugin, which had no build config and was not referenced anywhere in the repo - Build-AgentPlugin.ps1 now materializes a linked skill into a real directory, matching how queries and docs-mslearn are already handled - Added a test covering skill materialization --- .../Tests/Unit/AgentPlugins.Tests.ps1 | 16 ++++++++++++ src/scripts/Build-AgentPlugin.ps1 | 26 +++++++++++++++++++ .../skills/anomaly-investigation | 0 .../skills/azure-policy-governance | 0 .../skills/azure-workbooks-finops | 0 .../skills/cost-allocation | 0 .../skills/cost-data-source | 0 .../skills/finops-multitool | 0 .../skills/finops-reporting | 0 .../skills/focus-data-quality | 0 .../skills/forecasting-budgeting | 0 .../skills/power-bi-finops | 0 .../skills/rate-optimization-portfolio | 0 .../skills/sustainability-carbon | 0 .../skills/unit-economics | 0 15 files changed, 42 insertions(+) rename src/templates/{claude-plugin => agent-plugin}/skills/anomaly-investigation (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/azure-policy-governance (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/azure-workbooks-finops (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/cost-allocation (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/cost-data-source (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/finops-multitool (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/finops-reporting (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/focus-data-quality (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/forecasting-budgeting (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/power-bi-finops (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/rate-optimization-portfolio (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/sustainability-carbon (100%) rename src/templates/{claude-plugin => agent-plugin}/skills/unit-economics (100%) diff --git a/src/powershell/Tests/Unit/AgentPlugins.Tests.ps1 b/src/powershell/Tests/Unit/AgentPlugins.Tests.ps1 index ccb2a66d4..b27462003 100644 --- a/src/powershell/Tests/Unit/AgentPlugins.Tests.ps1 +++ b/src/powershell/Tests/Unit/AgentPlugins.Tests.ps1 @@ -137,6 +137,22 @@ Describe 'Agent plugin components' { $docs.Count | Should -BeGreaterThan 0 $docs.Extension | Select-Object -Unique | Should -Be @('.md') } + + It 'Materializes linked agent skills into real directories' { + $bundle = Join-Path $TestDrive 'agent-plugin-skills' + $skills = Join-Path $bundle 'skills' + New-Item $skills -ItemType Directory -Force | Out-Null + + # Mirrors how git checks the link out where core.symlinks is disabled: + # a plain file holding the relative target rather than a directory. + Set-Content -LiteralPath (Join-Path $skills 'cost-allocation') -Value '../../agent-skills/cost-allocation' -NoNewline + + & (Join-Path $script:RepoRoot 'src/scripts/Build-AgentPlugin.ps1') -DestDir $bundle + + $materialized = Join-Path $skills 'cost-allocation' + (Get-Item $materialized).PSIsContainer | Should -BeTrue + Join-Path $materialized 'SKILL.md' | Should -Exist + } } Describe 'Deprecated azure-cost-management skill' { diff --git a/src/scripts/Build-AgentPlugin.ps1 b/src/scripts/Build-AgentPlugin.ps1 index 5989d18b9..11619ad38 100644 --- a/src/scripts/Build-AgentPlugin.ps1 +++ b/src/scripts/Build-AgentPlugin.ps1 @@ -52,4 +52,30 @@ if (Test-Path $finopsSkill) } } +# Agent skills are linked into the plugin so the sources stay single-copy. Git +# writes those links as plain files wherever core.symlinks is disabled, so the +# build replaces whichever placeholder is present with the real directory. +$agentSkills = Join-Path $repoRoot 'src/templates/agent-skills' +if (Test-Path $agentSkills) +{ + foreach ($entry in (Get-ChildItem $skillsDir -Force)) + { + $isLink = [bool]($entry.Attributes -band [IO.FileAttributes]::ReparsePoint) + if ($entry.PSIsContainer -and -not $isLink) + { + continue + } + + $source = Join-Path $agentSkills $entry.Name + if (-not (Test-Path $source)) + { + continue + } + + $dest = Join-Path $skillsDir $entry.Name + Remove-PluginBundle $dest + Copy-Item $source -Destination $dest -Recurse -Force + } +} + Get-ChildItem $DestDir -Force -Recurse -Filter '.DS_Store' | Remove-Item -Force diff --git a/src/templates/claude-plugin/skills/anomaly-investigation b/src/templates/agent-plugin/skills/anomaly-investigation similarity index 100% rename from src/templates/claude-plugin/skills/anomaly-investigation rename to src/templates/agent-plugin/skills/anomaly-investigation diff --git a/src/templates/claude-plugin/skills/azure-policy-governance b/src/templates/agent-plugin/skills/azure-policy-governance similarity index 100% rename from src/templates/claude-plugin/skills/azure-policy-governance rename to src/templates/agent-plugin/skills/azure-policy-governance diff --git a/src/templates/claude-plugin/skills/azure-workbooks-finops b/src/templates/agent-plugin/skills/azure-workbooks-finops similarity index 100% rename from src/templates/claude-plugin/skills/azure-workbooks-finops rename to src/templates/agent-plugin/skills/azure-workbooks-finops diff --git a/src/templates/claude-plugin/skills/cost-allocation b/src/templates/agent-plugin/skills/cost-allocation similarity index 100% rename from src/templates/claude-plugin/skills/cost-allocation rename to src/templates/agent-plugin/skills/cost-allocation diff --git a/src/templates/claude-plugin/skills/cost-data-source b/src/templates/agent-plugin/skills/cost-data-source similarity index 100% rename from src/templates/claude-plugin/skills/cost-data-source rename to src/templates/agent-plugin/skills/cost-data-source diff --git a/src/templates/claude-plugin/skills/finops-multitool b/src/templates/agent-plugin/skills/finops-multitool similarity index 100% rename from src/templates/claude-plugin/skills/finops-multitool rename to src/templates/agent-plugin/skills/finops-multitool diff --git a/src/templates/claude-plugin/skills/finops-reporting b/src/templates/agent-plugin/skills/finops-reporting similarity index 100% rename from src/templates/claude-plugin/skills/finops-reporting rename to src/templates/agent-plugin/skills/finops-reporting diff --git a/src/templates/claude-plugin/skills/focus-data-quality b/src/templates/agent-plugin/skills/focus-data-quality similarity index 100% rename from src/templates/claude-plugin/skills/focus-data-quality rename to src/templates/agent-plugin/skills/focus-data-quality diff --git a/src/templates/claude-plugin/skills/forecasting-budgeting b/src/templates/agent-plugin/skills/forecasting-budgeting similarity index 100% rename from src/templates/claude-plugin/skills/forecasting-budgeting rename to src/templates/agent-plugin/skills/forecasting-budgeting diff --git a/src/templates/claude-plugin/skills/power-bi-finops b/src/templates/agent-plugin/skills/power-bi-finops similarity index 100% rename from src/templates/claude-plugin/skills/power-bi-finops rename to src/templates/agent-plugin/skills/power-bi-finops diff --git a/src/templates/claude-plugin/skills/rate-optimization-portfolio b/src/templates/agent-plugin/skills/rate-optimization-portfolio similarity index 100% rename from src/templates/claude-plugin/skills/rate-optimization-portfolio rename to src/templates/agent-plugin/skills/rate-optimization-portfolio diff --git a/src/templates/claude-plugin/skills/sustainability-carbon b/src/templates/agent-plugin/skills/sustainability-carbon similarity index 100% rename from src/templates/claude-plugin/skills/sustainability-carbon rename to src/templates/agent-plugin/skills/sustainability-carbon diff --git a/src/templates/claude-plugin/skills/unit-economics b/src/templates/agent-plugin/skills/unit-economics similarity index 100% rename from src/templates/claude-plugin/skills/unit-economics rename to src/templates/agent-plugin/skills/unit-economics From 89fb6348ff52e67d8e84ec6a4edcca0c57fc7143 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 14 Sep 2026 18:31:06 -0600 Subject: [PATCH 119/142] FinOps Multitool Update - Sep 14 Review findings - Commitment utilization queried subscription scope, which returns 404 for both the reservation and benefit APIs, so it always reported zero. Both now query the billing account or billing profile that owns the scanned subscriptions. - Corrected the reservationSummaries filter: UsageDate is a DateTimeOffset and requires both bounds unquoted. - benefitUtilizationSummaries now uses the documented grainParameter. - Both APIs return nextLink at the root, so Get-CostQueryResponsePage gained a RootNextLink switch and both queries are now paged. - Counts and averages are now per commitment rather than per monthly usage record. - A zero caused by no resolvable billing scope is now reported separately from an access denial. --- .../modules/Get-CommitmentUtilization.ps1 | 318 +++++++----------- .../helpers/Get-CostQueryResponsePage.ps1 | 12 +- .../CommitmentUtilizationDedupe.Tests.ps1 | 67 +++- .../Tests/Unit/CostQueryPagination.Tests.ps1 | 27 ++ 4 files changed, 215 insertions(+), 209 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 index 0f28897e4..e23d2899d 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 @@ -42,143 +42,112 @@ function Get-CommitmentUtilization { $reservations = @() $utilFailures = [System.Collections.Generic.List[string]]::new() $savingsPlans = @() - $subIds = $Subscriptions | ForEach-Object { $_.Id } # Set to $true if a reservation/savings-plan query is forbidden (401/403) # rather than simply returning no commitments. $accessDenied = $false - # -- Step 0 (MCA/MPA): Resolve billing profiles for this tenant ----- - # Under MCA, reservations and savings plans are scoped to the billing - # profile, NOT the subscription. Subscription-level Consumption API - # calls return empty for MCA agreements. - $billingProfileIds = @() - if ($AgreementType -in @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { - Write-Host " MCA/MPA detected — resolving billing profiles..." -ForegroundColor Cyan - # Get billing profiles for the accounts that own a scanned subscription. - # The previous subscription-scoped lookup used billingInfo/default, which is - # not a valid resource type, so the account set was always empty and the - # filter below was skipped entirely - every reachable MCA account was used. - try { - $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" - $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET - if ($baResp.StatusCode -eq 200) { - $baResult = ($baResp.Content | ConvertFrom-Json) - $mcaAccounts = @($baResult.value | Where-Object { $_.properties.agreementType -in @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement') }) - $scope = Get-FinOpsBillingScope -BillingAccounts $mcaAccounts -Subscriptions $Subscriptions - if (-not $scope.Resolved) { Write-Warning " $($scope.Reason)" } - foreach ($ba in @($scope.Accounts)) { + # Why billing-scope correlation produced nothing, when it produced nothing. + $scopeResolutionReason = $null + + # -- Step 0: Resolve the billing scopes that own the scanned subscriptions -- + # Both commitment APIs are billing-scoped. A subscription-scoped path answers + # 404 "Unknown. Please check the request path", which is not an access denial, + # so the previous per-subscription queries could only ever report zero. + # EA reads at billing account scope, MCA/MPA at billing profile scope. + $commitmentScopes = @() + try { + $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" + $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET + if ($baResp.StatusCode -eq 200) { + $allAccounts = @(($baResp.Content | ConvertFrom-Json).value) + $scope = Get-FinOpsBillingScope -BillingAccounts $allAccounts -Subscriptions $Subscriptions + if (-not $scope.Resolved) { + $scopeResolutionReason = $scope.Reason + Write-Warning " $($scope.Reason)" + } + + foreach ($ba in @($scope.Accounts)) { + # Fall back to the caller's agreement type when the account + # listing does not carry one. + $agreement = if ($ba.properties.agreementType) { $ba.properties.agreementType } else { $AgreementType } + if ($agreement -in @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { try { - $bpPath = "$($ba.id)/billingProfiles?api-version=2024-04-01" - $bpResp = Invoke-AzRestMethodWithRetry -Path $bpPath -Method GET + $bpResp = Invoke-AzRestMethodWithRetry -Path "$($ba.id)/billingProfiles?api-version=2024-04-01" -Method GET if ($bpResp.StatusCode -eq 200) { - $bpResult = ($bpResp.Content | ConvertFrom-Json) - foreach ($bp in $bpResult.value) { $billingProfileIds += $bp.id } + foreach ($bp in @(($bpResp.Content | ConvertFrom-Json).value)) { $commitmentScopes += $bp.id } } + elseif ($bpResp.StatusCode -in @(401, 403)) { $accessDenied = $true } } catch { - Write-Verbose "Billing profile lookup failed: $($_.Exception.Message)" + Write-Warning " Billing profile lookup failed for $($ba.name): $($_.Exception.Message)" } } + else { + $commitmentScopes += $ba.id + } } - } catch { - Write-Warning " Billing profile resolution failed: $($_.Exception.Message)" } - Write-Host " Found $($billingProfileIds.Count) billing profile(s) for MCA commitment queries." -ForegroundColor Cyan + elseif ($baResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } catch { + Write-Warning " Billing scope resolution failed: $($_.Exception.Message)" } + Write-Host " Found $($commitmentScopes.Count) billing scope(s) for commitment queries." -ForegroundColor Cyan # -- Step 1: Get all reservations and their utilization -------------- - # For MCA: query at billing-profile scope first - if ($billingProfileIds.Count -gt 0) { - foreach ($bpId in $billingProfileIds) { - try { - $summaryPath = "$bpId/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'" - $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET - if ($resp.StatusCode -eq 200) { - $data = ($resp.Content | ConvertFrom-Json) - if ($data.value) { - foreach ($item in $data.value) { - $p = $item.properties - $reservations += [PSCustomObject]@{ - ReservationOrderId = $p.reservationOrderId - ReservationId = $p.reservationId - SkuName = $p.skuName - Kind = $p.kind - AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) - MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1) - MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1) - ReservedHours = $p.reservedHours - UsedHours = $p.usedHours - UsageDate = $p.usageDate - } - } - } - } - elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } - } catch { - if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } - Write-Warning " Reservation query at billing profile scope failed: $($_.Exception.Message)" - } + # One row per reservation, not per usage period. The API returns a record + # per month, so counting records would inflate RICount and weight the + # average towards whichever reservation happens to span more months. + $latestReservation = @{} + $usageFrom = ((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd') + $usageTo = (Get-Date).ToString('yyyy-MM-dd') + $scopeTotal = @($commitmentScopes).Count + $scopeIdx = 0 + foreach ($scopeId in $commitmentScopes) { + $scopeIdx++ + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying reservations ($scopeIdx/$scopeTotal scopes)..." } - } + try { + # UsageDate needs both bounds and must not be quoted: it is an + # Edm.DateTimeOffset, so a quoted value fails the type comparison. + $summaryPath = "$scopeId/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/UsageDate ge $usageFrom and properties/UsageDate le $usageTo" + $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET - # For EA / fallback: query at subscription scope - if ($reservations.Count -eq 0) { - # Every selected subscription is queried. Stopping at the first one that - # answers, or at an arbitrary first N, hides reservations that only the - # remaining subscriptions can see. - # - # Two different duplications have to be collapsed before the summary - # stats are computed. A reservation is reported by every subscription - # that consumed it, and reservationSummaries returns one record per - # usage period. Keying on the reservation alone - and keeping only its - # newest period - leaves exactly one row per commitment, so RICount - # counts reservations rather than API records and the average is not - # weighted towards whichever reservation happens to span more months. - $latestReservation = @{} - $subTotal = @($Subscriptions).Count - $subIdx = 0 - foreach ($sub in $Subscriptions) { - $subIdx++ - if ($subIdx -eq 1 -or $subIdx -eq $subTotal -or ($subTotal -gt 5 -and $subIdx % [math]::Max(1, [int]($subTotal / 10)) -eq 0)) { - if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { - Update-ScanStatus "Querying reservations ($subIdx/$subTotal subs)..." - } - } - try { - $summaryPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'" - $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET - if ($resp.StatusCode -eq 200) { - $data = ($resp.Content | ConvertFrom-Json) - if ($data.value) { - foreach ($item in $data.value) { - $p = $item.properties - $key = "$($p.reservationOrderId)/$($p.reservationId)" - if ([string]::IsNullOrWhiteSpace(($key -replace '/', ''))) { continue } - $existing = $latestReservation[$key] - if ($existing -and -not (Test-UsageDateIsNewer -Candidate $p.usageDate -Existing $existing.UsageDate)) { continue } - $latestReservation[$key] = [PSCustomObject]@{ - ReservationOrderId = $p.reservationOrderId - ReservationId = $p.reservationId - SkuName = $p.skuName - Kind = $p.kind - AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) - MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1) - MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1) - ReservedHours = $p.reservedHours - UsedHours = $p.usedHours - UsageDate = $p.usageDate - } + if ($resp.StatusCode -eq 200) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Context 'reservation utilization' -RootNextLink)) { + foreach ($item in @(($page.Content | ConvertFrom-Json).value)) { + $p = $item.properties + $key = "$($p.reservationOrderId)/$($p.reservationId)" + if ([string]::IsNullOrWhiteSpace(($key -replace '/', ''))) { continue } + $existing = $latestReservation[$key] + if ($existing -and -not (Test-UsageDateIsNewer -Candidate $p.usageDate -Existing $existing.UsageDate)) { continue } + $latestReservation[$key] = [PSCustomObject]@{ + ReservationOrderId = $p.reservationOrderId + ReservationId = $p.reservationId + SkuName = $p.skuName + Kind = $p.kind + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1) + MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1) + ReservedHours = $p.reservedHours + UsedHours = $p.usedHours + UsageDate = $p.usageDate } } } - elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } - } catch { - if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } - Write-Warning " Reservation summaries query failed for $($sub.Name): $($_.Exception.Message)" } + elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } + else { + # A non-200 that is not a denial still means this scope produced + # nothing, which must not be presented as "no reservations". + [void]$utilFailures.Add("$scopeId : HTTP $($resp.StatusCode)") + } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Reservation summaries query failed for $scopeId : $($_.Exception.Message)" } - $reservations += @($latestReservation.Values) } + $reservations += @($latestReservation.Values) # -- Step 2: Try the Reservation Orders API at billing scope -- if ($reservations.Count -eq 0) { @@ -194,7 +163,7 @@ function Get-CommitmentUtilization { foreach ($ri in $op.reservations) { # Get utilization summary for each reservation try { - $utilPath = "$($ri.id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'" + $utilPath = "$($ri.id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/UsageDate ge $usageFrom and properties/UsageDate le $usageTo" $utilResp = Invoke-AzRestMethodWithRetry -Path $utilPath -Method GET if ($utilResp.StatusCode -eq 200) { $utilData = ($utilResp.Content | ConvertFrom-Json) @@ -233,90 +202,50 @@ function Get-CommitmentUtilization { } # -- Step 3: Savings Plans utilization via Benefit Utilization Summaries -- - # For MCA: query at billing-profile scope first - if ($billingProfileIds.Count -gt 0 -and $savingsPlans.Count -eq 0) { - foreach ($bpId in $billingProfileIds) { - try { - $spPath = "$bpId/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly" - $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET - if ($spResp.StatusCode -eq 200) { - $spData = ($spResp.Content | ConvertFrom-Json) - if ($spData.value) { - foreach ($item in $spData.value) { - $p = $item.properties - if ($p.benefitType -eq 'SavingsPlan') { - $savingsPlans += [PSCustomObject]@{ - BenefitId = $p.benefitId - BenefitOrderId = $p.benefitOrderId - BenefitType = $p.benefitType - AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) - UsageDate = $p.usageDate - } - } - } - } - } - elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true } - } catch { - if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } - Write-Warning " Savings plan query at billing profile scope failed: $($_.Exception.Message)" - } + # Keyed on benefitId, not benefitOrderId: one order can hold several savings + # plans, so ordering alone would collapse distinct plans into one and drop + # real commitments. Only the newest period per plan is kept, so SPCount + # counts plans rather than monthly records. + $latestSavingsPlan = @{} + $spIdx = 0 + foreach ($scopeId in $commitmentScopes) { + $spIdx++ + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying savings plans ($spIdx/$scopeTotal scopes)..." } - } + try { + $spPath = "$scopeId/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&grainParameter=Monthly" + $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET - # Fallback: subscription scope (EA, PAYG, etc.) - if ($savingsPlans.Count -eq 0) { - # Same coverage rule as reservations: query every selected subscription - # and de-duplicate, rather than sampling a few and stopping at the first - # hit. The try sits inside the loop so one unreadable subscription does - # not abandon the ones after it. - # - # Keyed on benefitId, not benefitOrderId: one order can contain several - # savings plans, so ordering alone would collapse distinct plans into - # one and drop real commitments. Only the newest period per plan is - # kept, so SPCount counts plans rather than monthly records. - $latestSavingsPlan = @{} - $spTotal = @($Subscriptions).Count - $spIdx = 0 - foreach ($sub in $Subscriptions) { - $spIdx++ - if ($spIdx -eq 1 -or $spIdx -eq $spTotal -or ($spTotal -gt 5 -and $spIdx % [math]::Max(1, [int]($spTotal / 10)) -eq 0)) { - if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { - Update-ScanStatus "Querying savings plans ($spIdx/$spTotal subs)..." - } - } - try { - $spPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly" - $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET - if ($spResp.StatusCode -eq 200) { - $spData = ($spResp.Content | ConvertFrom-Json) - if ($spData.value) { - foreach ($item in $spData.value) { - $p = $item.properties - if ($p.benefitType -eq 'SavingsPlan') { - $key = if ($p.benefitId) { [string]$p.benefitId } else { [string]$p.benefitOrderId } - if ([string]::IsNullOrWhiteSpace($key)) { continue } - $existing = $latestSavingsPlan[$key] - if ($existing -and -not (Test-UsageDateIsNewer -Candidate $p.usageDate -Existing $existing.UsageDate)) { continue } - $latestSavingsPlan[$key] = [PSCustomObject]@{ - BenefitId = $p.benefitId - BenefitOrderId = $p.benefitOrderId - BenefitType = $p.benefitType - AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) - UsageDate = $p.usageDate - } - } + if ($spResp.StatusCode -eq 200) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $spResp -Context 'savings plan utilization' -RootNextLink)) { + foreach ($item in @(($page.Content | ConvertFrom-Json).value)) { + $p = $item.properties + if ($p.benefitType -ne 'SavingsPlan') { continue } + $key = if ($p.benefitId) { [string]$p.benefitId } else { [string]$p.benefitOrderId } + if ([string]::IsNullOrWhiteSpace($key)) { continue } + $existing = $latestSavingsPlan[$key] + if ($existing -and -not (Test-UsageDateIsNewer -Candidate $p.usageDate -Existing $existing.UsageDate)) { continue } + $latestSavingsPlan[$key] = [PSCustomObject]@{ + BenefitId = $p.benefitId + BenefitOrderId = $p.benefitOrderId + BenefitType = $p.benefitType + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + UsageDate = $p.usageDate } } } - elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true } - } catch { - if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } - Write-Warning " Savings plan utilization query failed for $($sub.Name): $($_.Exception.Message)" } + elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + else { + [void]$utilFailures.Add("$scopeId : HTTP $($spResp.StatusCode)") + } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Savings plan utilization query failed for $scopeId : $($_.Exception.Message)" } - $savingsPlans += @($latestSavingsPlan.Values) } + $savingsPlans += @($latestSavingsPlan.Values) # -- Step 4: Calculate summary stats -- $riAvgUtil = 0 @@ -337,7 +266,13 @@ function Get-CommitmentUtilization { # Human-readable summary so the zeros below are never mistaken for # "no commitments / all healthy" when the real cause is no access. - $note = if ($denied) { + $note = if (@($commitmentScopes).Count -eq 0) { + # Distinct from an access denial: the account list was readable, it just + # does not contain an account that owns a scanned subscription. + $detail = if ($scopeResolutionReason) { " $scopeResolutionReason" } else { '' } + "Reservations and savings plans are billing-scoped, and no billing scope was resolved for the scanned subscriptions, so utilization could not be read.$detail" + } + elseif ($denied) { 'Access denied reading reservation/savings-plan utilization (needs Cost Management Reader / billing-scope access). The zero counts below reflect missing access, NOT confirmed absence of commitments.' } elseif ($riCount -eq 0 -and $spCount -eq 0) { @@ -362,6 +297,7 @@ function Get-CommitmentUtilization { UnderutilizedRIs = $underutilized HasData = ($riCount -gt 0 -or $spCount -gt 0) AccessDenied = $denied + ScopesQueried = @($commitmentScopes).Count Note = $note # Reservations excluded because their utilization could not be read. UtilizationFailures = $utilFailures.Count diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 index 734deb3bc..d424c48ee 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 @@ -64,7 +64,12 @@ function Get-CostQueryResponsePage { [string]$Context = 'cost query', [Parameter()] - [int]$MaxPages = 50 + [int]$MaxPages = 50, + + # The Cost Management query API nests nextLink under properties, while + # the Consumption and benefit list APIs return it at the root. + [Parameter()] + [switch]$RootNextLink ) $pages = [System.Collections.Generic.List[object]]::new() @@ -76,7 +81,10 @@ function Get-CostQueryResponsePage { $pageCount++ $next = $null - try { $next = ($resp.Content | ConvertFrom-Json).properties.nextLink } + try { + $parsed = $resp.Content | ConvertFrom-Json + $next = if ($RootNextLink) { $parsed.nextLink } else { $parsed.properties.nextLink } + } catch { $next = $null } if ([string]::IsNullOrWhiteSpace($next)) { break } diff --git a/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 b/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 index 9b73270cd..e05057a58 100644 --- a/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 +++ b/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 @@ -16,8 +16,22 @@ Describe 'Commitment utilization de-duplication' { [PSCustomObject]@{ Id = '00000000-0000-0000-0000-000000000002'; Name = 'Sub two' } ) - # One reservation, two usage periods. Both subscriptions consumed it, so - # both report the identical pair of records. + # Both APIs are billing-scoped, so the scan first resolves the billing + # account that owns the scanned subscriptions. + $script:BillingAccountPayload = @{ + value = @( + @{ id = '/providers/Microsoft.Billing/billingAccounts/TEST-BA' + name = 'TEST-BA' + properties = @{ agreementType = 'EnterpriseAgreement' } + } + ) + } | ConvertTo-Json -Depth 8 + + $script:BillingPropertyPayload = @{ + properties = @{ billingAccountId = '/providers/Microsoft.Billing/billingAccounts/TEST-BA' } + } | ConvertTo-Json -Depth 8 + + # One reservation reported across two usage periods. $script:ReservationPayload = @{ value = @( @{ properties = @{ reservationOrderId = 'order-1'; reservationId = 'res-1'; skuName = 'Standard_D2s_v5'; kind = 'Compute' @@ -52,32 +66,53 @@ Describe 'Commitment utilization de-duplication' { Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue } - It 'Counts one reservation when every subscription reports it for several months' { + It 'Counts one reservation when it is reported for several months' { Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { - if ($Path -match 'reservationSummaries') { - [PSCustomObject]@{ StatusCode = 200; Content = $script:ReservationPayload } - } - else { - [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } - } + if ($Path -match 'billingAccounts\?') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path -match 'reservationSummaries') { [PSCustomObject]@{ StatusCode = 200; Content = $script:ReservationPayload } } + else { [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } } } $result = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue - # 2 subscriptions x 2 months = 4 API records for a single commitment. + # Two monthly records describe a single commitment. $result.RICount | Should -Be 1 # The newest period wins, so the average is not dragged down by August. $result.RIAvgUtilization | Should -Be 90 } - It 'Keeps distinct savings plans that share one benefit order' { + It 'Queries billing scope rather than subscription scope' { + # Subscription-scoped paths answer 404, so a regression back to them + # would silently report zero commitments. + $script:SeenPaths = [System.Collections.Generic.List[string]]::new() Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { - if ($Path -match 'benefitUtilizationSummaries') { - [PSCustomObject]@{ StatusCode = 200; Content = $script:SavingsPlanPayload } - } - else { - [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } + if ($Path -match 'billingAccounts\?') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path -match 'reservationSummaries') { + $script:SeenPaths.Add($Path) + [PSCustomObject]@{ StatusCode = 200; Content = $script:ReservationPayload } } + else { [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } } + } + + $null = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + @($script:SeenPaths).Count | Should -BeGreaterThan 0 + foreach ($p in $script:SeenPaths) { + $p | Should -BeLike '/providers/Microsoft.Billing/billingAccounts/*' + $p | Should -Not -BeLike '/subscriptions/*' + # UsageDate is an Edm.DateTimeOffset; a quoted bound fails the compare. + $p | Should -Not -Match "UsageDate ge '" + } + } + + It 'Keeps distinct savings plans that share one benefit order' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match 'billingAccounts\?') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path -match 'benefitUtilizationSummaries') { [PSCustomObject]@{ StatusCode = 200; Content = $script:SavingsPlanPayload } } + else { [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } } } $result = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue diff --git a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 index adeab989a..63b2de887 100644 --- a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 +++ b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 @@ -61,6 +61,33 @@ Describe 'Cost Management query pagination' { @($parsed.properties.rows).Count | Should -Be 3 } + Context 'Root-level nextLink' { + # The Consumption and benefit list APIs return nextLink at the root, + # while the Cost Management query API nests it under properties. + BeforeAll { + $script:RootLinkResponse = [PSCustomObject]@{ + StatusCode = 200 + Content = (@{ value = @(1); nextLink = 'https://management.azure.com/next?page=2' } | ConvertTo-Json) + } + } + + It 'Follows it when RootNextLink is requested' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ StatusCode = 200; Content = (@{ value = @(2) } | ConvertTo-Json) } + } + + $pages = @(Get-CostQueryResponsePage -FirstResponse $script:RootLinkResponse -RootNextLink) + + $pages.Count | Should -Be 2 + } + + It 'Ignores it by default so the query API behaviour is unchanged' { + $pages = @(Get-CostQueryResponsePage -FirstResponse $script:RootLinkResponse) + + $pages.Count | Should -Be 1 + } + } + Context 'nextLink validation' { # nextLink is service-supplied. A relative or malformed value yields an # empty PathAndQuery rather than throwing, and a foreign host would be From 582ff2ff87ce3ed4c26a448f880f000874c146cd Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 14 Sep 2026 19:00:38 -0600 Subject: [PATCH 120/142] FinOps Multitool Update - Sep 14 Review findings - A budget query that returned a non-200 or threw was counted as a subscription without a budget, so missing access produced an exact-looking coverage percentage. Unreadable subscriptions are now tracked separately, excluded from the without-budget count, and suppress the percentage. - Coverage wording now covers both an unqueried sample and an unreadable subscription. --- .../Invoke-FinOpsMultitool.ps1 | 8 +- .../modules/Get-BudgetStatus.ps1 | 32 +++++-- .../Tests/Unit/BudgetCoverage.Tests.ps1 | 93 +++++++++++++++++++ 3 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index dba81d5f9..10290fa1c 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1760,7 +1760,7 @@ function Invoke-FinOpsMultitool { Write-Host " | " -ForegroundColor White -NoNewline Write-Host "Over budget: $($data.OverBudgetCount)" -ForegroundColor $(if ($data.OverBudgetCount -gt 0) { 'Red' } else { 'Green' }) -NoNewline if ($data.CoverageIncomplete) { - Write-Host " | Coverage: unverified (sampled $($data.ScannedSubs) of $($data.TotalSubs) subs)" -ForegroundColor Yellow + Write-Host " | Coverage: unverified (read $($data.ScannedSubs) of $($data.TotalSubs) subs)" -ForegroundColor Yellow } else { Write-Host " | Coverage: $($data.BudgetCoverage)%" -ForegroundColor White @@ -2349,8 +2349,8 @@ function Invoke-FinOpsMultitool { } elseif ($data.CoverageIncomplete) { $guidanceItems = @( - @{ Severity = 'Yellow'; Message = "No budgets found in a sample of $($data.ScannedSubs) of $($data.TotalSubs) subscriptions. Coverage across the rest is unverified." } - @{ Severity = 'Yellow'; Message = "Re-run against a narrower subscription set to measure budget coverage exactly."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' } + @{ Severity = 'Yellow'; Message = "Budgets were read for $($data.ScannedSubs) of $($data.TotalSubs) subscriptions, so coverage across the rest is unverified." } + @{ Severity = 'Yellow'; Message = "Re-run against a narrower subscription set, or resolve the access gap, to measure budget coverage exactly."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' } ) } elseif ($bCoverage -lt 50) { @@ -2948,7 +2948,7 @@ tr:hover td { background: var(--surface); } } 'Get-BudgetStatus' { $htmlCoverage = if ($data.CoverageIncomplete) { - "unverified (sampled $($data.ScannedSubs) of $($data.TotalSubs) subs)" + "unverified (read $($data.ScannedSubs) of $($data.TotalSubs) subs)" } else { "$($data.BudgetCoverage)%" } [void]$htmlSb.Append("

Budgets: $($data.TotalBudgets)  |  At risk: $($data.AtRiskCount)  |  Over budget: $($data.OverBudgetCount)  |  Coverage: $htmlCoverage

") diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 index a184c4e7c..19d8a952d 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -35,6 +35,9 @@ function Get-BudgetStatus { $sampled = $false $scannedSubs = $subCount $coverageIncomplete = $false + # Subscriptions whose budget query never answered. Counting these as "no + # budget" would turn missing access into a confident coverage percentage. + $unreadableSubs = 0 # -- For large tenants, sample first to see if budgets exist -------- $subsToQuery = $Subscriptions @@ -191,12 +194,12 @@ function Get-BudgetStatus { } } else { - $subsWithoutBudget++ + $unreadableSubs++ } } catch { Write-Warning " Budget query failed for $($sub.Name): $($_.Exception.Message)" - $subsWithoutBudget++ + $unreadableSubs++ } } @@ -206,24 +209,35 @@ function Get-BudgetStatus { $overBudget = @($budgets | Where-Object { $_.Risk -eq 'Over Budget' }).Count $atRisk = @($budgets | Where-Object { $_.Risk -in @('Forecast Over', 'At Risk') }).Count + # Either an unqueried sample or an unreadable subscription leaves coverage + # unmeasured, so both suppress the percentage rather than rounding down. + if ($unreadableSubs -gt 0) { $coverageIncomplete = $true } + $readSubs = $scannedSubs - $unreadableSubs + + $note = if ($sampled) { + "Sampled $scannedSubs of $subCount subscriptions and none had a budget. Budgets may still exist in the subscriptions that were not queried, so coverage is unverified." + } + elseif ($unreadableSubs -gt 0) { + "$unreadableSubs of $subCount subscriptions could not be queried for budgets, so coverage is unverified. They are not counted as being without a budget." + } + else { $null } + return [PSCustomObject]@{ Budgets = @($budgets) TotalBudgets = $budgets.Count SubsWithBudget = $subsWithBudget SubsWithoutBudget = $subsWithoutBudget + UnreadableSubs = $unreadableSubs OverBudgetCount = $overBudget AtRiskCount = $atRisk HasData = ($budgets.Count -gt 0) Sampled = $sampled - ScannedSubs = $scannedSubs + ScannedSubs = $readSubs TotalSubs = $subCount CoverageIncomplete = $coverageIncomplete - Note = if ($coverageIncomplete) { - "Sampled $scannedSubs of $subCount subscriptions and none had a budget. Budgets may still exist in the subscriptions that were not queried, so coverage is unverified." - } - else { $null } - # Left null when incomplete: a percentage derived from a sample would be - # read as a measured coverage figure for the whole tenant. + Note = $note + # Left null when incomplete: a percentage derived from a partial read + # would be taken as a measured figure for the whole tenant. BudgetCoverage = if ($coverageIncomplete) { $null } elseif ($Subscriptions.Count -gt 0) { [math]::Round(($subsWithBudget / $Subscriptions.Count) * 100, 1) diff --git a/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 new file mode 100644 index 000000000..dc44e2087 --- /dev/null +++ b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 @@ -0,0 +1,93 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Budget coverage reporting' { + + # 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 + + $script:SubA = '00000000-0000-0000-0000-00000000000a' + $script:SubB = '00000000-0000-0000-0000-00000000000b' + $script:TwoSubs = @( + [PSCustomObject]@{ Id = $script:SubA; Name = 'Sub A' } + [PSCustomObject]@{ Id = $script:SubB; Name = 'Sub B' } + ) + + $script:OneBudget = @{ + value = @( + @{ name = 'monthly-budget'; properties = @{ amount = 100; timeGrain = 'Monthly'; category = 'Cost' } } + ) + } | ConvertTo-Json -Depth 8 + + $script:NoBudgets = '{"value":[]}' + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + It 'Does not count an unreadable subscription as having no budget' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match $script:SubA) { [PSCustomObject]@{ StatusCode = 200; Content = $script:OneBudget } } + else { [PSCustomObject]@{ StatusCode = 403; Content = '{}' } } + } + + $r = Get-BudgetStatus -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + $r.SubsWithBudget | Should -Be 1 + # The denied subscription is unknown, not budget-free. + $r.SubsWithoutBudget | Should -Be 0 + $r.UnreadableSubs | Should -Be 1 + $r.CoverageIncomplete | Should -BeTrue + $r.ScannedSubs | Should -Be 1 + $r.TotalSubs | Should -Be 2 + } + + It 'Suppresses the coverage percentage when a subscription could not be read' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match $script:SubA) { [PSCustomObject]@{ StatusCode = 200; Content = $script:OneBudget } } + else { [PSCustomObject]@{ StatusCode = 500; Content = '{}' } } + } + + $r = Get-BudgetStatus -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + # 1 of 2 would read as 50% measured coverage, which was never measured. + $r.BudgetCoverage | Should -BeNullOrEmpty + $r.Note | Should -Match 'could not be queried' + } + + It 'Treats a thrown query as unreadable rather than budget-free' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match $script:SubA) { [PSCustomObject]@{ StatusCode = 200; Content = $script:OneBudget } } + else { throw 'network blew up' } + } + + $r = Get-BudgetStatus -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + $r.UnreadableSubs | Should -Be 1 + $r.SubsWithoutBudget | Should -Be 0 + $r.CoverageIncomplete | Should -BeTrue + } + + It 'Reports measured coverage when every subscription answered' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match $script:SubA) { [PSCustomObject]@{ StatusCode = 200; Content = $script:OneBudget } } + else { [PSCustomObject]@{ StatusCode = 200; Content = $script:NoBudgets } } + } + + $r = Get-BudgetStatus -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + # An empty 200 is a real answer: that subscription has no budget. + $r.SubsWithBudget | Should -Be 1 + $r.SubsWithoutBudget | Should -Be 1 + $r.UnreadableSubs | Should -Be 0 + $r.CoverageIncomplete | Should -BeFalse + $r.BudgetCoverage | Should -Be 50 + $r.Note | Should -BeNullOrEmpty + } +} From 93b0339db47b0b0bfee3d4036be40a6d6e5f12b9 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 14 Sep 2026 19:13:17 -0600 Subject: [PATCH 121/142] FinOps Multitool Update - Sep 14 Review findings - The variance analysis section did not point at the budget history scan, which is the one purpose-built for month-by-month budget vs actual. Added it there and to the hand-offs list. --- src/templates/agent-skills/forecasting-budgeting/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/templates/agent-skills/forecasting-budgeting/SKILL.md b/src/templates/agent-skills/forecasting-budgeting/SKILL.md index 1e3c81f13..7fbee129d 100644 --- a/src/templates/agent-skills/forecasting-budgeting/SKILL.md +++ b/src/templates/agent-skills/forecasting-budgeting/SKILL.md @@ -49,9 +49,11 @@ For an over/under, decompose the gap: Report each driver with its $ contribution so the variance is explained, not just stated. +For the month-by-month picture, run **Budget History** from `finops-multitool` rather than rebuilding the series from cost trend — it returns actuals per budget per month, which is the series variance analysis needs. + ## Hand-offs -- History + current budgets → `finops-multitool` (cost trend, budget status) / `finops-toolkit` KQL. +- History + current budgets → `finops-multitool` (cost trend, budget status, budget history) / `finops-toolkit` KQL. - Rate-driven variance → `rate-optimization-portfolio` and `unit-economics`. - Sudden unexpected spike → `anomaly-investigation` skill. - Write up the variance → `finops-reporting`. From 478ab89c723aeb611c058f69977bc8526f0e15fc Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Mon, 14 Sep 2026 21:49:51 -0600 Subject: [PATCH 122/142] Documentation update --- .../toolkit/multitool/finops-multitool-overview.md | 8 ++++++-- .../powershell/multitool/finops-multitool-commands.md | 8 +++++--- .../toolkit/powershell/multitool/start-finopsmultitool.md | 6 ++++-- .../Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 | 2 +- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md index f377d745a..400138601 100644 --- a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -3,7 +3,7 @@ title: FinOps multitool overview 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/25/2026 +ms.date: 09/14/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit @@ -37,7 +37,11 @@ Instead of checking Azure Advisor, Cost Analysis, Resource Graph, and the budget ## 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](/azure/carbon-optimization/permissions) assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. +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. + +Commitment utilization reads reservation and savings plan usage at billing account or billing profile scope, so it needs the same access as account scans. Reader on a subscription isn't enough. Without it, the scan tells you it couldn't reach a billing scope instead of showing zero commitments. + +The carbon scan needs Reader or [Carbon Optimization Reader](/azure/carbon-optimization/permissions) assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. ## Give feedback diff --git a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md index daffe2c06..fbe5dc043 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: 08/22/2026 +ms.date: 09/14/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -44,7 +44,9 @@ The multitool includes 30 scan modules across the following categories: - **AI and ML** – Azure AI workload spend. - **Sustainability** – Carbon emissions. -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 assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. +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. Commitment utilization reads reservation and savings plan usage at billing account or billing profile scope, so it needs that same billing access. The carbon scan needs Reader or Carbon Optimization Reader assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. + +When a scan can't read every subscription you selected, it tells you instead of treating the gap as a result. Budget status reports coverage as unverified rather than a percentage.
@@ -53,7 +55,7 @@ Analysis scans are read-only. Most need Reader or Cost Management Reader access. When a [FinOps hub](../../hubs/finops-hubs-overview.md) is present, cost scans read from the hub and choose the path automatically: - **Kusto database (recommended for large environments)** – When the hub has an Azure Data Explorer or Microsoft Fabric cluster, the multitool discovers it through Azure Resource Graph and pushes aggregation into the engine, returning only summarized results. This scales to large datasets without loading raw cost rows into PowerShell. To query a local hub on your own hardware, set the `FINOPS_HUB_KUSTO_URI` environment variable to a local Kusto endpoint (optionally set `FINOPS_HUB_KUSTO_DB`, which defaults to `Hub`). -- **Storage reader (small-dataset fallback)** – When no Kusto cluster is reachable, the multitool reads the hub's storage export and aggregates in PowerShell. Use this for smaller datasets. +- **Storage reader (small-dataset fallback)** – When no Kusto cluster is reachable, the multitool reads the hub's storage export and aggregates in PowerShell. Use this for smaller datasets. Reading Parquet exports installs a reader the first time you read one, using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. If neither is available, the multitool reads the CSV exports instead and tells you why. If no hub is available, cost scans use the live Cost Management API. diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md index afc08e3e6..7f8c04ca9 100644 --- a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -3,7 +3,7 @@ title: Start-FinOpsMultitool command 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: 08/27/2026 +ms.date: 09/14/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -17,7 +17,7 @@ The **Start-FinOpsMultitool** command launches the FinOps multitool interactive Results are rendered in the terminal. When you choose to export, the tool writes a CSV file per scan module, a `FinOpsReport.html` summary, and a `ScanSummary.txt` file. The scan modules are read-only. -The command requires PowerShell 7 or later on Windows, macOS, and Linux. 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 assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. +The command requires PowerShell 7 or later on Windows, macOS, and Linux. 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. Commitment utilization reads at billing account or billing profile scope, so it needs that same billing access. The carbon scan needs Reader or Carbon Optimization Reader assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. The tool prompts for each choice by default. To run it from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters. @@ -104,6 +104,8 @@ Use `-NonInteractive` when nothing can answer a prompt, such as a build agent. 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). +Reading Parquet exports installs a reader the first time you read one, using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. If neither is available, the multitool reads the CSV exports instead and tells you why. +
## Related content diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 10290fa1c..3fd8b0dfd 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -151,7 +151,7 @@ function Invoke-FinOpsMultitool { 'Get-UnitEconomics' = @{ Role = 'Cost Management Reader + Reader'; Scope = 'Management Group'; API = 'Cost Management Query API + Azure Resource Graph + Azure Monitor metrics'; Reason = 'Requires amortized cost (Cost Management), capacity counts (Resource Graph), and storage-account used capacity (Monitor UsedCapacity metric) to compute $/vCPU, $/GB RAM and $/GB stored.' } 'Get-AIWorkloadMetrics' = @{ Role = 'Cost Management Reader + Reader'; Scope = 'Management Group'; API = 'Azure Resource Graph + Monitor Metrics + Cost Management Query API'; Reason = 'Requires Reader to detect AI resources and read Azure OpenAI token metrics, plus Cost Management Reader to map token usage to spend. Skips the deep scan when no AI workloads are present.' } 'Get-ReservationAdvice' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Reservation Recommendations API'; Reason = 'Requires Microsoft.Consumption/reservationRecommendations/read to retrieve reservation purchase advice.' } - 'Get-CommitmentUtilization' = @{ Role = 'Cost Management Reader or Reservation Reader'; Scope = 'Reservation Order or Subscription'; API = 'Consumption Reservation Summaries API'; Reason = 'Requires Microsoft.Consumption/reservationSummaries/read. If no reservations exist, this will be empty.' } + 'Get-CommitmentUtilization' = @{ Role = 'Billing Reader, or Enterprise Administrator (reader) on an EA'; Scope = 'Billing account or billing profile'; API = 'Consumption Reservation Summaries + Cost Management Benefit Utilization APIs'; Reason = 'Reservation and savings plan utilization is published at billing scope only; a subscription-scoped read returns 404. Without billing access, the scan reports that no billing scope was resolved rather than reporting zero commitments.' } 'Get-SavingsRealized' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Benefit Utilization API'; Reason = 'Requires Microsoft.CostManagement/benefitUtilizationSummaries/read. Returns empty if no active reservations or savings plans.' } 'Get-BudgetStatus' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Budgets API'; Reason = 'Requires Microsoft.Consumption/budgets/read. Returns empty if no budgets are configured for scanned subscriptions.' } 'Get-BudgetHistory' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to retrieve monthly actuals per budget. Runs only when Budget Status returns budgets.' } From 8ce188f523bab79614066ee904cc7fabe224c771 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 15 Sep 2026 12:08:57 -0600 Subject: [PATCH 123/142] Update documentation --- .../multitool/finops-multitool-overview.md | 19 +++++++++++++------ .../multitool/finops-multitool-commands.md | 8 ++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md index 400138601..de688bd4d 100644 --- a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -3,7 +3,7 @@ title: FinOps multitool overview 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: 09/14/2026 +ms.date: 09/15/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit @@ -21,19 +21,26 @@ 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. Consoles that can't render the arrow-key menus fall back to numbered prompts, and a non-interactive mode runs the same scans from a pipeline or a scheduled job. -- **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. +- **AI agent support**
Agent skills describe the same investigations, the queries behind them, and how to read the results, so AI assistants can answer cost questions from your environment's data. -- **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. +- **Cost data sources**
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. -- **Read-only**
Every scan reads your environment and reports what it finds. The multitool never creates, changes, or deletes a resource. +- **Read-only**
The multitool never creates, changes, or deletes a resource. ## Benefits -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 select. +FinOps multitool provides the following benefits: + +- Run 30 scans across optimization, governance, cost analysis, commitments, monitoring, and sustainability in a single pass. +- Scope each scan to the subscriptions you select. +- Export findings to a CSV file per scan, an HTML report, and a text summary. +- Read cost data from a FinOps hub, the Cost Management API, or Azure Resource Graph. +- Run the same scans from a pipeline or a scheduled job with `-NonInteractive`. +- Run the same investigations from an AI assistant through agent skills. ## 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. +[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 reports the same kinds of findings in the terminal and through AI agent skills, so you can scan an environment during a working session without leaving the command line. ## Required permissions diff --git a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md index fbe5dc043..d8c0cf34d 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: 09/14/2026 +ms.date: 09/15/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -15,10 +15,10 @@ ms.reviewer: micflan 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 one scan engine through two interfaces: +The multitool provides one scan engine with 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. -- **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. +- **Agent skills** – A set of skills that describe which investigation answers a question, the queries behind it, and how to read the results. The terminal UI prompts for each choice by default. Consoles that can't render the arrow-key menus, such as PowerShell remoting sessions, fall back to numbered prompts. To run the tool from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters. @@ -54,7 +54,7 @@ When a scan can't read every subscription you selected, it tells you instead of When a [FinOps hub](../../hubs/finops-hubs-overview.md) is present, cost scans read from the hub and choose the path automatically: -- **Kusto database (recommended for large environments)** – When the hub has an Azure Data Explorer or Microsoft Fabric cluster, the multitool discovers it through Azure Resource Graph and pushes aggregation into the engine, returning only summarized results. This scales to large datasets without loading raw cost rows into PowerShell. To query a local hub on your own hardware, set the `FINOPS_HUB_KUSTO_URI` environment variable to a local Kusto endpoint (optionally set `FINOPS_HUB_KUSTO_DB`, which defaults to `Hub`). +- **Kusto database (used when available)** – When the hub has an Azure Data Explorer or Microsoft Fabric cluster, the multitool discovers it through Azure Resource Graph and pushes aggregation into the engine, returning only summarized results. This scales to large datasets without loading raw cost rows into PowerShell. To query a local hub on your own hardware, set the `FINOPS_HUB_KUSTO_URI` environment variable to a local Kusto endpoint (optionally set `FINOPS_HUB_KUSTO_DB`, which defaults to `Hub`). - **Storage reader (small-dataset fallback)** – When no Kusto cluster is reachable, the multitool reads the hub's storage export and aggregates in PowerShell. Use this for smaller datasets. Reading Parquet exports installs a reader the first time you read one, using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. If neither is available, the multitool reads the CSV exports instead and tells you why. If no hub is available, cost scans use the live Cost Management API. From 1d90c5094090e75aabc5bccadbc788b978081a2a Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 15 Sep 2026 12:24:41 -0600 Subject: [PATCH 124/142] FinOps Multitool Update - Sep 15 Review findings - Budget history reused cached Cost Trend data whenever the subscription was present, so requesting more months than the cache held reported the uncovered months as zero spend and under budget. Coverage is now checked and uncovered windows fall back to a live query. - Cursor repositioning now goes through a guarded helper so a buffer resize mid-render cannot surface a .NET stack trace. - Corrected the sustainability skill data source table for the Emissions Impact Dashboard retirement and marked Azure carbon optimization as preview. --- .../Invoke-FinOpsMultitool.ps1 | 23 ++++++-- .../modules/Get-BudgetStatus.ps1 | 21 ++++++-- .../Tests/Unit/BudgetCoverage.Tests.ps1 | 52 +++++++++++++++++++ .../sustainability-carbon/SKILL.md | 11 ++-- 4 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 3fd8b0dfd..764bd627f 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -240,6 +240,21 @@ function Invoke-FinOpsMultitool { return $rich } + # Repositioning can still fail after the capability probe passes, for example + # when the buffer shrinks mid-render, so a failure re-renders lower rather + # than surfacing a .NET stack trace. + function Move-FinOpsCursorLine { + param([int]$LinesUp = 0) + try { + $top = [Console]::CursorTop + if ($LinesUp -gt 0) { $top = [math]::Max(0, $top - $LinesUp) } + [Console]::SetCursorPosition(0, $top) + } + catch { + Write-Verbose "Cursor repositioning unavailable: $($_.Exception.Message)" + } + } + # Read-Host returns an empty string in a host that cannot prompt, which would # spin a validation loop forever, so every caller needs an attempt ceiling. function Read-FinOpsAnswer { @@ -504,7 +519,7 @@ function Invoke-FinOpsMultitool { while ($true) { $tWidth = Get-MenuWidth 85 - [Console]::SetCursorPosition(0, [Console]::CursorTop) + Move-FinOpsCursorLine for ($t = 0; $t -lt $tenants.Count; $t++) { $tPrefix = if ($t -eq $tCursor) { ' > ' } else { ' ' } $tColor = if ($t -eq $tCursor) { 'Green' } else { 'Gray' } @@ -549,7 +564,7 @@ function Invoke-FinOpsMultitool { # Move cursor back up to re-render $tLinesToClear = $tenants.Count + 2 - [Console]::SetCursorPosition(0, [math]::Max(0, [Console]::CursorTop - $tLinesToClear)) + Move-FinOpsCursorLine -LinesUp $tLinesToClear } Write-Host "" } @@ -621,7 +636,7 @@ function Invoke-FinOpsMultitool { $renderStart = $offset $renderEnd = [math]::Min($offset + $pageSize, $allSubs.Count) - 1 $width = Get-MenuWidth 75 - [Console]::SetCursorPosition(0, [Console]::CursorTop) + Move-FinOpsCursorLine for ($i = $renderStart; $i -le $renderEnd; $i++) { $prefix = if ($i -eq $cursor) { ' > ' } else { ' ' } @@ -656,7 +671,7 @@ function Invoke-FinOpsMultitool { # Move cursor back up to re-render $linesToClear = ($renderEnd - $renderStart + 1) + 2 - [Console]::SetCursorPosition(0, [math]::Max(0, [Console]::CursorTop - $linesToClear)) + Move-FinOpsCursorLine -LinesUp $linesToClear } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 index 19d8a952d..c2677d782 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -284,6 +284,15 @@ function Get-BudgetHistory { } } + # History reports on a fixed window, so cached trend data is only usable when + # it covers that whole window. Cost Trend may hold fewer months than + # -MonthsBack asks for, and the uncovered months would otherwise be filled + # with zero spend and reported as being under budget. + $requiredMonths = [System.Collections.Generic.List[string]]::new() + for ($m = $MonthsBack; $m -ge 1; $m--) { + [void]$requiredMonths.Add((Get-Date).AddMonths(-$m).ToString('yyyy-MM')) + } + # Group budgets by subscription to minimize API calls $bySubId = $Budgets | Group-Object SubscriptionId @@ -292,12 +301,18 @@ function Get-BudgetHistory { $subName = $subGroup.Group[0].Subscription # Prefer reusing Cost Trend data (zero extra API calls). Fall back to a - # live per-sub Cost Management query only when trend data is missing. + # live per-sub Cost Management query when trend data is missing or does + # not reach as far back as this report does. $monthlyCosts = $null if ($trendBySub.ContainsKey($subId)) { - $monthlyCosts = $trendBySub[$subId] + $cached = $trendBySub[$subId] + $covered = $true + foreach ($rm in $requiredMonths) { + if (-not $cached.ContainsKey($rm)) { $covered = $false; break } + } + if ($covered) { $monthlyCosts = $cached } } - else { + if (-not $monthlyCosts) { # Query monthly costs for this sub over the last N months $startDate = (Get-Date).AddMonths(-$MonthsBack).ToString('yyyy-MM-01') $endDate = (Get-Date -Day 1).AddDays(-1).ToString('yyyy-MM-dd') # Last day of previous month diff --git a/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 index dc44e2087..4d7df4919 100644 --- a/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 +++ b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 @@ -90,4 +90,56 @@ Describe 'Budget coverage reporting' { $r.BudgetCoverage | Should -Be 50 $r.Note | Should -BeNullOrEmpty } + + Context 'Budget history month coverage' { + + BeforeAll { + $script:HistBudget = @( + [PSCustomObject]@{ + SubscriptionId = $script:SubA + Subscription = 'Sub A' + BudgetName = 'monthly-budget' + Amount = 100 + TimeGrain = 'Monthly' + } + ) + + $script:Trend2 = [PSCustomObject]@{ + BySubscription = @{ $script:SubA = @(2, 1 | ForEach-Object { + [PSCustomObject]@{ MonthDate = (Get-Date).AddMonths(-$_); Cost = 10 } + }) + } + } + + $script:Trend6 = [PSCustomObject]@{ + BySubscription = @{ $script:SubA = @(6, 5, 4, 3, 2, 1 | ForEach-Object { + [PSCustomObject]@{ MonthDate = (Get-Date).AddMonths(-$_); Cost = 10 } + }) + } + } + } + + It 'Queries live cost when cached trend is shorter than the requested window' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ StatusCode = 403; Content = '{}' } + } + + $null = Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 6 -CostTrend $script:Trend2 -WarningAction SilentlyContinue + + # Without the coverage check the four uncovered months would be + # reported as zero spend and therefore as being under budget. + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly + } + + It 'Reuses cached trend when it covers the requested window' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ StatusCode = 403; Content = '{}' } + } + + $rows = Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 6 -CostTrend $script:Trend6 -WarningAction SilentlyContinue + + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + @($rows).Count | Should -Be 6 + } + } } diff --git a/src/templates/agent-skills/sustainability-carbon/SKILL.md b/src/templates/agent-skills/sustainability-carbon/SKILL.md index 928943928..1e576ef3d 100644 --- a/src/templates/agent-skills/sustainability-carbon/SKILL.md +++ b/src/templates/agent-skills/sustainability-carbon/SKILL.md @@ -21,11 +21,14 @@ Use it when the user mentions carbon, emissions, sustainability, ESG, green/effi | Tool | Provides | | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | **carbon** (finops-multitool) | kgCO2e totals, month-over-month change, 12-month trend, and per-subscription breakdown. Start here in a multitool-driven session. | -| **Emissions Impact Dashboard (EID)** | Scope 1/2/3 emissions for the Microsoft Cloud footprint, by service/subscription/time | -| **Azure carbon optimization** | Per-resource emissions estimates and reduction recommendations in the portal | -| **Cloud for Sustainability** | Broader org-level sustainability data model | +| **Azure carbon optimization** (preview) | Per-resource emissions estimates and reduction recommendations in the portal. This is the forward-looking source. | +| **Emissions Impact Dashboard (EID)** | Scope 1/2/3 emissions for the Microsoft Cloud footprint, by service/subscription/time. The Power BI-hosted Azure dashboard retires March 31, 2027, so treat it as historical reporting and export anything you need to keep. | +| **Microsoft Sustainability Manager** | Broader org-level sustainability data model. Its EID for Azure connector is deprecated on the same date. | -Reference: https://learn.microsoft.com/azure/carbon-optimization/ +References: + +- [Carbon optimization in Azure](https://learn.microsoft.com/azure/carbon-optimization/) +- [Emissions Impact Dashboard retirement notice](https://learn.microsoft.com/power-bi/connect-data/service-connect-to-emissions-impact-dashboard) ## Cost and carbon overlap From 582aca325beb7d42ac8625867d8e5e265630e081 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 15 Sep 2026 15:11:44 -0600 Subject: [PATCH 125/142] FinOps Multitool Update - Sep 15 Review findings - Shared cost allocation called ConvertTo-AllocationPercentages, which an earlier cleanup commit deleted along with the write path, so any scan that found a positive pool threw CommandNotFoundException. Reimplemented the normalization for the single input shape this caller builds. - Export amounts parsed under the operator's culture, reading 123.45 as 12345 wherever '.' is the thousands separator. They now parse invariantly. - The telemetry dimension name reached KQL unescaped, so a caller could terminate the string literal and append query operators. - Region, SKU, and location reached OData filters without their single quotes doubled, allowing the filter predicate to be rewritten. - Added a guard that fails on any call to a command the module never defines, plus coverage for the parsing and escaping fixes. --- .../modules/Get-AhbVmSavingsRatio.ps1 | 5 +- .../modules/Get-SharedCostAllocation.ps1 | 38 ++++- .../modules/Get-UnitEconomics.ps1 | 4 +- .../Get-UsageProportionalAllocation.ps1 | 2 +- .../modules/helpers/Get-CostExport.ps1 | 20 ++- .../Tests/Unit/MultitoolSafety.Tests.ps1 | 142 ++++++++++++++++++ 6 files changed, 203 insertions(+), 8 deletions(-) create mode 100644 src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 index b8149695c..8e185eb1c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 @@ -46,7 +46,10 @@ function Get-AhbVmRates { $result = $null try { - $filter = "armRegionName eq '$Region' and armSkuName eq '$VmSize' and priceType eq 'Consumption' and serviceName eq 'Virtual Machines'" + # OData escapes a single quote by doubling it. + $safeRegion = $Region.Replace("'", "''") + $safeSize = $VmSize.Replace("'", "''") + $filter = "armRegionName eq '$safeRegion' and armSkuName eq '$safeSize' and priceType eq 'Consumption' and serviceName eq 'Virtual Machines'" $url = "https://prices.azure.com/api/retail/prices?`$filter=$([uri]::EscapeDataString($filter))" $resp = Invoke-RestMethod -Uri $url -Method GET -TimeoutSec 20 $items = @($resp.Items) | Where-Object { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 index d0e8bd5b7..5960e068e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -255,6 +255,42 @@ function Get-AllocationCostMaps { } } +# Whole percentages that sum to exactly 100.00; the rounding residual lands on +# the largest share so the set stays paste-ready for an allocation rule. +function ConvertTo-AllocationPercentage { + param([object[]]$Targets) + + $raw = @(foreach ($t in $Targets) { + if (-not $t.subscriptionId) { continue } + [PSCustomObject]@{ Name = [string]$t.subscriptionId; Value = [double]$t.allocatedShared } + }) + + if ($raw.Count -eq 0) { + return @{ Ok = $false; Error = 'No usable targets: each one needs subscriptionId and allocatedShared.'; Values = @() } + } + + $sum = ($raw | Measure-Object -Property Value -Sum).Sum + if ($sum -le 0) { + return @{ Ok = $false; Error = ('Allocated shares sum to {0}; cannot build percentages.' -f $sum); Values = @() } + } + + $scaled = @(foreach ($r in $raw) { + [PSCustomObject]@{ Name = $r.Name; Percentage = [math]::Round(($r.Value / $sum) * 100, 2) } + }) + + $residual = [math]::Round(100 - ($scaled | Measure-Object -Property Percentage -Sum).Sum, 2) + if ($residual -ne 0) { + $top = $scaled | Sort-Object -Property Percentage -Descending | Select-Object -First 1 + $top.Percentage = [math]::Round($top.Percentage + $residual, 2) + } + + return @{ + Ok = $true + Error = $null + Values = @($scaled | ForEach-Object { @{ name = $_.Name; percentage = $_.Percentage } }) + } +} + # -- Main: allocate shared cost across spokes ------------------------------ function Get-SharedCostAllocation { [CmdletBinding()] @@ -399,7 +435,7 @@ function Get-SharedCostAllocation { $ruleTargets = @() if ($poolTotal -gt 0 -and @($allocations).Count -gt 0) { $rtInput = @($allocations | ForEach-Object { @{ subscriptionId = $_.Spoke; allocatedShared = $_.AllocatedShared } }) - $rt = ConvertTo-AllocationPercentages -Targets $rtInput -TargetDimension 'SubscriptionId' + $rt = ConvertTo-AllocationPercentage -Targets $rtInput if ($rt.Ok) { $ruleTargets = @($rt.Values | ForEach-Object { [PSCustomObject]@{ subscriptionId = $_.name; percentage = $_.percentage } }) $notes += 'RuleTargets is ready to paste straight into set_cost_allocation_rule (targets); percentages already sum to 100.' diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 index 32678ddb4..66178a7db 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -39,7 +39,9 @@ function Get-VmSizeCapability { if (-not $Cache.ContainsKey($Location)) { $map = @{} try { - $next = "/subscriptions/$SubId/providers/Microsoft.Compute/skus?api-version=2021-07-01&`$filter=location eq '$Location'" + # OData escapes a single quote by doubling it. + $safeLocation = $Location.Replace("'", "''") + $next = "/subscriptions/$SubId/providers/Microsoft.Compute/skus?api-version=2021-07-01&`$filter=location eq '$safeLocation'" $pages = 0 while ($next -and $pages -lt 6) { $resp = Invoke-AzRestMethodWithRetry -Path $next -Method GET diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 index 53e9e2652..8c58adea0 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 @@ -125,7 +125,7 @@ function Get-TelemetryWeighting { $dimName = if ($DimensionName) { $DimensionName } elseif ($def) { $def.DefaultDimension } else { '' } $kql = if ($Query) { $Query } else { $def.Query } - $kql = $kql.Replace('{lb}', "$lb").Replace('{dim}', $dimName) + $kql = $kql.Replace('{lb}', "$lb").Replace('{dim}', (ConvertTo-KqlLiteral $dimName)) $consumerDim = if ($def) { $def.ConsumerDimension } else { 'Custom' } $source = if ($def) { $def.Source } else { 'Custom query (Log Analytics)' } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 index c1876bcab..24c1c708d 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 @@ -247,6 +247,18 @@ function Get-GuidFromString { if ($m.Success) { return $m.Value } else { return $null } } +# Export amounts are always invariant-culture. Parsing them under the operator's +# culture reads "123.45" as 12345 wherever '.' is the thousands separator. +function ConvertTo-ExportAmount { + param([string]$Value) + $parsed = 0.0 + $styles = [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands + if ([double]::TryParse($Value, $styles, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)) { + return $parsed + } + return 0.0 +} + # -- Canonical export column resolver ------------------------------------- # Cost Management exports vary in schema (classic ActualCost vs FOCUS). Map # the columns we need to whatever synonym the export actually used. @@ -661,7 +673,7 @@ function ConvertTo-CostDataFromExport { $g = Get-GuidFromString -Value $rawSub if (-not $g) { continue } $key = if ($guidToKey.ContainsKey($g.ToLower())) { $guidToKey[$g.ToLower()] } else { $g } - $cost = 0.0; [double]::TryParse("$($r.$($cm.Cost))", [ref]$cost) | Out-Null + $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" if (-not $costMap.ContainsKey($key)) { $costMap[$key] = @{ Actual = 0; Forecast = 0; Currency = $ExportData.Currency } } @@ -697,7 +709,7 @@ function ConvertTo-ResourceCostsFromExport { foreach ($r in $ExportData.Rows) { $rid = if ($cm.ResourceId) { "$($r.$($cm.ResourceId))".Trim() } else { '' } if (-not $rid) { continue } - $cost = 0.0; [double]::TryParse("$($r.$($cm.Cost))", [ref]$cost) | Out-Null + $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" $key = $rid.ToLower() if (-not $agg.ContainsKey($key)) { $subId = '' @@ -757,7 +769,7 @@ function ConvertTo-CostByTagFromExport { foreach ($r in $ExportData.Rows) { $raw = "$($r.$($cm.Tags))" if ([string]::IsNullOrWhiteSpace($raw)) { continue } - $cost = 0.0; [double]::TryParse("$($r.$($cm.Cost))", [ref]$cost) | Out-Null + $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" if ($cost -eq 0) { continue } $tags = ConvertFrom-ExportTagString -Raw $raw foreach ($tk in $tags.Keys) { @@ -806,7 +818,7 @@ function ConvertTo-CostTrendFromExport { foreach ($r in $ExportData.Rows) { $dt = $null try { $dt = [datetime]"$($r.$($cm.Date))" } catch { continue } - $cost = 0.0; [double]::TryParse("$($r.$($cm.Cost))", [ref]$cost) | Out-Null + $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" $firstOfMo = Get-Date -Year $dt.Year -Month $dt.Month -Day 1 -Hour 0 -Minute 0 -Second 0 $key = $dt.ToString('yyyy-MM') diff --git a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 new file mode 100644 index 000000000..3f8cda382 --- /dev/null +++ b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 @@ -0,0 +1,142 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'FinOps Multitool safety' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:ModuleRoot = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool' + $script:MultitoolModule = Join-Path $script:ModuleRoot 'FinOpsMultitool.psm1' + Import-Module $script:MultitoolModule -Force + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Every command the module calls actually resolves' { + + # A cleanup commit once deleted a helper and left its caller behind, so + # the scan threw CommandNotFoundException on its normal success path. + It 'Has no calls to undefined internal commands' { + $files = Get-ChildItem -Path $script:ModuleRoot -Recurse -Include *.ps1, *.psm1 -File + + # Sibling private functions live one level up and are legitimate callees. + $defFiles = Get-ChildItem -Path (Join-Path $script:ModuleRoot '..') -Recurse -Include *.ps1, *.psm1 -File + + $defined = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + $optional = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + $called = @{} + + foreach ($file in $defFiles) { + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $file.FullName, [ref]$null, [ref]$null) + foreach ($fn in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) { + [void]$defined.Add($fn.Name) + } + } + + foreach ($file in $files) { + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $file.FullName, [ref]$null, [ref]$null) + + foreach ($cmd in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)) { + $name = $cmd.GetCommandName() + if (-not $name) { continue } + + # A name probed through Get-Command is an optional callback, + # so its absence is deliberate rather than a broken call. + if ($name -eq 'Get-Command') { + foreach ($el in $cmd.CommandElements) { + if ($el -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + [void]$optional.Add($el.Value) + } + } + continue + } + + if (-not $called.ContainsKey($name)) { + $called[$name] = '{0}:{1}' -f $file.Name, $cmd.Extent.StartLineNumber + } + } + } + + # Az cmdlets are excluded so the assertion does not depend on which + # Az modules happen to be installed on the runner. + $unresolved = foreach ($name in $called.Keys) { + if ($defined.Contains($name)) { continue } + if ($optional.Contains($name)) { continue } + if ($name -match '^(Az|AzureRm)' -or $name -match '-Az') { continue } + if (Get-Command -Name $name -ErrorAction SilentlyContinue) { continue } + '{0} (called at {1})' -f $name, $called[$name] + } + + @($unresolved) -join "`n" | Should -BeNullOrEmpty + } + } + + Context 'Allocation percentages' { + + It 'Normalizes uneven shares to exactly 100' { + $r = ConvertTo-AllocationPercentage -Targets @( + @{ subscriptionId = 'a'; allocatedShared = 33.333 } + @{ subscriptionId = 'b'; allocatedShared = 33.333 } + @{ subscriptionId = 'c'; allocatedShared = 33.334 } + ) + + $r.Ok | Should -BeTrue + ($r.Values | ForEach-Object { $_.percentage } | Measure-Object -Sum).Sum | Should -Be 100 + } + + It 'Reports failure instead of inventing percentages' { + (ConvertTo-AllocationPercentage -Targets @()).Ok | Should -BeFalse + (ConvertTo-AllocationPercentage -Targets @( + @{ subscriptionId = 'a'; allocatedShared = 0 } + )).Ok | Should -BeFalse + } + } + + Context 'Export amounts parse invariantly' { + + It 'Reads a decimal point as a decimal point regardless of culture' { + $original = [System.Threading.Thread]::CurrentThread.CurrentCulture + try { + # In de-DE '.' is the thousands separator, so a culture-sensitive + # parse turns 123.45 into 12345. + [System.Threading.Thread]::CurrentThread.CurrentCulture = + [System.Globalization.CultureInfo]::new('de-DE') + + ConvertTo-ExportAmount '123.45' | Should -Be 123.45 + ConvertTo-ExportAmount '1,234.56' | Should -Be 1234.56 + } + finally { + [System.Threading.Thread]::CurrentThread.CurrentCulture = $original + } + } + + It 'Returns zero for values that are not numbers' { + ConvertTo-ExportAmount 'not-a-number' | Should -Be 0 + ConvertTo-ExportAmount '' | Should -Be 0 + } + } + + Context 'KQL literal escaping' { + + It 'Leaves no quote that could terminate the enclosing literal' { + $escaped = ConvertTo-KqlLiteral "Subscription Id']) | extend injected = true //" + [regex]::Matches($escaped, "(? Date: Tue, 15 Sep 2026 22:44:09 -0600 Subject: [PATCH 126/142] FinOps Multitool Update - Sep 15 Review findings - Fix null comparison ordering, $matches shadowing an automatic variable, and four variables that were assigned but never read. - Rename Parse-* helpers to approved verbs. Two were both named Parse-CostRows with different signatures and collided in module scope. - Suppress console output, helper shape, and signature parity rules with justifications, matching the existing pattern in the repository. - Test.PowerShell.Lint now passes 7144 of 7144. --- .../FinOpsMultitool/Invoke-FinOpsMultitool.ps1 | 8 ++++++-- .../modules/Get-AHBOpportunities.ps1 | 13 ++++++++++--- .../modules/Get-AIWorkloadMetrics.ps1 | 4 ++++ .../modules/Get-AhbVmSavingsRatio.ps1 | 4 ++++ .../FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 | 4 ++++ .../FinOpsMultitool/modules/Get-BillingAccount.ps1 | 3 +++ .../modules/Get-BillingStructure.ps1 | 3 +++ .../FinOpsMultitool/modules/Get-BudgetStatus.ps1 | 4 ++++ .../FinOpsMultitool/modules/Get-CarbonMetrics.ps1 | 4 ++++ .../modules/Get-CommitmentUtilization.ps1 | 3 +++ .../FinOpsMultitool/modules/Get-ContractInfo.ps1 | 4 ++++ .../FinOpsMultitool/modules/Get-CostByTag.ps1 | 13 +++++++++---- .../FinOpsMultitool/modules/Get-CostData.ps1 | 4 ++++ .../FinOpsMultitool/modules/Get-CostTrend.ps1 | 14 +++++++++----- .../FinOpsMultitool/modules/Get-IdleVMs.ps1 | 10 +++++++--- .../modules/Get-LegacyResources.ps1 | 4 ++++ .../FinOpsMultitool/modules/Get-MaccCommitment.ps1 | 3 +++ .../modules/Get-OptimizationAdvice.ps1 | 3 +++ .../modules/Get-OrphanedResources.ps1 | 4 ++++ .../modules/Get-PolicyInventory.ps1 | 5 +++++ .../modules/Get-PolicyRecommendations.ps1 | 3 +++ .../modules/Get-ReservationAdvice.ps1 | 3 +++ .../FinOpsMultitool/modules/Get-ResourceCosts.ps1 | 9 +++++---- .../modules/Get-SavingsRealized.ps1 | 4 ++++ .../modules/Get-SharedCostAllocation.ps1 | 5 +++++ .../modules/Get-StorageTierAdvice.ps1 | 3 +++ .../FinOpsMultitool/modules/Get-TagInventory.ps1 | 3 +++ .../modules/Get-TagRecommendations.ps1 | 3 +++ .../FinOpsMultitool/modules/Get-UnitEconomics.ps1 | 4 ++++ .../modules/Get-UsageProportionalAllocation.ps1 | 4 ++++ .../modules/Get-VmCostBreakdown.ps1 | 4 ++++ .../modules/helpers/Get-CostExport.ps1 | 8 ++++++-- .../modules/helpers/Get-CostQueryResponsePage.ps1 | 3 +++ .../modules/helpers/Get-FOHubProvider.ps1 | 4 ++++ .../modules/helpers/Get-KpiInsights.ps1 | 10 +++++++--- .../helpers/Invoke-AzRestMethodWithRetry.ps1 | 5 ++++- .../modules/helpers/Invoke-FOHubKustoQuery.ps1 | 3 +++ .../modules/helpers/MgCostScope.ps1 | 4 ++++ .../modules/helpers/Read-FinOpsHubData.ps1 | 7 +++++++ .../modules/helpers/Resolve-CostDataSource.ps1 | 4 ++++ .../modules/helpers/Resolve-CurrencyLabel.ps1 | 3 +++ .../modules/helpers/Search-AzGraphSafe.ps1 | 3 +++ 42 files changed, 186 insertions(+), 27 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 764bd627f..3c2ecace4 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1,6 +1,12 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; callers pass these uniformly across the scan family.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Justification = 'Scan results are published to the caller session by design.')] +param() + ########################################################################### # INVOKE-FINOPSMULTITOOL.PS1 # INTERACTIVE TERMINAL LAUNCHER FOR FINOPS MULTITOOL @@ -735,7 +741,6 @@ function Invoke-FinOpsMultitool { # Build display lines grouped by category $lines = @() $lineToIndex = @{} # map display line -> module index - $moduleIdx = 0 foreach ($cat in $categories) { $lines += " ── $cat ──" @@ -1769,7 +1774,6 @@ function Invoke-FinOpsMultitool { Write-Host " Compliance: $($data.CompliancePct)%" -ForegroundColor White } 'Get-BudgetStatus' { - $bSumColor = if ($data.OverBudgetCount -gt 0) { 'Red' } elseif ($data.AtRiskCount -gt 0) { 'Yellow' } else { 'Green' } Write-Host " Budgets: $($data.TotalBudgets) | " -ForegroundColor White -NoNewline Write-Host "At risk: $($data.AtRiskCount)" -ForegroundColor $(if ($data.AtRiskCount -gt 0) { 'Yellow' } else { 'Green' }) -NoNewline Write-Host " | " -ForegroundColor White -NoNewline diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 index 33130124a..0612dca8e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # GET-AHBOPPORTUNITIES.PS1 # AZURE FINOPS MULTITOOL - Azure Hybrid Benefit Gap Detection @@ -43,7 +47,8 @@ resources "@ $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 -All $windowsVMs = if ($result) { @($result.Data) } else { @() } - } catch { + } + catch { Write-Warning "Windows VM AHB scan failed: $($_.Exception.Message)" } @@ -72,7 +77,8 @@ resources "@ $result = Search-AzGraphSafe -Query $sqlVMQuery -Subscription $subIds -First 1000 -All $sqlVMs = if ($result) { @($result.Data) } else { @() } - } catch { + } + catch { Write-Warning "SQL VM AHB scan failed: $($_.Exception.Message)" } @@ -93,7 +99,8 @@ resources "@ $result = Search-AzGraphSafe -Query $sqlDBQuery -Subscription $subIds -First 1000 -All $sqlDBs = if ($result) { @($result.Data) } else { @() } - } catch { + } + catch { Write-Warning "SQL Database AHB scan failed: $($_.Exception.Message)" } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 index 0737ec310..f09113edd 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # 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 8e185eb1c..6253e8f52 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # 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 df4915a94..077e0df3f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # 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 2af799d0c..86b5344c0 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + ########################################################################### # GET-BILLINGACCOUNT.PS1 # DISCOVER BILLING ACCOUNTS + COST ALLOCATION ELIGIBILITY diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 index 89a8ee19b..b99769dfe 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + ########################################################################### # 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 c2677d782..0a3d20663 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +param() + ########################################################################### # 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 e0bc20471..43b5e1c01 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # 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 e23d2899d..1f0c3f472 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + ########################################################################### # 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 db7177a5b..b73f77bfa 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +param() + ########################################################################### # 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 2c7751087..3ebac26b8 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 @@ -1,6 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; the dispatcher passes -TenantId to every scan module.')] +param() + ########################################################################### # GET-COSTBYTAG.PS1 # AZURE FINOPS MULTITOOL - Cost Breakdown by Tag @@ -175,7 +180,7 @@ function Get-CostByTag { # Helper: parse a ResourceId-grouped Cost Management response into rows of # @{ ResourceId; Cost; Currency }. Rows with an empty ResourceId represent # non-resource charges (reservations, marketplace, refunds/credits). - function Parse-ResourceIdRows { + function ConvertFrom-ResourceIdRow { param($ResponseContent) $parsed = [System.Collections.Generic.List[PSCustomObject]]::new() $result = ($ResponseContent | ConvertFrom-Json) @@ -204,7 +209,7 @@ function Get-CostByTag { } # Helper: parse Cost Management query response using column headers - function Parse-CostRows { + function ConvertFrom-TagCostRow { param($ResponseContent) $parsed = [System.Collections.Generic.List[PSCustomObject]]::new() $result = ($ResponseContent | ConvertFrom-Json) @@ -241,7 +246,7 @@ function Get-CostByTag { } # Helper: parse batched TagKey+TagValue response into per-tag results - function Parse-BatchedCostRows { + function ConvertFrom-BatchedCostRow { param($ResponseContent) $perTag = @{} $result = ($ResponseContent | ConvertFrom-Json) @@ -444,7 +449,7 @@ function Get-CostByTag { # Follow nextLink: one page only would understate a large subscription. $rows = @() foreach ($page in (Get-CostQueryResponsePage -FirstResponse $subResp -Context "cost-by-tag for $($pj.SubName)")) { - $rows += Parse-ResourceIdRows -ResponseContent $page.Content + $rows += ConvertFrom-ResourceIdRow -ResponseContent $page.Content } foreach ($row in $rows) { $cost = $row.Cost diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 index ddeb58406..ea33ff6d3 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +param() + ########################################################################### # 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 ca3629c29..8bcad7ada 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Read-only: builds in-memory objects and changes no state.')] +param() + ########################################################################### # GET-COSTTREND.PS1 # AZURE FINOPS MULTITOOL - 6-Month Cost Trend Data @@ -76,7 +80,7 @@ function Get-CostTrend { } | ConvertTo-Json -Depth 10 # Helper: parse cost query rows into month entries - function Parse-CostRows { + function ConvertFrom-TrendCostRow { param($Rows, $Columns) $entries = [System.Collections.Generic.List[PSCustomObject]]::new() if (-not $Rows) { return $entries } @@ -133,7 +137,7 @@ function Get-CostTrend { try { # Parse a SubscriptionId-grouped Monthly response into per-sub entries. - function Parse-GroupedCostRows { + function ConvertFrom-GroupedCostRow { param($Rows, $Columns) $out = [System.Collections.Generic.List[PSCustomObject]]::new() if (-not $Rows) { return $out } @@ -220,7 +224,7 @@ function Get-CostTrend { if ($subResp.StatusCode -eq 200) { $paged = Get-AllCostRow -FirstResponse $subResp -Context "cost trend for $($only.Name)" if ($paged.Rows.Count -gt 0) { - $months = Parse-CostRows -Rows $paged.Rows -Columns $paged.Columns + $months = ConvertFrom-TrendCostRow -Rows $paged.Rows -Columns $paged.Columns $bySubscription[$only.Id] = @($months | Sort-Object MonthDate) } } else { @@ -244,7 +248,7 @@ function Get-CostTrend { if ($response.StatusCode -eq 200) { $paged = Get-AllCostRow -FirstResponse $response -Context 'management-group cost trend' if ($paged.Rows.Count -gt 0) { - $entries = Parse-GroupedCostRows -Rows $paged.Rows -Columns $paged.Columns + $entries = ConvertFrom-GroupedCostRow -Rows $paged.Rows -Columns $paged.Columns Set-TrendFromGrouped -Entries $entries $groupedOk = ($months.Count -gt 0) } @@ -276,7 +280,7 @@ function Get-CostTrend { if ($subResp.StatusCode -eq 200) { $paged = Get-AllCostRow -FirstResponse $subResp -Context "cost trend for $($sub.Name)" if ($paged.Rows.Count -gt 0) { - $subMonths = Parse-CostRows -Rows $paged.Rows -Columns $paged.Columns + $subMonths = ConvertFrom-TrendCostRow -Rows $paged.Rows -Columns $paged.Columns $bySubscription[$sub.Id] = @($subMonths | Sort-Object MonthDate) foreach ($sm in $subMonths) { diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 index 1b8aaa685..f3fd3ec2e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # GET-IDLEVMS.PS1 # AZURE FINOPS MULTITOOL - Idle & Underutilized VM Detection @@ -111,7 +115,7 @@ resources foreach ($dp in $ts.data) { switch ($metricName) { 'Percentage CPU' { - if ($dp.average -ne $null) { $avgCpu = $dp.average } + if ($null -ne $dp.average) { $avgCpu = $dp.average } } 'Network In Total' { if ($dp.total) { $totalNetIn += $dp.total } @@ -130,11 +134,11 @@ resources $isIdle = $false $classification = $null - if ($avgCpu -ne $null -and $avgCpu -lt $cpuThreshold -and $totalNetwork -lt $networkThreshold14d) { + if ($null -ne $avgCpu -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 ($null -ne $avgCpu -and $avgCpu -lt 10 -and $totalNetwork -lt ($networkThreshold14d * 10)) { $isIdle = $true $classification = 'Underutilized' } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 index a5623b784..d715a6efc 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # 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 604325344..1468417cc 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + ########################################################################### # 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 b5347b5d7..7151c92b2 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + ########################################################################### # 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 402b02c60..f4ed72324 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # 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 dbf8134a6..a6df04a80 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -1,6 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; the dispatcher passes -TenantId to every scan module.')] +param() + ########################################################################### # 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 3c8e19233..1b2bf221e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # 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 4eabbf052..26ed2d4dc 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + ########################################################################### # 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 9c5299aaa..493f86e9a 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 @@ -1,6 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # GET-RESOURCECOSTS.PS1 # AZURE FINOPS MULTITOOL - Per-Resource Cost Breakdown @@ -135,10 +140,8 @@ function Get-ResourceCosts { $rg = $row[$cols['ResourceGroupName']] $resType = 'Unknown' - $resName = $resourceId if ($resourceId -match '/providers/(.+)/([^/]+)$') { $providerType = $Matches[1].ToLower() - $resName = $Matches[2] $resType = if ($typeMap.ContainsKey($providerType)) { $typeMap[$providerType] } else { $providerType -replace 'microsoft\.', '' } } @@ -266,10 +269,8 @@ function Get-ResourceCosts { # 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\.', '' } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 index 6b5636c07..bd42cd593 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Read-only: builds in-memory objects and changes no state.')] +param() + ########################################################################### # GET-SAVINGSREALIZED.PS1 # AZURE FINOPS MULTITOOL - Estimated Savings from Commitments diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 index 5960e068e..708adc064 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -1,6 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity across the allocation helper family.')] +param() + ########################################################################### # 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 7575085c2..a022bf034 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + ########################################################################### # 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 ab1478897..4b444a69d 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + ########################################################################### # 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 570734faf..d30176e40 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # GET-TAGRECOMMENDATIONS.PS1 # AZURE FINOPS MULTITOOL - Tag Recommendations (MS Best Practices) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 index 66178a7db..85cfcab9d 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # 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 8c58adea0..f5b5d4bcf 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; the dispatcher passes -TenantId to every scan module.')] +param() + ########################################################################### # GET-USAGEPROPORTIONALALLOCATION.PS1 # TELEMETRY-KEYED SHOWBACK FOR SHARED PLATFORMS (AKS / APIM / AOAI) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 index 10caba848..013083604 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; the dispatcher passes -TenantId to every scan module.')] +param() + ########################################################################### # GET-VMCOSTBREAKDOWN.PS1 # AZURE FINOPS MULTITOOL - Full VM Cost Decomposition diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 index 24c1c708d..5ffd3e3db 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 @@ -1,6 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by export schema and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity across the export converter family.')] +param() + ########################################################################### # GET-COSTEXPORT.PS1 # COST MANAGEMENT EXPORT DETECTION & FAST READ @@ -555,7 +560,6 @@ function Get-CostExportData { $blobs = [System.Collections.Generic.List[PSCustomObject]]::new() $csvBlobs = @() - $usedPrefix = $null $anyListed = $false $seen = @{} foreach ($prefix in $candidates) { @@ -567,7 +571,7 @@ function Get-CostExportData { $blobs = $listed.Blobs $csvBlobs = @($blobs | Where-Object { $_.Name -match '\.csv(\.gz)?$' }) - if ($csvBlobs.Count -gt 0) { $usedPrefix = $prefix; break } + if ($csvBlobs.Count -gt 0) { break } } if (-not $anyListed) { diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 index d424c48ee..648e4fcbf 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +param() + ########################################################################### # GET-COSTQUERYRESPONSEPAGE.PS1 # COST MANAGEMENT QUERY PAGINATION diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 index 9b69ded0b..302df3ef6 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + ########################################################################### # GET-FOHUBPROVIDER.PS1 # FINOPS HUB - SCALABLE DATA PROVIDER (KUSTO-FIRST) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index 87b69fccd..2e2139d08 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; callers pass -Catalog across the KPI helper family.')] +param() + ########################################################################### # GET-KPIINSIGHTS.PS1 # FINOPS KPI CORRELATION LAYER @@ -288,13 +292,13 @@ function Add-KpiInsights { elseif ($Result.PSObject.Properties['tool']) { $toolName = $Result.tool } if (-not $toolName) { return $Result } - $matches = @($catalog.kpis | Where-Object { $_.sourceTool -eq $toolName }) - if ($matches.Count -eq 0) { return $Result } + $matched = @($catalog.kpis | Where-Object { $_.sourceTool -eq $toolName }) + if ($matched.Count -eq 0) { return $Result } $data = if ($Result -is [hashtable]) { $Result['data'] } else { $Result.data } $insights = @() - foreach ($kpi in $matches) { + foreach ($kpi in $matched) { $value = $null if ($kpi.compute) { $value = Get-KpiComputedValue -KpiId $kpi.id -Data $data -Catalog $catalog } $status = if ($value) { 'computed' } else { 'informational' } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 index 94ed7ed64..076140194 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + # -- Shared Runspace Pool -------------------------------------------------- # Created once at module load. Reused by Invoke-AzRestMethodWithRetry and # Search-AzGraphSafe to avoid the ~1-2s cold-start per runspace creation. @@ -24,7 +27,7 @@ function Test-WpfLoaded { $dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher return ($null -ne $dispatcher -and -not $dispatcher.HasShutdownStarted -and - [System.Windows.Application]::Current -ne $null) + $null -ne [System.Windows.Application]::Current) } catch { return $false } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 index 241a624d3..1fd4786dd 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by query and is not a declared contract.')] +param() + ########################################################################### # INVOKE-FOHUBKUSTOQUERY.PS1 # FINOPS HUB - KUSTO (ADX / FABRIC / FTKLOCAL) QUERY TRANSPORT diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 index 8dfa81ee9..dff0a0a6b 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Read-only: resolves a scope and changes no state.')] +param() + # -- 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. diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index e36a0ca93..34253f952 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -1,6 +1,13 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by hub schema and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Read-only: reads hub data and changes no state.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity across the hub reader family.')] +param() + ########################################################################### # READ-FINOPSHUBDATA.PS1 # FINOPS HUB STORAGE DATA READER diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 index 29a648ac7..2977610d0 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by source and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; the dispatcher passes -TenantId to every scan module.')] +param() + ########################################################################### # RESOLVE-COSTDATASOURCE.PS1 # COST DATA SOURCE RESOLVER (EXPORT-FIRST ROUTING) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 index df3cbf47e..fe6397a0c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by input and is not a declared contract.')] +param() + ########################################################################### # RESOLVE-CURRENCYLABEL.PS1 # MIXED-CURRENCY DETECTION FOR COST AGGREGATION diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 index 07a8a0ce5..5baad572c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 @@ -1,6 +1,9 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + # 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. From b37d3e91be7b7c6be12d9a0d854ae202a6fa763f Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Tue, 15 Sep 2026 22:59:13 -0600 Subject: [PATCH 127/142] FinOps Multitool Update - Sep 15 Review findings - Use interval=FULL for the Idle VMs and Storage Tier metric queries. P14D and P30D are not published timegrains, so Azure Monitor rejected both with HTTP 400 and the scans returned zero on every tenant. - Count a failed blob capacity read as a metric failure. Every tier recommendation requires a capacity reading, so the previous silent failure suppressed the recommendation instead of reporting the account as unevaluated. --- .../Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 | 4 +++- .../modules/Get-StorageTierAdvice.ps1 | 12 ++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 index f3fd3ec2e..be5237ae9 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 @@ -101,7 +101,9 @@ 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 = "$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" + # FULL is the only way to get one datapoint for the whole span: + # P14D is not a published timegrain and the API rejects it. + $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=FULL" $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop $metricData = ($resp.Content | ConvertFrom-Json) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 index a022bf034..d208754e7 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 @@ -59,7 +59,9 @@ 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 = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Transactions×pan=$thirtyDaysAgo/$nowStr&aggregation=Total&interval=P30D" + # FULL is the only way to get one datapoint for the whole span: + # P30D is not a published timegrain and the API rejects it. + $metricUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Transactions×pan=$thirtyDaysAgo/$nowStr&aggregation=Total&interval=FULL" $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop $metricData = ($resp.Content | ConvertFrom-Json) @@ -72,9 +74,11 @@ resources } } - # Also query used capacity - $capacityUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=BlobCapacity×pan=$thirtyDaysAgo/$nowStr&aggregation=Average&interval=P30D" - $capResp = Invoke-WebRequest -Uri $capacityUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction SilentlyContinue + # Also query used capacity. Every recommendation below requires a + # capacity reading, so a silent failure here would suppress the + # recommendation instead of reporting the account as unevaluated. + $capacityUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=BlobCapacity×pan=$thirtyDaysAgo/$nowStr&aggregation=Average&interval=FULL" + $capResp = Invoke-WebRequest -Uri $capacityUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop $capacityBytes = 0 if ($capResp) { $capData = ($capResp.Content | ConvertFrom-Json) From 8b1d68c2c6fb01b5198ae278eb3e9e75605a859a Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 16 Sep 2026 00:11:10 -0600 Subject: [PATCH 128/142] FinOps Multitool Update - Sep 16 Review findings - Fail closed when an explicitly requested subscription cannot be resolved and the host cannot prompt. The unanswered picker read a blank answer as "scan everything", so an automated run with a stale -SubscriptionId exported every subscription in the tenant. - URI-encode the OData location value. Doubling the quotes alone left a percent-encoded quote to decode back into a literal one and rewrite the filter predicate. --- .../Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 | 7 +++++++ .../Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 3c2ecace4..d39afad49 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -499,6 +499,13 @@ function Invoke-FinOpsMultitool { Write-Host "" return @($sub) } + # Widening an explicit request to every accessible subscription would + # put other tenants' cost data in a report scoped to one of them. A host + # that cannot prompt has to fail the same way -NonInteractive does, + # because an unanswered prompt further down reads as "scan everything". + if ($NonInteractive -or -not (Test-FinOpsRichConsole)) { + throw "Subscription '$PreselectedId' could not be resolved in any accessible tenant. Refusing to widen the scan to all subscriptions." + } Write-Host " Subscription $PreselectedId not found in any accessible tenant, showing picker..." -ForegroundColor Yellow } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 index 85cfcab9d..a0ec17df0 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -43,8 +43,9 @@ function Get-VmSizeCapability { if (-not $Cache.ContainsKey($Location)) { $map = @{} try { - # OData escapes a single quote by doubling it. - $safeLocation = $Location.Replace("'", "''") + # Double the quotes for OData, then encode so a percent-encoded quote + # in the input cannot decode back into a literal one. + $safeLocation = [uri]::EscapeDataString($Location.Replace("'", "''")) $next = "/subscriptions/$SubId/providers/Microsoft.Compute/skus?api-version=2021-07-01&`$filter=location eq '$safeLocation'" $pages = 0 while ($next -and $pages -lt 6) { From d3c99d18878770142474e852ec5b1333300a4183 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 16 Sep 2026 00:19:00 -0600 Subject: [PATCH 129/142] FinOps Multitool Update - Sep 16 Review findings - Prefix exported cells that open with =, +, - , @, tab, or CR so a resource name or tag cannot execute as a spreadsheet formula. Numbers are left alone so a negative cost stays a number. - Parse hub subscription ids as GUIDs and fail on an unparseable one. The character-class check passed 36 hyphens, and an all-invalid list dropped the scope filter and returned every subscription in the hub. - Cap gzip expansion when reading export parts, so a highly compressed blob in the container cannot exhaust memory. - Flag commitment figures that came from the tenant-wide reservation order endpoint, which cannot be filtered to the scanned subscriptions. --- .../Invoke-FinOpsMultitool.ps1 | 20 ++++++-- .../modules/Get-CommitmentUtilization.ps1 | 18 ++++++- .../modules/helpers/Get-CostExport.ps1 | 17 +++++-- .../modules/helpers/Get-FOHubProvider.ps1 | 19 +++++-- .../Tests/Unit/MultitoolSafety.Tests.ps1 | 49 +++++++++++++++++++ 5 files changed, 109 insertions(+), 14 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index d39afad49..b70afda99 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1288,20 +1288,32 @@ function Invoke-FinOpsMultitool { } } + # A cell opening with =, +, -, @, tab, or CR is treated as a formula by + # spreadsheet apps, and resource names, tags, and policy display names are + # all controlled by whoever created the resource. + function Protect-FinOpsExportText { + param([string]$Text) + if ($Text -match '^[=+\-@\t\r]') { return "'" + $Text } + return $Text + } + # 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 } + # Numbers, booleans, and dates carry no formula risk, and prefixing one + # would stop a negative cost being read as a number. + if ($Value -is [ValueType]) { return $Value } + if ($Value -is [string]) { return Protect-FinOpsExportText $Value } if ($Value -is [System.Collections.IDictionary]) { - return (($Value.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; ') + return Protect-FinOpsExportText ((($Value.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; ')) } if ($Value -is [System.Collections.IEnumerable]) { - return ((@($Value) | ForEach-Object { [string]$_ }) -join '; ') + return Protect-FinOpsExportText (((@($Value) | ForEach-Object { [string]$_ }) -join '; ')) } - return [string]$Value + return Protect-FinOpsExportText ([string]$Value) } # Scan results are wrapper objects whose payload is a nested collection or a diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 index 1f0c3f472..b219776f4 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 @@ -153,6 +153,10 @@ function Get-CommitmentUtilization { $reservations += @($latestReservation.Values) # -- Step 2: Try the Reservation Orders API at billing scope -- + # This endpoint is tenant-wide and cannot be filtered to the requested + # subscriptions, so anything it returns is flagged as unscoped rather than + # presented as belonging to the scan scope. + $unscopedFallback = $false if ($reservations.Count -eq 0) { try { $roPath = "/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01" @@ -202,6 +206,12 @@ function Get-CommitmentUtilization { if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } Write-Warning " Reservation orders query failed: $($_.Exception.Message)" } + # This block only runs when the scoped queries returned nothing, so + # anything present now came from the tenant-wide endpoint. + if ($reservations.Count -gt 0) { + $unscopedFallback = $true + Write-Warning " Commitments were read from every reservation order this account can see; they are not limited to the scanned subscriptions." + } } # -- Step 3: Savings Plans utilization via Benefit Utilization Summaries -- @@ -301,7 +311,13 @@ function Get-CommitmentUtilization { HasData = ($riCount -gt 0 -or $spCount -gt 0) AccessDenied = $denied ScopesQueried = @($commitmentScopes).Count - Note = $note + # True when the figures came from the tenant-wide reservation order + # endpoint, which cannot be filtered to the scanned subscriptions. + UnscopedFallback = $unscopedFallback + Note = if ($unscopedFallback) { + 'No commitments were readable at the resolved billing scopes, so these figures come from every reservation order this account can see and are not limited to the scanned subscriptions.' + } + else { $note } # Reservations excluded because their utilization could not be read. UtilizationFailures = $utilFailures.Count UtilizationFailureDetail = @($utilFailures) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 index 5ffd3e3db..318c8be39 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 @@ -216,20 +216,27 @@ function Get-StorageContainerList { # Newer Cost Management exports can write '.csv.gz' parts. Invoke-RestMethod # hands these back as bytes (or a mojibake string); gunzip into UTF-8 text. function Expand-GzipText { - param($Content) + param($Content, [long]$MaxBytes = 512MB) $inStream = $null; $gzip = $null; $outStream = $null try { $bytes = if ($Content -is [byte[]]) { $Content } elseif ($Content -is [string]) { [System.Text.Encoding]::GetEncoding('ISO-8859-1').GetBytes($Content) } elseif ($Content -is [System.Collections.IEnumerable]) { [byte[]]@($Content) } else { return $null } - # CopyTo a buffer (rather than StreamReader.ReadToEnd) because the - # latter can return empty on large GZipStreams, and CopyTo also reads - # all members of a concatenated/multi-member gzip. + # Read in bounded chunks (rather than StreamReader.ReadToEnd) because the + # latter can return empty on large GZipStreams, and this also reads all + # members of a concatenated/multi-member gzip. The ceiling stops a + # highly compressed blob in the container from exhausting memory. $inStream = New-Object System.IO.MemoryStream(, $bytes) $gzip = New-Object System.IO.Compression.GZipStream($inStream, [System.IO.Compression.CompressionMode]::Decompress) $outStream = New-Object System.IO.MemoryStream - $gzip.CopyTo($outStream) + $buffer = [byte[]]::new(81920) + while (($read = $gzip.Read($buffer, 0, $buffer.Length)) -gt 0) { + if (($outStream.Length + $read) -gt $MaxBytes) { + throw "Decompressed export part exceeded the $MaxBytes byte ceiling." + } + $outStream.Write($buffer, 0, $read) + } $outBytes = $outStream.ToArray() return [System.Text.Encoding]::UTF8.GetString($outBytes) } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 index 302df3ef6..9a04f12e7 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 @@ -75,11 +75,22 @@ function Get-FOHubWindowClause { function Get-FOHubScopeClause { # Restrict to specific subscriptions (FOCUS SubAccountId is - # /subscriptions/{guid}). Only valid GUIDs are injected (no KQL injection). + # /subscriptions/{guid}). Parsing every id keeps the interpolation to hex + # and hyphens, and an unparseable one fails rather than dropping the filter + # and silently returning every subscription in the hub. param([string[]]$SubscriptionIds) - $guids = @($SubscriptionIds | Where-Object { $_ -match '^[0-9a-fA-F-]{36}$' } | ForEach-Object { $_.ToLower() }) - if ($guids.Count -eq 0) { return '' } - $arr = ($guids | ForEach-Object { '"' + $_ + '"' }) -join ', ' + + if (-not $SubscriptionIds -or @($SubscriptionIds).Count -eq 0) { return '' } + + $guids = foreach ($id in $SubscriptionIds) { + $parsed = [guid]::Empty + if (-not [guid]::TryParse($id, [ref]$parsed)) { + throw "'$id' is not a subscription GUID. Refusing to drop the scope filter." + } + $parsed.ToString() + } + + $arr = (@($guids) | ForEach-Object { '"' + $_ + '"' }) -join ', ' return "| where SubAccountId has_any (dynamic([$arr]))" } diff --git a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 index 3f8cda382..90d79722b 100644 --- a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 +++ b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 @@ -139,4 +139,53 @@ Describe 'FinOps Multitool safety' { $escaped | Should -BeExactly "abc\\\' or 1==1 //" } } + + Context 'Hub subscription scope clause' { + + It 'Returns no clause only when no subscription was requested' { + Get-FOHubScopeClause -SubscriptionIds @() | Should -BeNullOrEmpty + } + + It 'Scopes the query to the requested subscriptions' { + $clause = Get-FOHubScopeClause -SubscriptionIds @('00000000-0000-0000-0000-00000000000a') + $clause | Should -Match 'SubAccountId has_any' + $clause | Should -Match '00000000-0000-0000-0000-00000000000a' + } + + It 'Fails rather than dropping the filter for an unparseable id' { + # The old character-class check passed 36 hyphens, then an empty + # clause returned every subscription in the hub. + { Get-FOHubScopeClause -SubscriptionIds @('------------------------------------') } | + Should -Throw -ExpectedMessage '*not a subscription GUID*' + { Get-FOHubScopeClause -SubscriptionIds @('/subscriptions/00000000-0000-0000-0000-00000000000a') } | + Should -Throw + } + } + + Context 'Gzip expansion is bounded' { + + It 'Round-trips content that fits the budget' { + $text = 'hello world' + $raw = [System.Text.Encoding]::UTF8.GetBytes($text) + $ms = [System.IO.MemoryStream]::new() + $gz = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionMode]::Compress) + $gz.Write($raw, 0, $raw.Length) + $gz.Dispose() + + Expand-GzipText -Content $ms.ToArray() | Should -Be $text + } + + It 'Refuses to expand past the byte ceiling' { + # Highly compressible input stands in for a hostile blob in the + # export container. + $raw = [byte[]]::new(1MB) + $ms = [System.IO.MemoryStream]::new() + $gz = [System.IO.Compression.GZipStream]::new($ms, [System.IO.Compression.CompressionMode]::Compress) + $gz.Write($raw, 0, $raw.Length) + $gz.Dispose() + + Expand-GzipText -Content $ms.ToArray() -MaxBytes 1024 -WarningAction SilentlyContinue | + Should -BeNullOrEmpty + } + } } From 1fe0cdaa82de28a4e6dedab1a253fc826d782070 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 16 Sep 2026 00:28:25 -0600 Subject: [PATCH 130/142] Documentation update - Document that -SubscriptionId returns an error when the subscription cannot be resolved and nothing can answer a prompt, rather than scanning every subscription. - Refresh ms.date on the six pages this pull request changes. The update workflow is skipped for fork pull requests. --- docs-mslearn/toolkit/changelog.md | 2 +- docs-mslearn/toolkit/finops-toolkit-overview.md | 2 +- .../multitool/finops-multitool-overview.md | 2 +- .../multitool/finops-multitool-commands.md | 2 +- .../multitool/start-finopsmultitool.md | 16 ++++++++-------- .../toolkit/powershell/powershell-commands.md | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 13e5bf463..9e618fa2a 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: 09/09/2026 +ms.date: 09/16/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit diff --git a/docs-mslearn/toolkit/finops-toolkit-overview.md b/docs-mslearn/toolkit/finops-toolkit-overview.md index b53196d5d..9e4df0a92 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: 08/21/2026 +ms.date: 09/16/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit diff --git a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md index de688bd4d..bae6b577b 100644 --- a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -3,7 +3,7 @@ title: FinOps multitool overview 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: 09/15/2026 +ms.date: 09/16/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit diff --git a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md index d8c0cf34d..68402cc1a 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: 09/15/2026 +ms.date: 09/16/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md index 7f8c04ca9..29a2a4ae0 100644 --- a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -3,7 +3,7 @@ title: Start-FinOpsMultitool command 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: 09/14/2026 +ms.date: 09/16/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -39,13 +39,13 @@ Start-FinOpsMultitool ` ## Parameters -| Name | Description | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `‑SubscriptionId` | Optional. Scopes the scan to a single subscription. When omitted, all accessible subscriptions are discovered. | -| `‑OutputPath` | Optional. Directory for exported result files. Defaults to a `FinOpsResults` folder in your home directory. | -| `‑Scans` | Optional. Runs the specified scans instead of the default selection. Accepts a scan command name, such as `Get-OrphanedResources`, or its menu label, such as `Orphaned Resources`. Use `All` to select every scan. An unrecognized name returns an error. | -| `‑DataSource` | Optional. Sets the data source and skips the data source prompt. Valid values are `Hub`, `API`, and `GraphOnly`. `Hub` falls back to `API` when no FinOps hub is found in scope. | -| `‑NonInteractive` | Optional. Runs without prompting. Every choice comes from the parameters or their defaults, and results are exported only when you set `-OutputPath`. | +| Name | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `‑SubscriptionId` | Optional. Scopes the scan to a single subscription. When omitted, all accessible subscriptions are discovered. If the subscription can't be resolved and nothing can answer a prompt, the command returns an error rather than scanning every subscription. | +| `‑OutputPath` | Optional. Directory for exported result files. Defaults to a `FinOpsResults` folder in your home directory. | +| `‑Scans` | Optional. Runs the specified scans instead of the default selection. Accepts a scan command name, such as `Get-OrphanedResources`, or its menu label, such as `Orphaned Resources`. Use `All` to select every scan. An unrecognized name returns an error. | +| `‑DataSource` | Optional. Sets the data source and skips the data source prompt. Valid values are `Hub`, `API`, and `GraphOnly`. `Hub` falls back to `API` when no FinOps hub is found in scope. | +| `‑NonInteractive` | Optional. Runs without prompting. Every choice comes from the parameters or their defaults, and results are exported only when you set `-OutputPath`. |
diff --git a/docs-mslearn/toolkit/powershell/powershell-commands.md b/docs-mslearn/toolkit/powershell/powershell-commands.md index 7c4436012..853841263 100644 --- a/docs-mslearn/toolkit/powershell/powershell-commands.md +++ b/docs-mslearn/toolkit/powershell/powershell-commands.md @@ -3,7 +3,7 @@ title: FinOps toolkit PowerShell module description: Automate and scale your FinOps efforts using the FinOps toolkit PowerShell module, which includes commands to manage FinOps solutions. author: flanakin ms.author: micflan -ms.date: 08/21/2026 +ms.date: 09/16/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit From 9fd8a4cbac9340e1da2af82895523cad51a009a0 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Wed, 16 Sep 2026 21:00:27 -0600 Subject: [PATCH 131/142] FinOps Multitool Update - Sep 16 Review findings - Correct full-month forecast windows, column matching, and row totals. - Flag actual-only forecast fallbacks. - Keep measured zero utilization without counting absent commitments. - Restrict export totals to selected subscriptions. --- .../FinOpsMultitool/modules/Get-CostData.ps1 | 72 ++++--- .../modules/helpers/Get-CostExport.ps1 | 8 +- .../modules/helpers/Get-KpiInsights.ps1 | 23 ++- .../Tests/Unit/MultitoolSafety.Tests.ps1 | 180 ++++++++++++++++++ 4 files changed, 245 insertions(+), 38 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 index ea33ff6d3..e9af3ba1e 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 @@ -19,6 +19,16 @@ param() # Reference: https://learn.microsoft.com/en-us/rest/api/cost-management/query/usage ########################################################################### +function Get-CostColumnIndex { + param($Columns, [string[]]$Names) + + if (-not $Columns) { return -1 } + for ($columnIndex = 0; $columnIndex -lt $Columns.Count; $columnIndex++) { + if (([string]$Columns[$columnIndex].name).ToLower() -in $Names) { return $columnIndex } + } + return -1 +} + function Get-CostData { [CmdletBinding()] param( @@ -92,24 +102,18 @@ function Get-CostData { # 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 } - } + $aSubIdx = Get-CostColumnIndex -Columns $aCols -Names @('subscriptionid') + $aCurIdx = Get-CostColumnIndex -Columns $aCols -Names @('currency') + $aCostIdx = Get-CostColumnIndex -Columns $aCols -Names @('cost', 'pretaxcost', 'costusd') + if ($aCostIdx -lt 0 -or $aSubIdx -lt 0) { + throw "Actual cost response did not expose the expected Cost and SubscriptionId columns." } - if ($aCostIdx -eq -1) { $aCostIdx = 0 } - if ($aSubIdx -eq -1) { $aSubIdx = 1 } - if ($aCurIdx -eq -1) { $aCurIdx = 2 } if ($result.properties.rows) { foreach ($row in $result.properties.rows) { $subId = $row[$aSubIdx] $amount = [math]::Round($row[$aCostIdx], 2) - $currency = $row[$aCurIdx] + $currency = if ($aCurIdx -ge 0) { $row[$aCurIdx] } else { 'USD' } if ($selectedSubs -and -not $selectedSubs.Contains([string]$subId)) { continue } @@ -133,14 +137,14 @@ function Get-CostData { $forecastSuccess = $false try { Write-Host " Querying forecast costs (MG scope)..." -ForegroundColor Cyan - $now = Get-Date + $now = (Get-Date).ToUniversalTime() $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) $forecastBody = @{ type = 'Usage' timeframe = 'Custom' timePeriod = @{ - from = $now.ToString('yyyy-MM-dd') + from = $now.AddDays(1 - $now.Day).ToString('yyyy-MM-dd') to = $monthEnd.ToString('yyyy-MM-dd') } dataset = $( @@ -177,16 +181,11 @@ function Get-CostData { # columns differently and adds a CostStatus column, so positional # access can mistake "Forecast"/"Actual" text for a subscription ID. $fCols = $fResult.properties.columns - $fCostIdx = -1; $fSubIdx = -1 - if ($fCols) { - for ($ci = 0; $ci -lt $fCols.Count; $ci++) { - $cn = ([string]$fCols[$ci].name).ToLower() - if ($cn -eq 'subscriptionid') { $fSubIdx = $ci } - elseif ($cn -match 'cost|pretaxcost') { $fCostIdx = $ci } - } + $fSubIdx = Get-CostColumnIndex -Columns $fCols -Names @('subscriptionid') + $fCostIdx = Get-CostColumnIndex -Columns $fCols -Names @('cost', 'pretaxcost', 'costusd') + if ($fCostIdx -lt 0 -or $fSubIdx -lt 0) { + throw "Forecast response did not expose the expected Cost and SubscriptionId columns." } - if ($fCostIdx -eq -1) { $fCostIdx = 0 } - if ($fSubIdx -eq -1) { $fSubIdx = 1 } $forecastSums = @{} foreach ($row in $fResult.properties.rows) { @@ -217,7 +216,7 @@ function Get-CostData { # Per-subscription forecast fallback if (-not $forecastSuccess -and $Subscriptions) { - $now = Get-Date + $now = (Get-Date).ToUniversalTime() $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) $subCount = $Subscriptions.Count $i = 0 @@ -234,7 +233,7 @@ function Get-CostData { type = 'Usage' timeframe = 'Custom' timePeriod = @{ - from = $now.ToString('yyyy-MM-dd') + from = $now.AddDays(1 - $now.Day).ToString('yyyy-MM-dd') to = $monthEnd.ToString('yyyy-MM-dd') } dataset = @{ @@ -270,9 +269,13 @@ function Get-CostData { } # Ensure any subs without forecast data default to actual - foreach ($subId in $costMap.Keys) { + foreach ($subId in @($costMap.Keys)) { + if (-not $costMap[$subId].ContainsKey('ForecastSource')) { + $costMap[$subId].ForecastSource = 'Forecast' + } if ($costMap[$subId].Forecast -eq 0 -and $costMap[$subId].Actual -gt 0) { $costMap[$subId].Forecast = $costMap[$subId].Actual + $costMap[$subId].ForecastSource = 'Actual' } } @@ -333,18 +336,18 @@ function Get-CostDataPerSubscription { } } - $costMap[$sub.Id] = @{ Actual = $actual; Forecast = $actual; Currency = $currency } + $costMap[$sub.Id] = @{ Actual = $actual; Forecast = $actual; Currency = $currency; ForecastSource = 'Actual' } # Per-sub forecast (skipped for large tenants) if (-not $skipForecast) { try { - $now = Get-Date + $now = (Get-Date).ToUniversalTime() $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') + from = $now.AddDays(1 - $now.Day).ToString('yyyy-MM-dd') to = $monthEnd.ToString('yyyy-MM-dd') } dataset = @{ @@ -361,8 +364,15 @@ function Get-CostDataPerSubscription { if ($fResp.StatusCode -eq 200) { $fRes = ($fResp.Content | ConvertFrom-Json) if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) { - $fAmount = [math]::Round($fRes.properties.rows[0][0], 2) - $costMap[$sub.Id].Forecast = $actual + $fAmount + $fcIdx = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('cost', 'pretaxcost', 'costusd') + if ($fcIdx -ge 0) { + $total = 0.0 + foreach ($fRow in $fRes.properties.rows) { + $total += [double]$fRow[$fcIdx] + } + $costMap[$sub.Id].Forecast = [math]::Round($total, 2) + $costMap[$sub.Id].ForecastSource = 'Forecast' + } } } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 index 318c8be39..c043707c1 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 @@ -676,6 +676,7 @@ function ConvertTo-CostDataFromExport { # Seed every selected sub so the UI shows them even at $0 foreach ($s in $Subscriptions) { $costMap[$s.Id] = @{ Actual = 0; Forecast = 0; Currency = $ExportData.Currency } } + $skippedRows = 0 foreach ($r in $ExportData.Rows) { # SubscriptionId may be a bare GUID (classic) or a /subscriptions/ # path (FOCUS SubAccountId). Fall back to ResourceId when absent. @@ -683,7 +684,8 @@ function ConvertTo-CostDataFromExport { if ([string]::IsNullOrWhiteSpace($rawSub) -and $cm.ResourceId) { $rawSub = "$($r.$($cm.ResourceId))" } $g = Get-GuidFromString -Value $rawSub if (-not $g) { continue } - $key = if ($guidToKey.ContainsKey($g.ToLower())) { $guidToKey[$g.ToLower()] } else { $g } + if (-not $guidToKey.ContainsKey($g.ToLower())) { $skippedRows++; continue } + $key = $guidToKey[$g.ToLower()] $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" if (-not $costMap.ContainsKey($key)) { $costMap[$key] = @{ Actual = 0; Forecast = 0; Currency = $ExportData.Currency } @@ -691,6 +693,10 @@ function ConvertTo-CostDataFromExport { $costMap[$key].Actual += $cost } + if ($skippedRows -gt 0) { + Write-Verbose " Export covers a wider scope: ignored $skippedRows row(s) for unselected subscriptions." + } + # Linear month-to-date projection for a sensible forecast $now = Get-Date $daysInMo = [DateTime]::DaysInMonth($now.Year, $now.Month) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index 2e2139d08..7374f0905 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -76,6 +76,21 @@ function New-KpiValue { [PSCustomObject]@{ Display = $Display; Value = $Value } } +function Get-CommitmentUtilizationValue { + param($Data) + + $vals = @() + if ([int](Get-ScanField $Data 'RICount') -gt 0) { + $ri = Get-ScanField $Data 'RIAvgUtilization' + if ($null -ne $ri) { $vals += [double]$ri } + } + if ([int](Get-ScanField $Data 'SPCount') -gt 0) { + $sp = Get-ScanField $Data 'SPAvgUtilization' + if ($null -ne $sp) { $vals += [double]$sp } + } + return $vals +} + function Get-KpiComputedValue { param([string]$KpiId, $Data, $Catalog) @@ -99,9 +114,7 @@ function Get-KpiComputedValue { if ($null -ne $v -and $v -gt 0) { return (New-KpiValue "$cur $v per vCPU / month" ([double]$v)) } } 'commitment-utilization-score' { - $ri = Get-ScanField $Data 'RIAvgUtilization' - $sp = Get-ScanField $Data 'SPAvgUtilization' - $vals = @($ri, $sp) | Where-Object { $null -ne $_ -and $_ -gt 0 } + $vals = @(Get-CommitmentUtilizationValue -Data $Data) if ($vals.Count -gt 0) { $avg = [math]::Round(($vals | Measure-Object -Average).Average, 1) return (New-KpiValue "$avg%" $avg) @@ -218,9 +231,7 @@ function Get-KpiComputedValue { } } 'pct-commitment-discount-waste' { - $ri = Get-ScanField $Data 'RIAvgUtilization' - $sp = Get-ScanField $Data 'SPAvgUtilization' - $vals = @($ri, $sp) | Where-Object { $null -ne $_ -and $_ -gt 0 } + $vals = @(Get-CommitmentUtilizationValue -Data $Data) if ($vals.Count -gt 0) { $avg = ($vals | Measure-Object -Average).Average $waste = [math]::Round(100 - $avg, 1) diff --git a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 index 90d79722b..927197688 100644 --- a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 +++ b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 @@ -189,3 +189,183 @@ Describe 'FinOps Multitool safety' { } } } + +Describe 'FinOps Multitool cost math' { + + BeforeAll { + $script:ModuleRoot = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool' + Import-Module (Join-Path $script:ModuleRoot 'FinOpsMultitool.psm1') -Force + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Cost column resolution' { + + It 'Resolves Cost and not CostStatus when both are present' { + InModuleScope FinOpsMultitool { + $columns = @( + [pscustomobject]@{ name = 'Cost' } + [pscustomobject]@{ name = 'SubscriptionId' } + [pscustomobject]@{ name = 'CostStatus' } + ) + Get-CostColumnIndex -Columns $columns -Names @('cost', 'pretaxcost', 'costusd') | + Should -Be 0 + } + } + + It 'Reports -1 rather than guessing when the column is absent' { + InModuleScope FinOpsMultitool { + $columns = @([pscustomobject]@{ name = 'CostStatus' }) + Get-CostColumnIndex -Columns $columns -Names @('cost') | Should -Be -1 + } + } + } + + Context 'Forecast requests' { + + It 'Requests the full month before summing actual and forecast rows ()' -ForEach @( + @{ QueryPath = 'PerSubscription' } + @{ QueryPath = 'ManagementGroup' } + @{ QueryPath = 'ManagementGroupFallback' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath } { + param($QueryPath) + + Mock Get-Date { [datetime]'2026-09-16T12:00:00Z' } + Mock Get-Date { [datetime]'2026-09-01T00:00:00' } -ParameterFilter { $Day -eq 1 } + Mock Resolve-CostMgId { 'mock-management-group' } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*forecast*') { + if ($QueryPath -eq 'ManagementGroupFallback' -and $Path -like '/providers/Microsoft.Management/*') { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $forecastRequest = $Payload | ConvertFrom-Json + $forecastRows = @(, @(250.0, 'Forecast', '11111111-1111-1111-1111-111111111111', 'USD')) + if ($forecastRequest.timePeriod.from -eq '2026-09-01') { + $forecastRows = @( + @(100.0, 'Actual', '11111111-1111-1111-1111-111111111111', 'USD'), + @(250.0, 'Forecast', '11111111-1111-1111-1111-111111111111', 'USD') + ) + } + $body = @{ + properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'CostStatus' }, @{ name = 'SubscriptionId' }, @{ name = 'Currency' }) + rows = $forecastRows + } + } + } + else { + $body = @{ + properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'Currency' }, @{ name = 'SubscriptionId' }) + rows = @(, @(100.0, 'USD', '11111111-1111-1111-1111-111111111111')) + } + } + } + [pscustomobject]@{ + StatusCode = 200 + Content = ($body | ConvertTo-Json -Depth 10) + } + } + + $subs = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + $result = if ($QueryPath -eq 'PerSubscription') { + Get-CostDataPerSubscription -Subscriptions $subs + } + else { + Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subs + } + + $entry = $result['11111111-1111-1111-1111-111111111111'] + $entry.Actual | Should -Be 100 + $entry.Forecast | Should -Be 350 + $entry.ForecastSource | Should -Be 'Forecast' + $forecastCalls = if ($QueryPath -eq 'ManagementGroupFallback') { 2 } else { 1 } + Should -Invoke Invoke-AzRestMethodWithRetry -Times $forecastCalls -Exactly -ParameterFilter { + $request = $Payload | ConvertFrom-Json + $Path -like '*forecast*' -and $Method -eq 'POST' -and + $request.timePeriod.from -eq '2026-09-01' -and + $request.timePeriod.to -eq '2026-09-30' -and + $request.includeActualCost -eq $true + } + } + } + + It 'Flags the fallback when no forecast is available' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*forecast*') { + return [pscustomobject]@{ StatusCode = 404; Content = '{}' } + } + $body = @{ + properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'Currency' }) + rows = @(, @(100.0, 'USD')) + } + } + [pscustomobject]@{ + StatusCode = 200 + Content = ($body | ConvertTo-Json -Depth 10) + } + } + + $subs = @([pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'test' }) + $result = Get-CostDataPerSubscription -Subscriptions $subs + + $result['22222222-2222-2222-2222-222222222222'].ForecastSource | Should -Be 'Actual' + } + } + } + + Context 'Export scope' { + + It 'Ignores rows for subscriptions that were not selected' { + InModuleScope FinOpsMultitool { + $selected = '44444444-4444-4444-4444-444444444444' + $other = '55555555-5555-5555-5555-555555555555' + + $exportData = [pscustomobject]@{ + Currency = 'USD' + ColMap = [pscustomobject]@{ Cost = 'Cost'; SubscriptionId = 'SubscriptionId'; ResourceId = $null } + Rows = @( + [pscustomobject]@{ SubscriptionId = $selected; Cost = '10.00' } + [pscustomobject]@{ SubscriptionId = $other; Cost = '999.00' } + ) + } + $subs = @([pscustomobject]@{ Id = $selected; Name = 'selected' }) + + $map = ConvertTo-CostDataFromExport -ExportData $exportData -Subscriptions $subs + + $map.Keys | Should -Not -Contain $other + @($map.Keys).Count | Should -Be 1 + $map[$selected].Actual | Should -Be 10 + } + } + } + + Context 'Commitment utilization' { + + It 'Ignores a family that has no commitments' { + InModuleScope FinOpsMultitool { + $data = [pscustomobject]@{ RICount = 10; RIAvgUtilization = 100; SPCount = 0; SPAvgUtilization = 0 } + @(Get-CommitmentUtilizationValue -Data $data) | Should -Be @(100) + } + } + + It 'Keeps a real 0% when commitments exist' { + InModuleScope FinOpsMultitool { + $data = [pscustomobject]@{ RICount = 3; RIAvgUtilization = 0; SPCount = 0; SPAvgUtilization = 0 } + @(Get-CommitmentUtilizationValue -Data $data) | Should -Be @(0) + } + } + + It 'Reports nothing when no commitments were found at all' { + InModuleScope FinOpsMultitool { + $data = [pscustomobject]@{ RICount = 0; RIAvgUtilization = 0; SPCount = 0; SPAvgUtilization = 0 } + @(Get-CommitmentUtilizationValue -Data $data).Count | Should -Be 0 + } + } + } +} From 1af4f06307bc60f1f4b5ba9d00224055f8808120 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Thu, 17 Sep 2026 11:13:29 -0600 Subject: [PATCH 132/142] fix(multitool): FinOps Multitool Update - Sep 17 Review findings - Reject incomplete pages, required-query failures, and invalid costs. - Preserve POST bodies and discard failed attempts before retries. - Calculate hourly vCPU cost from the captured UTC reporting window. --- .../modules/Get-AIWorkloadMetrics.ps1 | 12 +- .../modules/Get-BudgetStatus.ps1 | 6 +- .../modules/Get-CommitmentUtilization.ps1 | 4 +- .../FinOpsMultitool/modules/Get-CostByTag.ps1 | 8 +- .../FinOpsMultitool/modules/Get-CostData.ps1 | 167 +++-- .../FinOpsMultitool/modules/Get-CostTrend.ps1 | 49 +- .../modules/Get-OrphanedResources.ps1 | 7 +- .../modules/Get-ResourceCosts.ps1 | 62 +- .../modules/Get-SavingsRealized.ps1 | 60 +- .../modules/Get-SharedCostAllocation.ps1 | 4 +- .../modules/Get-UnitEconomics.ps1 | 31 +- .../modules/Get-VmCostBreakdown.ps1 | 2 +- .../helpers/Get-CostQueryResponsePage.ps1 | 128 +++- .../modules/helpers/Get-KpiInsights.ps1 | 15 +- .../Tests/Unit/BudgetCoverage.Tests.ps1 | 35 +- .../CommitmentUtilizationDedupe.Tests.ps1 | 21 + .../Tests/Unit/CostQueryPagination.Tests.ps1 | 702 +++++++++++++++++- .../Tests/Unit/MultitoolSafety.Tests.ps1 | 38 +- 18 files changed, 1144 insertions(+), 207 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 index f09113edd..e73f23971 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -120,11 +120,11 @@ resources Write-Host " No AI workloads detected - skipping AI KPIs." -ForegroundColor Gray } return [PSCustomObject]@{ - HasData = $false - AIFootprint = $footprint - ScannedSubs = $Subscriptions.Count + HasData = $false + AIFootprint = $footprint + ScannedSubs = $Subscriptions.Count DetectionFailed = $detectionFailed - Note = if ($detectionFailed) { + Note = if ($detectionFailed) { 'AI detection query failed, so the absence of AI workloads is unverified.' } else { @@ -285,9 +285,9 @@ resources $path = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + $cdata = Get-CostQueryResult -FirstResponse $resp -Payload $body -Context 'AI spend' if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { - $cdata = $resp.Content | ConvertFrom-Json if ($cdata.properties.rows) { $costOk = $true # Column order follows properties.columns; resolve indices. @@ -307,7 +307,7 @@ resources } } catch { - Write-Warning " AI cost query failed: $($_.Exception.Message)" + throw "AI cost query failed: $($_.Exception.Message)" } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 index 0a3d20663..d06359e97 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -336,9 +336,8 @@ function Get-BudgetHistory { $costPath = "/subscriptions/$subId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" try { $resp = Invoke-AzRestMethodWithRetry -Path $costPath -Method POST -Payload $body - if ($resp.StatusCode -ne 200) { continue } - $result = ($resp.Content | ConvertFrom-Json) + $result = Get-CostQueryResult -FirstResponse $resp -Payload $body -Context "budget history for $subName" if (-not $result.properties -or -not $result.properties.rows) { continue } # Parse columns @@ -382,8 +381,7 @@ function Get-BudgetHistory { } } catch { - Write-Warning " Budget history query failed for $subName : $($_.Exception.Message)" - continue + throw "Budget history query failed for $subName : $($_.Exception.Message)" } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 index b219776f4..c546f0d09 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 @@ -147,7 +147,7 @@ function Get-CommitmentUtilization { } } catch { if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } - Write-Warning " Reservation summaries query failed for $scopeId : $($_.Exception.Message)" + throw "Reservation summaries query failed for $scopeId : $($_.Exception.Message)" } } $reservations += @($latestReservation.Values) @@ -255,7 +255,7 @@ function Get-CommitmentUtilization { } } catch { if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } - Write-Warning " Savings plan utilization query failed for $scopeId : $($_.Exception.Message)" + throw "Savings plan utilization query failed for $scopeId : $($_.Exception.Message)" } } $savingsPlans += @($latestSavingsPlan.Values) diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 index 3ebac26b8..6839699f0 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 @@ -366,8 +366,7 @@ function Get-CostByTag { # pay-as-you-go (unlike TagKey/TagValue, which 400/408s on most sub types). # This collapses the old tags x subs x timeframes call matrix down to a # single call per subscription, then attributes each resource's cost to its - # tag values client-side. The scan is built to COMPLETE even when some subs - # fail: a failed subscription is recorded and skipped, never thrown. + # tag values client-side. All selected subscriptions must be readable. $usedTimeframe = 'MonthToDate' $timeframes = @('MonthToDate', 'Custom') @@ -448,7 +447,7 @@ function Get-CostByTag { $subsQueried++ # Follow nextLink: one page only would understate a large subscription. $rows = @() - foreach ($page in (Get-CostQueryResponsePage -FirstResponse $subResp -Context "cost-by-tag for $($pj.SubName)")) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $subResp -Payload $body -Context "cost-by-tag for $($pj.SubName)")) { $rows += ConvertFrom-ResourceIdRow -ResponseContent $page.Content } foreach ($row in $rows) { @@ -509,6 +508,9 @@ function Get-CostByTag { else { $subsFailed++ } + if (-not $subResp -or $subResp.StatusCode -ne 200) { + throw "Cost-by-tag query failed for $($pj.Call.SubName) (HTTP $($subResp.StatusCode)); results are incomplete." + } } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 index e9af3ba1e..44787a92a 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 @@ -19,12 +19,14 @@ param() # Reference: https://learn.microsoft.com/en-us/rest/api/cost-management/query/usage ########################################################################### +# Matches column names exactly. A substring match lands on 'CostStatus', whose +# value is the text 'Actual' or 'Forecast', and casting that to a number throws. function Get-CostColumnIndex { param($Columns, [string[]]$Names) if (-not $Columns) { return -1 } - for ($columnIndex = 0; $columnIndex -lt $Columns.Count; $columnIndex++) { - if (([string]$Columns[$columnIndex].name).ToLower() -in $Names) { return $columnIndex } + for ($i = 0; $i -lt $Columns.Count; $i++) { + if (([string]$Columns[$i].name).ToLower() -in $Names) { return $i } } return -1 } @@ -97,36 +99,49 @@ function Get-CostData { throw "MG-scope cost query returned HTTP $($response.StatusCode). Falling back to per-subscription." } - $result = ($response.Content | ConvertFrom-Json) - - # Resolve column indices by name. The MG-scope response is not contractually - # ordered, and a reorder would silently attribute cost to the wrong sub. - $aCols = $result.properties.columns - $aSubIdx = Get-CostColumnIndex -Columns $aCols -Names @('subscriptionid') - $aCurIdx = Get-CostColumnIndex -Columns $aCols -Names @('currency') - $aCostIdx = Get-CostColumnIndex -Columns $aCols -Names @('cost', 'pretaxcost', 'costusd') - if ($aCostIdx -lt 0 -or $aSubIdx -lt 0) { - throw "Actual cost response did not expose the expected Cost and SubscriptionId columns." - } + # The query API returns one page at a time. A truncated read looks like + # lower cost rather than an error, so follow nextLink before summing. + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $response -Payload $actualBody -Context 'actual cost')) { + $result = ($page.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 + $aSubIdx = Get-CostColumnIndex -Columns $aCols -Names @('subscriptionid') + $aCurIdx = Get-CostColumnIndex -Columns $aCols -Names @('currency') + $aCostIdx = Get-CostColumnIndex -Columns $aCols -Names @('cost', 'pretaxcost', 'costusd') + + # Guessing at positions here would attribute real money to the wrong + # subscription, so fail into the per-subscription path instead. + if ($aCostIdx -lt 0 -or $aSubIdx -lt 0) { + throw "Actual cost response did not expose the expected Cost and SubscriptionId columns." + } - if ($result.properties.rows) { - foreach ($row in $result.properties.rows) { - $subId = $row[$aSubIdx] - $amount = [math]::Round($row[$aCostIdx], 2) - $currency = if ($aCurIdx -ge 0) { $row[$aCurIdx] } else { 'USD' } + if ($result.properties.rows) { + foreach ($row in $result.properties.rows) { + $subId = [string]$row[$aSubIdx] + $amount = [double]$row[$aCostIdx] + $currency = if ($aCurIdx -ge 0) { $row[$aCurIdx] } else { 'USD' } - if ($selectedSubs -and -not $selectedSubs.Contains([string]$subId)) { continue } + if ($selectedSubs -and -not $selectedSubs.Contains($subId)) { continue } - if (-not $costMap.ContainsKey($subId)) { - $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = $currency } + if (-not $costMap.ContainsKey($subId)) { + $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = $currency; ForecastSource = 'Actual' } + } + $costMap[$subId].Actual += $amount + $costMap[$subId].Currency = $currency } - $costMap[$subId].Actual = $amount - $costMap[$subId].Currency = $currency } } + + # Round once after every page is in, not per page. + foreach ($subId in @($costMap.Keys)) { + $costMap[$subId].Actual = [math]::Round($costMap[$subId].Actual, 2) + } } catch { Write-Warning "Actual cost query failed: $($_.Exception.Message)" + if (-not $Subscriptions) { throw } Write-Warning "Falling back to per-subscription queries." $costMap = Get-CostDataPerSubscription -Subscriptions $Subscriptions return $costMap @@ -171,15 +186,10 @@ function Get-CostData { throw "Forecast query returned HTTP $($fResponse.StatusCode)" } - $fResult = ($fResponse.Content | ConvertFrom-Json) - - if ($fResult.properties.rows -and $fResult.properties.rows.Count -gt 0) { - # The Forecast API with includeActualCost returns rows that may have - # a CostStatus column (Actual/Forecast). Sum all rows per subscription - # to get the full-month projected cost. - # Resolve column indices by name. The forecast endpoint may order - # columns differently and adds a CostStatus column, so positional - # access can mistake "Forecast"/"Actual" text for a subscription ID. + $forecastSums = @{} + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $fResponse -Payload $forecastBody -Context 'forecast')) { + $fResult = $page.Content | ConvertFrom-Json + if ($fResult.properties.rows.Count -eq 0) { continue } $fCols = $fResult.properties.columns $fSubIdx = Get-CostColumnIndex -Columns $fCols -Names @('subscriptionid') $fCostIdx = Get-CostColumnIndex -Columns $fCols -Names @('cost', 'pretaxcost', 'costusd') @@ -187,8 +197,7 @@ function Get-CostData { throw "Forecast response did not expose the expected Cost and SubscriptionId columns." } - $forecastSums = @{} - foreach ($row in $fResult.properties.rows) { + foreach ($row in @($fResult.properties.rows)) { $subId = [string]$row[$fSubIdx] if ($subId -notmatch '^[0-9a-fA-F]{8}-') { continue } if ($selectedSubs -and -not $selectedSubs.Contains($subId)) { continue } @@ -196,11 +205,14 @@ function Get-CostData { if (-not $forecastSums.ContainsKey($subId)) { $forecastSums[$subId] = 0 } $forecastSums[$subId] += $amount } + } + if ($forecastSums.Count -gt 0) { foreach ($subId in $forecastSums.Keys) { if (-not $costMap.ContainsKey($subId)) { $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = 'USD' } } $costMap[$subId].Forecast = [math]::Round($forecastSums[$subId], 2) + $costMap[$subId].ForecastSource = 'Forecast' } $forecastSuccess = $true Write-Host " MG-scope forecast: got data for $($forecastSums.Count) subscriptions" -ForegroundColor Green @@ -211,6 +223,7 @@ function Get-CostData { } catch { Write-Warning "MG-scope forecast failed: $($_.Exception.Message)" + if (-not $Subscriptions) { throw } Write-Host " Falling back to per-subscription forecast queries..." -ForegroundColor Yellow } @@ -247,33 +260,44 @@ function Get-CostData { } | ConvertTo-Json -Depth 10 $fResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" -Method POST -Payload $fBody + if (-not $fResp -or $fResp.StatusCode -ne 200) { + throw "Forecast retry returned HTTP $($fResp.StatusCode); results are incomplete." + } if ($fResp.StatusCode -eq 200) { - $fRes = ($fResp.Content | ConvertFrom-Json) - if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) { - $total = 0 - foreach ($row in $fRes.properties.rows) { $total += [double]$row[0] } + $total = 0.0 + $rowCount = 0 + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $fResp -Payload $fBody -Context "forecast for $($sub.Id)")) { + $fRes = $page.Content | ConvertFrom-Json + if ($fRes.properties.rows.Count -eq 0) { continue } + $costIndex = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('cost', 'pretaxcost', 'costusd') + if ($costIndex -lt 0) { throw 'Forecast response did not expose the expected Cost column.' } + foreach ($row in $fRes.properties.rows) { $total += [double]$row[$costIndex]; $rowCount++ } + } + if ($rowCount -gt 0) { if (-not $costMap.ContainsKey($sub.Id)) { $costMap[$sub.Id] = @{ Actual = 0; Forecast = 0; Currency = 'USD' } } $costMap[$sub.Id].Forecast = [math]::Round($total, 2) + $costMap[$sub.Id].ForecastSource = 'Forecast' $hitCount++ } + else { + throw 'Forecast retry returned no rows; results are incomplete.' + } } } catch { - # Forecast not available for this sub - Write-Verbose "Non-fatal: $($_.Exception.Message)" + throw "Forecast query failed for $($sub.Name): $($_.Exception.Message)" } } Write-Host " Per-sub forecast: got data for $hitCount of $subCount subscriptions" -ForegroundColor $(if ($hitCount -gt 0) { 'Green' } else { 'Yellow' }) } - # Ensure any subs without forecast data default to actual + # Subs without forecast data fall back to actual, which understates a + # full-month projection. Flag it so callers can label the number rather + # than present month-to-date spend as a forecast. foreach ($subId in @($costMap.Keys)) { - if (-not $costMap[$subId].ContainsKey('ForecastSource')) { - $costMap[$subId].ForecastSource = 'Forecast' - } - if ($costMap[$subId].Forecast -eq 0 -and $costMap[$subId].Actual -gt 0) { + if ($costMap[$subId].ForecastSource -ne 'Forecast') { $costMap[$subId].Forecast = $costMap[$subId].Actual $costMap[$subId].ForecastSource = 'Actual' } @@ -318,11 +342,19 @@ function Get-CostDataPerSubscription { $actual = 0; $currency = 'USD' if ($resp.StatusCode -eq 200) { - $res = ($resp.Content | ConvertFrom-Json) - if ($res.properties.rows -and $res.properties.rows.Count -gt 0) { - $actual = [math]::Round($res.properties.rows[0][0], 2) - $currency = $res.properties.rows[0][1] + $sum = 0.0 + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context "actual cost for $($sub.Id)")) { + $res = $page.Content | ConvertFrom-Json + if ($res.properties.rows.Count -eq 0) { continue } + $cIdx = Get-CostColumnIndex -Columns $res.properties.columns -Names @('cost', 'pretaxcost', 'costusd') + $curIdx = Get-CostColumnIndex -Columns $res.properties.columns -Names @('currency') + if ($cIdx -lt 0) { throw 'Actual cost response did not expose the expected Cost column.' } + foreach ($row in $res.properties.rows) { + $sum += [double]$row[$cIdx] + if ($curIdx -ge 0 -and $row[$curIdx]) { $currency = $row[$curIdx] } + } } + $actual = [math]::Round($sum, 2) } elseif ($resp.StatusCode -in @(400, 403) -and $resp.Content) { $errMsg = try { ($resp.Content | ConvertFrom-Json).error.message } catch { '' } @@ -335,7 +367,12 @@ function Get-CostDataPerSubscription { Write-Warning " Cost data access denied. Verify Billing Profile Reader or Cost Management Reader role assignment." } } + if (-not $resp -or $resp.StatusCode -ne 200) { + throw "Actual cost query returned HTTP $($resp.StatusCode); results are incomplete." + } + # Forecast starts as actual so a sub with no forecast still reports a + # number; ForecastSource records that it is month-to-date, not a projection. $costMap[$sub.Id] = @{ Actual = $actual; Forecast = $actual; Currency = $currency; ForecastSource = 'Actual' } # Per-sub forecast (skipped for large tenants) @@ -361,29 +398,35 @@ function Get-CostDataPerSubscription { } | ConvertTo-Json -Depth 10 $fResp = Invoke-AzRestMethodWithRetry -Path "$path/forecast?api-version=2023-11-01" -Method POST -Payload $fBody + if (-not $fResp -or $fResp.StatusCode -ne 200) { + throw "Forecast query returned HTTP $($fResp.StatusCode); results are incomplete." + } if ($fResp.StatusCode -eq 200) { - $fRes = ($fResp.Content | ConvertFrom-Json) - if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) { + $total = 0.0 + $rowCount = 0 + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $fResp -Payload $fBody -Context "forecast for $($sub.Id)")) { + $fRes = $page.Content | ConvertFrom-Json + if ($fRes.properties.rows.Count -eq 0) { continue } $fcIdx = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('cost', 'pretaxcost', 'costusd') - if ($fcIdx -ge 0) { - $total = 0.0 - foreach ($fRow in $fRes.properties.rows) { - $total += [double]$fRow[$fcIdx] - } - $costMap[$sub.Id].Forecast = [math]::Round($total, 2) - $costMap[$sub.Id].ForecastSource = 'Forecast' - } + if ($fcIdx -lt 0) { throw 'Forecast response did not expose the expected Cost column.' } + foreach ($fRow in $fRes.properties.rows) { $total += [double]$fRow[$fcIdx]; $rowCount++ } + } + if ($rowCount -gt 0) { + $costMap[$sub.Id].Forecast = [math]::Round($total, 2) + $costMap[$sub.Id].ForecastSource = 'Forecast' + } + else { + throw 'Forecast query returned no rows; results are incomplete.' } } } catch { - # Forecast not available for all account types - Write-Verbose "Non-fatal: $($_.Exception.Message)" + throw } } } catch { - Write-Warning " Cost query failed for $($sub.Name): $($_.Exception.Message)" + throw "Cost query failed for $($sub.Name): $($_.Exception.Message)" } } return $costMap diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 index 8bcad7ada..160c678e4 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 @@ -124,10 +124,10 @@ function Get-CostTrend { # under-reports a large scope as lower spend rather than as an error, so # every page is collected before the rows are parsed. function Get-AllCostRow { - param($FirstResponse, [string]$Context) + param($FirstResponse, [string]$Payload, [string]$Context) $rows = [System.Collections.Generic.List[object]]::new() $columns = $null - foreach ($page in (Get-CostQueryResponsePage -FirstResponse $FirstResponse -Context $Context)) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $FirstResponse -Payload $Payload -Context $Context)) { $parsed = ($page.Content | ConvertFrom-Json) if (-not $columns) { $columns = $parsed.properties.columns } foreach ($row in @($parsed.properties.rows)) { [void]$rows.Add($row) } @@ -221,14 +221,10 @@ function Get-CostTrend { $only = $Subscriptions[0] $subPath = "/subscriptions/$($only.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body - if ($subResp.StatusCode -eq 200) { - $paged = Get-AllCostRow -FirstResponse $subResp -Context "cost trend for $($only.Name)" - if ($paged.Rows.Count -gt 0) { - $months = ConvertFrom-TrendCostRow -Rows $paged.Rows -Columns $paged.Columns - $bySubscription[$only.Id] = @($months | Sort-Object MonthDate) - } - } else { - Write-Warning " Single-sub cost trend returned HTTP $($subResp.StatusCode)" + $paged = Get-AllCostRow -FirstResponse $subResp -Payload $body -Context "cost trend for $($only.Name)" + if ($paged.Rows.Count -gt 0) { + $months = ConvertFrom-TrendCostRow -Rows $paged.Rows -Columns $paged.Columns + $bySubscription[$only.Id] = @($months | Sort-Object MonthDate) } } else { @@ -246,7 +242,7 @@ function Get-CostTrend { $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $groupedBody if ($response.StatusCode -eq 200) { - $paged = Get-AllCostRow -FirstResponse $response -Context 'management-group cost trend' + $paged = Get-AllCostRow -FirstResponse $response -Payload $groupedBody -Context 'management-group cost trend' if ($paged.Rows.Count -gt 0) { $entries = ConvertFrom-GroupedCostRow -Rows $paged.Rows -Columns $paged.Columns Set-TrendFromGrouped -Entries $entries @@ -261,8 +257,6 @@ function Get-CostTrend { # -- Fallback: per-subscription loop (MG scope unavailable) --- if (-not $groupedOk -and $Subscriptions) { - $sampleErrors = 0 - $sampleSize = [math]::Min(3, $subCount) $aggTotals = @{} # used for aggregate if MG scope failed $i = 0 @@ -277,27 +271,18 @@ function Get-CostTrend { $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body - if ($subResp.StatusCode -eq 200) { - $paged = Get-AllCostRow -FirstResponse $subResp -Context "cost trend for $($sub.Name)" - if ($paged.Rows.Count -gt 0) { - $subMonths = ConvertFrom-TrendCostRow -Rows $paged.Rows -Columns $paged.Columns - $bySubscription[$sub.Id] = @($subMonths | Sort-Object MonthDate) + $paged = Get-AllCostRow -FirstResponse $subResp -Payload $body -Context "cost trend for $($sub.Name)" + if ($paged.Rows.Count -gt 0) { + $subMonths = ConvertFrom-TrendCostRow -Rows $paged.Rows -Columns $paged.Columns + $bySubscription[$sub.Id] = @($subMonths | Sort-Object MonthDate) - foreach ($sm in $subMonths) { - $key = $sm.MonthDate.ToString('yyyy-MM') - if (-not $aggTotals.ContainsKey($key)) { - $aggTotals[$key] = @{ Cost = 0; Date = $sm.MonthDate; Currency = $sm.Currency } - } - $aggTotals[$key].Cost += $sm.Cost + foreach ($sm in $subMonths) { + $key = $sm.MonthDate.ToString('yyyy-MM') + if (-not $aggTotals.ContainsKey($key)) { + $aggTotals[$key] = @{ Cost = 0; Date = $sm.MonthDate; Currency = $sm.Currency } } + $aggTotals[$key].Cost += $sm.Cost } - } else { - if ($i -le $sampleSize) { $sampleErrors++ } - } - - if ($i -eq $sampleSize -and $sampleErrors -eq $sampleSize -and $subCount -gt $sampleSize) { - Write-Host " All $sampleSize sample subs returned errors - skipping remaining $($subCount - $sampleSize) subs" -ForegroundColor Yellow - break } } @@ -314,7 +299,7 @@ function Get-CostTrend { } } } catch { - Write-Warning "Cost trend query failed: $($_.Exception.Message)" + throw "Cost trend query failed: $($_.Exception.Message)" } # Sort by date diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 index f4ed72324..4906d62b8 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -346,11 +346,8 @@ resources } try { - if (-not $costPeriodLabel) { $costPeriodLabel = $usedLabel } - $costQueried++ - # Follow nextLink: one page only would leave later orphans uncosted. - foreach ($page in (Get-CostQueryResponsePage -FirstResponse $costResp -Context "orphan cost for $($sub.Name)")) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $costResp -Payload $body -Context "orphan cost for $($sub.Name)")) { $costResult = ($page.Content | ConvertFrom-Json) $costCols = @{} for ($cIdx = 0; $cIdx -lt $costResult.properties.columns.Count; $cIdx++) { @@ -362,6 +359,8 @@ resources if ($rid) { $costMap[$rid.ToLowerInvariant()] = [math]::Round([double]$costRow[$costCols['Cost']], 2) } } } + if (-not $costPeriodLabel) { $costPeriodLabel = $usedLabel } + $costQueried++ } catch { [void]$costFailures.Add("$($sub.Name): $($_.Exception.Message)") diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 index 493f86e9a..55cb0b403 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 @@ -125,9 +125,9 @@ function Get-ResourceCosts { $cols[$result.properties.columns[$colIdx].name] = $colIdx } - $page = $result $pageNum = 0 - do { + foreach ($responsePage in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context 'management-group resource costs')) { + $page = $responsePage.Content | ConvertFrom-Json $pageNum++ if ($page.properties.rows) { if ($pageNum -eq 1 -or $pageNum % 3 -eq 0) { @@ -156,14 +156,7 @@ function Get-ResourceCosts { }) } } - if ($page.properties.nextLink) { - $nextUri = [System.Uri]$page.properties.nextLink - $nResp = Invoke-AzRestMethodWithRetry -Path $nextUri.PathAndQuery -Method GET - if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) } - else { break } - } - else { break } - } while ($true) + } if ($allRows.Count -gt 0) { $gotMgData = $true @@ -204,6 +197,8 @@ function Get-ResourceCosts { } } catch { + $allRows.Clear() + $gotMgData = $false Write-Warning " MG-scope resource cost query failed: $($_.Exception.Message)" } } @@ -258,8 +253,8 @@ function Get-ResourceCosts { } # Process all pages (Cost Management API paginates at ~5000 rows) - $page = $result - do { + foreach ($responsePage in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context "resource costs for $($sub.Name)")) { + $page = $responsePage.Content | ConvertFrom-Json if ($page.properties.rows) { foreach ($row in $page.properties.rows) { $cost = [math]::Round($row[$cols['Cost']], 2) @@ -285,19 +280,14 @@ function Get-ResourceCosts { } } } - # Follow pagination link if present - if ($page.properties.nextLink) { - $uri = [System.Uri]$page.properties.nextLink - $nResp = Invoke-AzRestMethodWithRetry -Path $uri.PathAndQuery -Method GET - if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) } - else { break } - } - else { break } - } while ($true) + } + } + else { + throw "Resource cost query returned HTTP $($resp.StatusCode); results are incomplete." } } catch { - Write-Warning " Resource cost query failed for $($sub.Name): $($_.Exception.Message)" + throw "Resource cost query failed for $($sub.Name): $($_.Exception.Message)" } # -- Forecast: use subscription-level forecast ratio ------------- @@ -320,14 +310,14 @@ function Get-ResourceCosts { elseif (-not $skipForecast) { # Only call forecast API for small tenants without CostData try { - $now = Get-Date + $now = (Get-Date).ToUniversalTime() $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') + from = $now.AddDays(1 - $now.Day).ToString('yyyy-MM-dd') to = $monthEnd.ToString('yyyy-MM-dd') } dataset = @{ @@ -342,20 +332,32 @@ function Get-ResourceCosts { $fResp = Invoke-AzRestMethodWithRetry -Path "$basePath/forecast?api-version=2023-11-01" -Method POST -Payload $fBody + if (-not $fResp -or $fResp.StatusCode -ne 200) { + throw "Resource forecast returned HTTP $($fResp.StatusCode); results are incomplete." + } if ($fResp.StatusCode -eq 200) { - $fResult = ($fResp.Content | ConvertFrom-Json) - if ($fResult.properties.rows -and $fResult.properties.rows.Count -gt 0) { - $forecastTotal = 0 + $forecastTotal = 0.0 + $rowCount = 0 + foreach ($responsePage in (Get-CostQueryResponsePage -FirstResponse $fResp -Payload $fBody -Context "resource forecast for $($sub.Name)")) { + $fResult = $responsePage.Content | ConvertFrom-Json + if ($fResult.properties.rows.Count -eq 0) { continue } + $costIndex = Get-CostColumnIndex -Columns $fResult.properties.columns -Names @('cost', 'pretaxcost', 'costusd') + if ($costIndex -lt 0) { throw 'Forecast response did not expose the expected Cost column.' } foreach ($row in $fResult.properties.rows) { - $forecastTotal += [double]$row[0] + $forecastTotal += [double]$row[$costIndex] + $rowCount++ } + } + if ($rowCount -gt 0) { $subForecast = [math]::Round($forecastTotal, 2) } + else { + throw 'Resource forecast returned no rows; results are incomplete.' + } } } catch { - # Forecast not available for all account types - Write-Verbose "Non-fatal: $($_.Exception.Message)" + throw "Resource forecast query failed for $($sub.Name): $($_.Exception.Message)" } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 index bd42cd593..6a861272b 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -165,16 +165,26 @@ function Get-SavingsRealized { Write-Host " Calculating savings (single subscription, direct scope)..." -ForegroundColor Cyan $subPath = "/subscriptions/$($only.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" - $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload (New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('ChargeType')) + $actualBody = New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('ChargeType') + $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $actualBody + if (-not $actualResp -or $actualResp.StatusCode -ne 200) { + throw "Savings charge query returned HTTP $($actualResp.StatusCode); results are incomplete." + } if ($actualResp.StatusCode -eq 200) { - foreach ($d in (Read-SavingsActual -Result ($actualResp.Content | ConvertFrom-Json))) { + $actualResult = Get-CostQueryResult -FirstResponse $actualResp -Payload $actualBody -Context "savings charges for $($only.Name)" + foreach ($d in (Read-SavingsActual -Result $actualResult)) { $d.Subscription = $only.Name; [void]$details.Add($d) } } - $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload (New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('PricingModel')) + $amortBody = New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('PricingModel') + $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $amortBody + if (-not $amortResp -or $amortResp.StatusCode -ne 200) { + throw "Savings benefit query returned HTTP $($amortResp.StatusCode); results are incomplete." + } if ($amortResp.StatusCode -eq 200) { - $parsed = Read-SavingsAmort -Result ($amortResp.Content | ConvertFrom-Json) + $amortResult = Get-CostQueryResult -FirstResponse $amortResp -Payload $amortBody -Context "savings benefits for $($only.Name)" + $parsed = Read-SavingsAmort -Result $amortResult foreach ($d in $parsed.Rows) { $d.Subscription = $only.Name; [void]$details.Add($d) } $riSavings += $parsed.RI $spSavings += $parsed.SP @@ -184,10 +194,10 @@ function Get-SavingsRealized { } $gotMgData = $true - Write-Host " Single-subscription savings calculated (2 API calls)" -ForegroundColor Green + Write-Host " Single-subscription savings calculated" -ForegroundColor Green } catch { - Write-Warning " Single-subscription savings query failed: $($_.Exception.Message)" + throw "Single-subscription savings query failed: $($_.Exception.Message)" } } elseif ($hasCommitments) { @@ -198,18 +208,28 @@ function Get-SavingsRealized { Write-Host " Calculating savings (MG scope, grouped by subscription)..." -ForegroundColor Cyan $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" - $actualResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload (New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('SubscriptionId', 'ChargeType')) + $actualBody = New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('SubscriptionId', 'ChargeType') + $actualResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $actualBody if ($actualResp.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed throw "MG-scope savings query returned HTTP $($actualResp.StatusCode)" } + if (-not $actualResp -or $actualResp.StatusCode -ne 200) { + throw "MG-scope savings charge query returned HTTP $($actualResp.StatusCode); results are incomplete." + } if ($actualResp.StatusCode -eq 200) { - foreach ($d in (Read-SavingsActual -Result ($actualResp.Content | ConvertFrom-Json))) { [void]$details.Add($d) } + $actualResult = Get-CostQueryResult -FirstResponse $actualResp -Payload $actualBody -Context 'management-group savings charges' + foreach ($d in (Read-SavingsActual -Result $actualResult)) { [void]$details.Add($d) } } - $amortResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload (New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('SubscriptionId', 'PricingModel')) + $amortBody = New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('SubscriptionId', 'PricingModel') + $amortResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $amortBody + if (-not $amortResp -or $amortResp.StatusCode -ne 200) { + throw "MG-scope savings benefit query returned HTTP $($amortResp.StatusCode); results are incomplete." + } if ($amortResp.StatusCode -eq 200) { - $parsed = Read-SavingsAmort -Result ($amortResp.Content | ConvertFrom-Json) + $amortResult = Get-CostQueryResult -FirstResponse $amortResp -Payload $amortBody -Context 'management-group savings benefits' + $parsed = Read-SavingsAmort -Result $amortResult foreach ($d in $parsed.Rows) { [void]$details.Add($d) } $riSavings += $parsed.RI $spSavings += $parsed.SP @@ -219,7 +239,7 @@ function Get-SavingsRealized { } $gotMgData = $true - Write-Host " MG scope savings calculated (2 API calls)" -ForegroundColor Green + Write-Host " MG scope savings calculated" -ForegroundColor Green } catch { Write-Warning " MG-scope savings query failed: $($_.Exception.Message)" @@ -229,6 +249,12 @@ function Get-SavingsRealized { # -- Strategy 2: Per-subscription fallback (only if MG/direct scope unavailable) -- if ($hasCommitments -and -not $gotMgData) { + $details.Clear() + $riSavings = 0.0 + $spSavings = 0.0 + $committedAmort = 0.0 + $onDemandAmort = 0.0 + $spotAmort = 0.0 # -- Step 1: Query amortized vs actual to find RI/SP benefit amounts -- # The difference between ActualCost and AmortizedCost reveals commitment savings $subCount = $Subscriptions.Count @@ -258,9 +284,12 @@ function Get-SavingsRealized { $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $actualBody + if (-not $actualResp -or $actualResp.StatusCode -ne 200) { + throw "Savings charge retry returned HTTP $($actualResp.StatusCode); results are incomplete." + } if ($actualResp.StatusCode -eq 200) { - $actualResult = ($actualResp.Content | ConvertFrom-Json) + $actualResult = Get-CostQueryResult -FirstResponse $actualResp -Payload $actualBody -Context "savings charges for $($sub.Name)" if ($actualResult.properties.rows) { foreach ($row in $actualResult.properties.rows) { $chargeType = $row[1] @@ -296,8 +325,11 @@ function Get-SavingsRealized { } | ConvertTo-Json -Depth 10 $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $amortBody + if (-not $amortResp -or $amortResp.StatusCode -ne 200) { + throw "Savings benefit retry returned HTTP $($amortResp.StatusCode); results are incomplete." + } if ($amortResp.StatusCode -eq 200) { - $amortResult = ($amortResp.Content | ConvertFrom-Json) + $amortResult = Get-CostQueryResult -FirstResponse $amortResp -Payload $amortBody -Context "savings benefits for $($sub.Name)" if ($amortResult.properties.rows) { foreach ($row in $amortResult.properties.rows) { $pricingModel = $row[1] @@ -333,7 +365,7 @@ function Get-SavingsRealized { } } catch { - Write-Warning " Savings query failed for $($sub.Name): $($_.Exception.Message)" + throw "Savings query failed for $($sub.Name): $($_.Exception.Message)" } } } # end per-sub fallback diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 index 708adc064..88ac1f7e6 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -228,7 +228,7 @@ function Get-AllocationCostMaps { try { $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body if (-not $bySub.ContainsKey($sub)) { $bySub[$sub] = 0.0 } - foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Context "shared cost for $sub")) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context "shared cost for $sub")) { $data = $page.Content | ConvertFrom-Json $cols = @($data.properties.columns.name) $iCost = [array]::IndexOf($cols, 'Cost') @@ -247,7 +247,7 @@ function Get-AllocationCostMaps { } } catch { - Write-Warning " Cost query failed for $sub : $($_.Exception.Message)" + throw "Cost query failed for $sub : $($_.Exception.Message)" } } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 index a0ec17df0..fbdb5d305 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -257,6 +257,9 @@ resources $totalGb = $diskGb + $blobFileGb # -- 3: Amortized cost by meter category (sub-scoped, with fallback) -- + $costPeriodEndUtc = (Get-Date).ToUniversalTime() + $costPeriodStartUtc = $costPeriodEndUtc.Date.AddDays(1 - $costPeriodEndUtc.Day) + $costTimePeriod = @{ from = $costPeriodStartUtc.ToString('yyyy-MM-ddTHH:mm:ssZ'); to = $costPeriodEndUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') } $computeCost = 0.0 $storageCost = 0.0 # A tenant can bill subscriptions in different currencies; keep them all. @@ -278,7 +281,8 @@ resources if ($subFilter) { $dataset['filter'] = $subFilter } $body = @{ type = 'AmortizedCost' - timeframe = 'MonthToDate' + timeframe = 'Custom' + timePeriod = $costTimePeriod dataset = $dataset } | ConvertTo-Json -Depth 10 @@ -290,11 +294,16 @@ resources Set-MgCostScopeFailed } elseif ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { - if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencySeen $currenciesSeen) { - $costOk = $true - $costScope = "mg:$mgScopeId" + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context 'management-group unit costs')) { + if (Add-MeterCosts -Content $page.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencySeen $currenciesSeen) { + $costOk = $true + $costScope = "mg:$mgScopeId" + } } } + else { + $mgFailed = $true + } } else { $mgFailed = $true @@ -308,11 +317,15 @@ resources # Per-subscription fallback when the MG scope is not accessible. This is # the same pattern Get-CostData uses so unit economics is never silently $0. if (-not $costOk -and $mgFailed) { + $computeCost = 0.0 + $storageCost = 0.0 + $currenciesSeen.Clear() foreach ($sid in $subIds) { try { $body = @{ type = 'AmortizedCost' - timeframe = 'MonthToDate' + timeframe = 'Custom' + timePeriod = $costTimePeriod dataset = @{ granularity = 'None' aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } @@ -322,15 +335,15 @@ resources $path = "/subscriptions/$sid/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body - if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { - if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencySeen $currenciesSeen) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context "unit costs for $sid")) { + if (Add-MeterCosts -Content $page.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencySeen $currenciesSeen) { $costOk = $true $costScope = 'per-sub' } } } catch { - Write-Warning " Per-sub cost query failed for $sid : $($_.Exception.Message)" + throw "Per-sub cost query failed for $sid : $($_.Exception.Message)" } } } @@ -371,6 +384,8 @@ resources return [PSCustomObject]@{ HasData = $hasData Currency = Resolve-CurrencyLabel -Seen $currenciesSeen + CostPeriodStartUtc = $costPeriodStartUtc + CostPeriodEndUtc = $costPeriodEndUtc ComputeCost = [math]::Round($computeCost, 2) StorageCost = [math]::Round($storageCost, 2) ComputeSharePct = $computeSharePct diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 index 013083604..e0478e7e1 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 @@ -275,7 +275,7 @@ function Get-VmCostBreakdown { try { $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { - $data = $resp.Content | ConvertFrom-Json + $data = Get-CostQueryResult -FirstResponse $resp -Payload $body -Context "VM costs for $($vm.Name)" $cols = @($data.properties.columns.name) $iCost = [array]::IndexOf($cols, 'Cost') $iQty = [array]::IndexOf($cols, 'UsageQuantity') diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 index 648e4fcbf..2e890f12f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 @@ -20,10 +20,12 @@ param() # # Returns the raw response objects rather than parsed rows, because callers # read the payload differently (column-index lookups, row parsers). +# Throws without returning pages if the response chain is incomplete. # # ── Parameters ────────────────────────────────────────────── # FirstResponse The already-issued first-page response -# Context Label used in warnings so a partial total is attributable +# Context Label used in errors so a failed query is attributable +# Payload Original POST body for cost query and forecast continuations # MaxPages Bounds a pathological nextLink chain # # Prerequisites: @@ -44,6 +46,7 @@ function Resolve-NextLinkPath { if ([string]::IsNullOrWhiteSpace($NextLink)) { return $null } $trimmed = $NextLink.Trim() + if ($trimmed.StartsWith('//') -or $trimmed.Contains('\')) { return $null } if ($trimmed.StartsWith('/')) { return $trimmed } $uri = $null @@ -61,12 +64,17 @@ function Get-CostQueryResponsePage { [CmdletBinding()] param( [Parameter(Mandatory)] + [AllowNull()] [object]$FirstResponse, [Parameter()] [string]$Context = 'cost query', [Parameter()] + [string]$Payload, + + [Parameter()] + [ValidateRange(1, 1000)] [int]$MaxPages = 50, # The Cost Management query API nests nextLink under properties, while @@ -78,38 +86,124 @@ function Get-CostQueryResponsePage { $pages = [System.Collections.Generic.List[object]]::new() $resp = $FirstResponse $pageCount = 0 + $firstColumns = $null + $visitedLinks = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) - while ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { - [void]$pages.Add($resp) + while ($true) { $pageCount++ + if (-not $resp -or $resp.StatusCode -ne 200) { + $code = if ($resp) { [string]$resp.StatusCode } else { 'no response' } + throw "$Context : page $pageCount failed ($code); results are incomplete." + } + if ([string]::IsNullOrWhiteSpace($resp.Content)) { + throw "$Context : page $pageCount has no content; results are incomplete." + } - $next = $null try { - $parsed = $resp.Content | ConvertFrom-Json - $next = if ($RootNextLink) { $parsed.nextLink } else { $parsed.properties.nextLink } + $parsed = $resp.Content | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "$Context : page $pageCount contains invalid JSON; results are incomplete." + } + if ($RootNextLink) { + if ($parsed.value -isnot [array]) { + throw "$Context : page $pageCount is missing its value array; results are incomplete." + } + $next = $parsed.nextLink } - catch { $next = $null } + else { + if ($parsed.properties.rows -isnot [array] -or $parsed.properties.columns -isnot [array]) { + throw "$Context : page $pageCount is missing query rows or columns; results are incomplete." + } + $pageColumns = ConvertTo-Json -InputObject @($parsed.properties.columns | Select-Object name, type) -Depth 4 -Compress + if ($null -ne $firstColumns -and $pageColumns -ne $firstColumns) { + throw "$Context : columns changed on page $pageCount; results are incomplete." + } + $firstColumns = $pageColumns + $costIndexes = @( + for ($columnIndex = 0; $columnIndex -lt $parsed.properties.columns.Count; $columnIndex++) { + if ($parsed.properties.columns[$columnIndex].name -in @('Cost', 'PreTaxCost', 'CostUSD', 'TotalCost')) { $columnIndex } + } + ) + if ($parsed.properties.rows.Count -gt 0 -and $costIndexes.Count -eq 0) { + throw "$Context : page $pageCount is missing a cost column; results are incomplete." + } + foreach ($row in $parsed.properties.rows) { + if ($row -isnot [array] -or $row.Count -ne $parsed.properties.columns.Count) { + throw "$Context : page $pageCount contains an invalid row; results are incomplete." + } + foreach ($costIndex in $costIndexes) { + $amount = 0.0 + if (-not [double]::TryParse([string]$row[$costIndex], [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$amount) -or + [double]::IsNaN($amount) -or [double]::IsInfinity($amount)) { + throw "$Context : page $pageCount contains an invalid cost; results are incomplete." + } + } + } + $next = $parsed.properties.nextLink + } + + [void]$pages.Add($resp) if ([string]::IsNullOrWhiteSpace($next)) { break } $nextPath = Resolve-NextLinkPath -NextLink $next if (-not $nextPath) { - Write-Warning " $Context : ignoring an unexpected nextLink; totals may be incomplete." - break + throw "$Context : page $pageCount contains an unexpected nextLink; results are incomplete." + } + if (-not $visitedLinks.Add($nextPath)) { + throw "$Context : a continuation link repeated; results are incomplete." } if ($pageCount -ge $MaxPages) { - Write-Warning " $Context : stopped after $MaxPages pages; totals are incomplete." - break + throw "$Context : stopped after $MaxPages pages; results are incomplete." } - $resp = Invoke-AzRestMethodWithRetry -Path $nextPath -Method GET - # A failed continuation must be reported: silence here reads as lower cost. - if (-not $resp -or $resp.StatusCode -ne 200) { - $code = if ($resp) { [string]$resp.StatusCode } else { 'no response' } - Write-Warning " $Context : continuation page failed ($code); totals are incomplete." - break + if (-not $RootNextLink -and [string]::IsNullOrWhiteSpace($Payload)) { + throw "$Context : the original POST payload is required for pagination; results are incomplete." + } + try { + $resp = if ($RootNextLink) { + Invoke-AzRestMethodWithRetry -Path $nextPath -Method GET + } + else { + Invoke-AzRestMethodWithRetry -Path $nextPath -Method POST -Payload $Payload + } + } + catch { + throw "$Context : continuation request failed; results are incomplete. $($_.Exception.Message)" } } return $pages } + +function Get-CostQueryResult { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object]$FirstResponse, + + [Parameter(Mandatory)] + [string]$Payload, + + [Parameter()] + [string]$Context = 'cost query' + ) + + $rows = [System.Collections.Generic.List[object]]::new() + $columns = @() + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $FirstResponse -Payload $Payload -Context $Context)) { + $result = $page.Content | ConvertFrom-Json -ErrorAction Stop + $columns = $result.properties.columns + foreach ($row in $result.properties.rows) { [void]$rows.Add($row) } + } + + return [PSCustomObject]@{ + properties = [PSCustomObject]@{ + columns = $columns + rows = $rows.ToArray() + nextLink = $null + } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index 7374f0905..df270620f 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -104,14 +104,25 @@ function Get-KpiComputedValue { $v = Get-ScanField $Data 'CostPerVCpu' $cur = Get-ScanField $Data 'Currency' if ($null -ne $v -and $v -gt 0) { - $hourly = [math]::Round([double]$v / 730, 4) + $periodStart = Get-ScanField $Data 'CostPeriodStartUtc' + $periodEnd = Get-ScanField $Data 'CostPeriodEndUtc' + if ($null -ne $periodStart -and $null -ne $periodEnd) { + $periodStart = ([datetime]$periodStart).ToUniversalTime() + $periodEnd = ([datetime]$periodEnd).ToUniversalTime() + } + else { + $periodEnd = (Get-Date).ToUniversalTime() + $periodStart = $periodEnd.Date.AddDays(1 - $periodEnd.Day) + } + $elapsedHours = [math]::Max(($periodEnd - $periodStart).TotalHours, 1) + $hourly = [math]::Round([double]$v / $elapsedHours, 4) return (New-KpiValue "$cur $hourly per vCPU / hour" $hourly) } } 'effective-avg-compute-cost-per-core' { $v = Get-ScanField $Data 'CostPerVCpu' $cur = Get-ScanField $Data 'Currency' - if ($null -ne $v -and $v -gt 0) { return (New-KpiValue "$cur $v per vCPU / month" ([double]$v)) } + if ($null -ne $v -and $v -gt 0) { return (New-KpiValue "$cur $v per vCPU (month-to-date)" ([double]$v)) } } 'commitment-utilization-score' { $vals = @(Get-CommitmentUtilizationValue -Data $Data) diff --git a/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 index 4d7df4919..d94881a4f 100644 --- a/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 +++ b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 @@ -124,7 +124,8 @@ Describe 'Budget coverage reporting' { [PSCustomObject]@{ StatusCode = 403; Content = '{}' } } - $null = Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 6 -CostTrend $script:Trend2 -WarningAction SilentlyContinue + { Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 6 -CostTrend $script:Trend2 -WarningAction SilentlyContinue } | + Should -Throw '*403*incomplete*' # Without the coverage check the four uncovered months would be # reported as zero spend and therefore as being under budget. @@ -141,5 +142,37 @@ Describe 'Budget coverage reporting' { Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly @($rows).Count | Should -Be 6 } + + It 'Includes monthly spend from every page (continuation fails: )' -ForEach @( + @{ PageFails = $false } + @{ PageFails = $true } + ) { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + $isNextPage = $Path -like '*page=2' + if ($PageFails -and $isNextPage) { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $monthsAgo = if ($isNextPage) { -1 } else { -2 } + $amount = if ($isNextPage) { 120.0 } else { 40.0 } + $month = (Get-Date).AddMonths($monthsAgo).ToString('yyyyMM01') + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'BillingMonth' }, @{ name = 'Currency' }) + rows = @(, @($amount, $month, 'USD')) + } + if (-not $isNextPage) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + + if ($PageFails) { + { Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 2 } | Should -Throw '*incomplete*' + } + else { + $rows = @(Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 2) + $rows.Count | Should -Be 2 + ($rows | Measure-Object -Property ActualSpend -Sum).Sum | Should -Be 160 + @($rows | Where-Object Status -EQ 'Over').Count | Should -Be 1 + } + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and ($Payload | ConvertFrom-Json).dataset.granularity -eq 'Monthly' + } + } } } diff --git a/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 b/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 index e05057a58..55c41437b 100644 --- a/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 +++ b/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 @@ -122,6 +122,27 @@ Describe 'Commitment utilization de-duplication' { $result.SPAvgUtilization | Should -Be 50 } + It 'Does not return incomplete utilization after pagination fails' -ForEach @( + @{ Endpoint = 'reservationSummaries'; PayloadName = 'ReservationPayload' } + @{ Endpoint = 'benefitUtilizationSummaries'; PayloadName = 'SavingsPlanPayload' } + ) { + $pagedPayload = (Get-Variable -Name $PayloadName -Scope Script -ValueOnly) | ConvertFrom-Json + $pagedPayload | Add-Member -NotePropertyName nextLink -NotePropertyValue "/providers/Microsoft.Billing/billingAccounts/TEST-BA/$Endpoint`?page=2" + $firstPageContent = $pagedPayload | ConvertTo-Json -Depth 10 + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -like '*page=2') { [PSCustomObject]@{ StatusCode = 503; Content = '{}' } } + elseif ($Path -match 'billingAccounts\?') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path.Contains($Endpoint)) { [PSCustomObject]@{ StatusCode = 200; Content = $firstPageContent } } + else { [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } } + } + + { Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue } | Should -Throw '*incomplete*' + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'GET' -and [string]::IsNullOrEmpty($Payload) + } + } + Context 'Usage date comparison' { It 'Treats a newer ISO date as newer' { diff --git a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 index 63b2de887..9d6140de5 100644 --- a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 +++ b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 @@ -34,24 +34,31 @@ Describe 'Cost Management query pagination' { $pages.Count | Should -Be 1 } - It 'Returns nothing when the first response is not 200' { - $pages = @(Get-CostQueryResponsePage -FirstResponse (Get-FakeResponse -StatusCode 403)) - $pages.Count | Should -Be 0 + It 'Rejects a failed first response' { + { Get-CostQueryResponsePage -FirstResponse (Get-FakeResponse -StatusCode 403) } | + Should -Throw '*page 1 failed (403)*incomplete*' } - It 'Returns nothing when the response has no content' { - $pages = @(Get-CostQueryResponsePage -FirstResponse ([PSCustomObject]@{ StatusCode = 200; Content = $null })) - $pages.Count | Should -Be 0 + It 'Rejects missing response content' -ForEach @( + @{ Content = $null } + @{ Content = '' } + ) { + { Get-CostQueryResponsePage -FirstResponse ([PSCustomObject]@{ StatusCode = 200; Content = $Content }) } | + Should -Throw '*no content*incomplete*' } - It 'Returns nothing when the response is null' { - $pages = @(Get-CostQueryResponsePage -FirstResponse ([PSCustomObject]@{ StatusCode = 200; Content = '' })) - $pages.Count | Should -Be 0 + It 'Rejects a null response' { + { Get-CostQueryResponsePage -FirstResponse $null } | Should -Throw '*no response*incomplete*' } - It 'Tolerates a payload that is not valid JSON rather than throwing' { + It 'Rejects a payload that is not valid JSON' { $bad = [PSCustomObject]@{ StatusCode = 200; Content = 'not json at all' } - $pages = @(Get-CostQueryResponsePage -FirstResponse $bad -WarningAction SilentlyContinue) + { Get-CostQueryResponsePage -FirstResponse $bad } | Should -Throw '*invalid JSON*incomplete*' + } + + It 'Preserves a successful empty result' { + $empty = [PSCustomObject]@{ StatusCode = 200; Content = '{"properties":{"columns":[],"rows":[],"nextLink":null}}' } + $pages = @(Get-CostQueryResponsePage -FirstResponse $empty) $pages.Count | Should -Be 1 } @@ -61,6 +68,668 @@ Describe 'Cost Management query pagination' { @($parsed.properties.rows).Count | Should -Be 3 } + It 'Rejects a failed continuation without returning partial pages' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ StatusCode = 503; Content = '{}' } + } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + $returnedPages = [System.Collections.Generic.List[object]]::new() + + { + Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{"type":"ActualCost"}' | + ForEach-Object { [void]$returnedPages.Add($_) } + } | Should -Throw '*incomplete*' + + $returnedPages.Count | Should -Be 0 + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly + } + + It 'Replays the original POST payload and returns both pages exactly once' { + $nextPage = Get-FakeResponse -RowCount 2 + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { $nextPage } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' -RowCount 3 + $payload = '{"type":"ActualCost","timeframe":"MonthToDate"}' + + $pages = @(Get-CostQueryResponsePage -FirstResponse $firstPage -Payload $payload) + + $pages.Count | Should -Be 2 + @((($pages[0].Content | ConvertFrom-Json).properties.rows)).Count | Should -Be 3 + @((($pages[1].Content | ConvertFrom-Json).properties.rows)).Count | Should -Be 2 + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + $Path -eq '/subscriptions/x/q?page=2' -and $Method -eq 'POST' -and + $Payload -eq '{"type":"ActualCost","timeframe":"MonthToDate"}' + } + } + + It 'Combines complete pages into the parsed query result shape without flattening rows' { + $nextPage = Get-FakeResponse -RowCount 2 + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { $nextPage } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' -RowCount 3 + + $result = Get-CostQueryResult -FirstResponse $firstPage -Payload '{}' + + $result.properties.rows.Count | Should -Be 5 + $result.properties.rows[0].Count | Should -Be 3 + $result.properties.columns.name | Should -Be @('ResourceId', 'Cost', 'Currency') + $result.properties.nextLink | Should -BeNullOrEmpty + } + + It 'Rejects an invalid continuation payload ()' -ForEach @( + @{ Case = 'empty'; Content = '' } + @{ Case = 'invalid JSON'; Content = 'not json' } + @{ Case = 'missing query rows'; Content = '{"properties":{"columns":[]}}' } + @{ Case = 'missing query columns'; Content = '{"properties":{"rows":[]}}' } + @{ Case = 'truncated row'; Content = '{"properties":{"columns":[{"name":"ResourceId"},{"name":"Cost"},{"name":"Currency"}],"rows":[["resource",10]]}}' } + ) { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ StatusCode = 200; Content = $Content } + } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + { Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' } | Should -Throw '*incomplete*' + } + + It 'Rejects an unreadable cost before returning any pages ()' -ForEach @( + @{ Case = 'null'; Amount = $null } + @{ Case = 'blank'; Amount = '' } + @{ Case = 'invalid'; Amount = 'not-a-number' } + @{ Case = 'NaN'; Amount = 'NaN' } + @{ Case = 'infinity'; Amount = 'Infinity' } + @{ Case = 'overflow'; Amount = '1e999' } + ) { + $properties = @{ + columns = @(@{ name = 'ResourceId' }, @{ name = 'Cost' }, @{ name = 'Currency' }) + rows = @(, @('/subscriptions/x/r2', $Amount, 'USD')) + } + $badPage = [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { $badPage } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + $returnedPages = [System.Collections.Generic.List[object]]::new() + + { + Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' | + ForEach-Object { [void]$returnedPages.Add($_) } + } | Should -Throw '*cost*incomplete*' + $returnedPages.Count | Should -Be 0 + } + + It 'Preserves genuine zero and negative costs' -ForEach @( + @{ Amount = 0.0 } + @{ Amount = -12.5 } + ) { + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'Currency' }) + rows = @(, @($Amount, 'USD')) + } + $firstPage = [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + + $result = Get-CostQueryResult -FirstResponse $firstPage -Payload '{}' + + $result.properties.rows[0][0] | Should -Be $Amount + } + + It 'Rejects changed column order across pages' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ + StatusCode = 200 + Content = '{"properties":{"columns":[{"name":"Cost"},{"name":"ResourceId"},{"name":"Currency"}],"rows":[[20,"resource","USD"]]}}' + } + } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + { Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' } | Should -Throw '*columns changed*' + } + + It 'Rejects a repeated continuation link' { + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { $firstPage } + { Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' } | Should -Throw '*link repeated*' + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly + } + + It 'Rejects a truncated chain at the page limit' { + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + { Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' -MaxPages 1 } | Should -Throw '*stopped after 1 pages*' + } + + It 'Rejects an unexpected continuation URL without making a request' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw 'Must not send this request' } + $firstPage = Get-FakeResponse -NextLink 'https://example.com/page2' + { Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' } | Should -Throw '*unexpected nextLink*' + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + } + + It 'Requires the original payload before following a query continuation' { + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + { Get-CostQueryResponsePage -FirstResponse $firstPage } | Should -Throw '*original POST payload is required*' + } + + Context 'Actual and forecast totals' { + It 'Sums complete pages once (, empty first page: )' -ForEach @( + @{ QueryPath = 'PerSubscription'; EmptyFirstPage = $false } + @{ QueryPath = 'ManagementGroup'; EmptyFirstPage = $false } + @{ QueryPath = 'ManagementGroupFallback'; EmptyFirstPage = $false } + @{ QueryPath = 'ManagementGroupContinuationFallback'; EmptyFirstPage = $false } + @{ QueryPath = 'PerSubscription'; EmptyFirstPage = $true } + @{ QueryPath = 'ManagementGroup'; EmptyFirstPage = $true } + @{ QueryPath = 'ManagementGroupFallback'; EmptyFirstPage = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath; EmptyFirstPage = $EmptyFirstPage } { + param($QueryPath, $EmptyFirstPage) + + Mock Resolve-CostMgId { 'test-management-group' } + Mock Invoke-AzRestMethodWithRetry { + $isForecast = $Path -like '*forecast*' + $isNextPage = $Path -like '*page=2' + $failManagementGroup = $QueryPath -eq 'ManagementGroupFallback' -or ($QueryPath -eq 'ManagementGroupContinuationFallback' -and $isNextPage) + if ($failManagementGroup -and $isForecast -and $Path -like '/providers/Microsoft.Management/*') { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $amount = if ($isForecast) { if ($isNextPage) { 100.0 } else { 250.0 } } else { if ($isNextPage) { 25.0 } else { 100.0 } } + $body = @{ + properties = @{ + columns = @(@{ name = 'Currency' }, @{ name = 'SubscriptionId' }, @{ name = 'Cost' }) + rows = @(, @('USD', '11111111-1111-1111-1111-111111111111', $amount)) + } + } + if (-not $isNextPage) { + $body.properties.nextLink = "$Path&page=2" + if ($EmptyFirstPage) { $body.properties.rows = @() } + } + [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + + $result = if ($QueryPath -eq 'PerSubscription') { + Get-CostDataPerSubscription -Subscriptions $subscriptions + } + else { + Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + } + + $entry = $result[$subscriptions[0].Id] + $entry.Actual | Should -Be $(if ($EmptyFirstPage) { 25 } else { 125 }) + $entry.Forecast | Should -Be $(if ($EmptyFirstPage) { 100 } else { 350 }) + $entry.ForecastSource | Should -Be 'Forecast' + $continuationCalls = if ($QueryPath -eq 'ManagementGroupContinuationFallback') { 3 } else { 2 } + Should -Invoke Invoke-AzRestMethodWithRetry -Times $continuationCalls -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and + ($Payload | ConvertFrom-Json).dataset.aggregation.totalCost.function -eq 'Sum' + } + } + } + + It 'Returns no cost map when forecast pagination cannot be completed (, fallback unavailable: )' -ForEach @( + @{ QueryPath = 'PerSubscription'; FallbackUnavailable = $false } + @{ QueryPath = 'ManagementGroup'; FallbackUnavailable = $false } + @{ QueryPath = 'ManagementGroup'; FallbackUnavailable = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath; FallbackUnavailable = $FallbackUnavailable } { + param($QueryPath, $FallbackUnavailable) + + $usePerSubscription = $QueryPath -eq 'PerSubscription' + $failSubscriptionRetry = $FallbackUnavailable + Mock Resolve-CostMgId { 'test-management-group' } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + if ($failSubscriptionRetry -and $Path -like '/subscriptions/*/forecast*') { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $isForecast = $Path -like '*forecast*' + $amount = if ($isForecast) { 250.0 } else { 100.0 } + $body = @{ + properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'SubscriptionId' }, @{ name = 'Currency' }) + rows = @(, @($amount, '11111111-1111-1111-1111-111111111111', 'USD')) + } + } + if ($isForecast) { $body.properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + + $returnedResults = [System.Collections.Generic.List[object]]::new() + { + $result = if ($usePerSubscription) { + Get-CostDataPerSubscription -Subscriptions $subscriptions + } + else { + Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions -WarningAction SilentlyContinue + } + [void]$returnedResults.Add($result) + } | Should -Throw '*incomplete*' + $returnedResults.Count | Should -Be 0 + } + } + + It 'Does not return partial actual cost after a continuation fails' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + [pscustomobject]@{ + StatusCode = 200 + Content = '{"properties":{"columns":[{"name":"Cost"},{"name":"Currency"}],"rows":[[100,"USD"]],"nextLink":"/subscriptions/x/query?page=2"}}' + } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + { Get-CostDataPerSubscription -Subscriptions $subscriptions } | Should -Throw '*incomplete*' + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly -ParameterFilter { $Path -like '*forecast*' } + } + } + } + + Context 'Resource costs' { + It 'Reads every resource page without retaining a failed MG attempt ()' -ForEach @( + @{ QueryPath = 'PerSubscription' } + @{ QueryPath = 'ManagementGroup' } + @{ QueryPath = 'ManagementGroupFallback' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath } { + param($QueryPath) + + Mock Resolve-CostMgId { if ($QueryPath -ne 'PerSubscription') { 'test-management-group' } } + Mock Get-Date { [datetime]'2026-09-16T12:00:00Z' } + Mock Get-Date { [datetime]'2026-09-01T00:00:00' } -ParameterFilter { $Day -eq 1 } + Mock Invoke-AzRestMethodWithRetry { + $isNextPage = $Path -like '*page=2' + if ($QueryPath -eq 'ManagementGroupFallback' -and $Path -like '/providers/Microsoft.Management/*' -and $isNextPage) { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + if ($Path -like '*forecast*') { + $amount = if ($isNextPage) { 100.0 } else { 400.0 } + $properties = @{ + columns = @(@{ name = 'CostStatus' }, @{ name = 'Cost' }, @{ name = 'Currency' }) + rows = @(, @('Forecast', $amount, 'USD')) + } + } + else { + $amount = if ($isNextPage) { 25.0 } else { 100.0 } + $resourceName = if ($isNextPage) { 'second' } else { 'first' } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }) + rows = @(, @($amount, "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/$resourceName", 'test', 'USD')) + } + } + if (-not $isNextPage) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + + $result = @(Get-ResourceCosts -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -WarningAction SilentlyContinue) + + $result.Count | Should -Be 2 + ($result | Measure-Object -Property Actual -Sum).Sum | Should -Be 125 + if ($QueryPath -ne 'ManagementGroup') { + ($result | Measure-Object -Property Forecast -Sum).Sum | Should -Be 500 + Should -Invoke Invoke-AzRestMethodWithRetry -Times 2 -Exactly -ParameterFilter { + $request = $Payload | ConvertFrom-Json + $Path -like '*forecast*' -and $Method -eq 'POST' -and $request.timePeriod.from -eq '2026-09-01' + } + } + $continuationCalls = switch ($QueryPath) { 'ManagementGroup' { 1 } 'ManagementGroupFallback' { 3 } default { 2 } } + Should -Invoke Invoke-AzRestMethodWithRetry -Times $continuationCalls -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and -not [string]::IsNullOrWhiteSpace($Payload) + } + } + } + + It 'Rejects incomplete per-resource results' -ForEach @( + @{ Operation = 'query' } + @{ Operation = 'forecast' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Operation = $Operation } { + param($Operation) + + $failedOperation = $Operation + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }) + rows = @(, @(100.0, '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/first', 'test', 'USD')) + } + if ($Path.Contains("/$failedOperation`?")) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + { Get-ResourceCosts -Subscriptions $subscriptions } | Should -Throw '*incomplete*' + } + } + } + + It 'Leaves orphan cost unavailable when its continuation fails' { + InModuleScope FinOpsMultitool { + Mock Search-AzGraphSafe { + $rows = @() + if ($Query.Contains("properties.diskState == 'Unattached'")) { + $rows = @([pscustomobject]@{ + id = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/first' + name = 'first'; resourceGroup = 'test'; subscriptionId = '11111111-1111-1111-1111-111111111111' + location = 'eastus'; diskSizeGb = 128; sku = 'Premium_LRS' + }) + } + [pscustomobject]@{ Data = $rows } + } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $body = @{ + properties = @{ + columns = @(@{ name = 'ResourceId' }, @{ name = 'Cost' }) + rows = @(, @('/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/first', 100.0)) + nextLink = "$Path&page=2" + } + } + [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + + $result = Get-OrphanedResources -Subscriptions $subscriptions + + $result.TotalCount | Should -Be 1 + $result.CostAvailable | Should -BeFalse + $result.CostedCount | Should -Be 0 + $result.MonthlyCost | Should -BeNullOrEmpty + $result.Orphans[0].MonthlyCost | Should -BeNullOrEmpty + $result.CostPeriod | Should -BeNullOrEmpty + $result.CostIssue | Should -BeLike '*incomplete*' + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and ($Payload | ConvertFrom-Json).type -eq 'ActualCost' + } + } + } + + Context 'Parsed query consumers' { + It 'Requires complete cost pages (continuation fails: )' -ForEach @( + @{ Scan = 'AI'; PageFails = $false } + @{ Scan = 'AI'; PageFails = $true } + @{ Scan = 'VM'; PageFails = $false } + @{ Scan = 'VM'; PageFails = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Scan = $Scan; PageFails = $PageFails } { + param($Scan, $PageFails) + + $failContinuation = $PageFails + $targetResourceId = if ($Scan -eq 'AI') { + '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.CognitiveServices/accounts/test' + } + else { + '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/virtualMachines/test' + } + Mock Resolve-CostMgId { 'test-management-group' } + Mock Search-AzGraphSafe { + [pscustomobject]@{ Data = @([pscustomobject]@{ id = $targetResourceId; type = 'microsoft.cognitiveservices/accounts'; lkind = 'TextAnalytics' }) } + } + Mock Resolve-VmAssociation { + $associated = [System.Collections.Generic.HashSet[string]]::new() + [void]$associated.Add($targetResourceId) + [pscustomobject]@{ + Id = $targetResourceId; Name = 'test'; SubscriptionId = '11111111-1111-1111-1111-111111111111' + ResourceGroup = 'test'; Associated = $associated + } + } + Mock Invoke-AzRestMethodWithRetry { + $isNextPage = $Path -like '*page=2' + if ($failContinuation -and $isNextPage) { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $amount = if ($isNextPage) { 25.0 } else { 100.0 } + $category = if ($isNextPage) { 'Storage' } else { 'Virtual Machines' } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'MeterCategory' }, @{ name = 'Currency' }, @{ name = 'UsageQuantity' }) + rows = @(, @($amount, $targetResourceId, $category, 'USD', 1.0)) + } + if (-not $isNextPage) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + + if ($Scan -eq 'AI') { + if ($PageFails) { + { Get-AIWorkloadMetrics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } | Should -Throw '*incomplete*' + } + else { + $result = Get-AIWorkloadMetrics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + $result.TotalAICost | Should -Be 125 + } + } + else { + $result = Get-VmCostBreakdown -VmName 'test' -Subscriptions $subscriptions + if ($PageFails) { + $result.HasData | Should -BeFalse + $result.TotalCost | Should -BeNullOrEmpty + $result.Note | Should -BeLike '*incomplete*' + } + else { + $result.HasData | Should -BeTrue + $result.TotalCost | Should -Be 125 + } + } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and ($Payload | ConvertFrom-Json).type -eq 'AmortizedCost' + } + } + } + } + + Context 'Cost-only scan failures' { + It 'Does not return a successful scan after a failed continuation ()' -ForEach @( + @{ Scan = 'Trend' } + @{ Scan = 'SharedAllocation' } + @{ Scan = 'Savings' } + @{ Scan = 'SavingsFallback' } + @{ Scan = 'UnitEconomics' } + ) { + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + InModuleScope FinOpsMultitool -Parameters @{ Scan = $Scan; FirstPage = $firstPage } { + param($Scan, $FirstPage) + + $scanName = $Scan + $initialResponse = $FirstPage + Mock Search-AzGraphSafe { [pscustomobject]@{ Data = @() } } + Mock Resolve-CostMgId { if ($scanName -eq 'SavingsFallback') { 'test-management-group' } } + Mock Get-StorageAccountUsedGb { 0.0 } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + if ($scanName -eq 'SavingsFallback' -and $Path -like '/subscriptions/*') { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $initialResponse + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + if ($scanName -eq 'SavingsFallback') { + $subscriptions += [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'second' } + } + { + switch ($scanName) { + 'Trend' { Get-CostTrend -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + 'SharedAllocation' { Get-AllocationCostMaps -SubscriptionIds $subscriptions.Id } + 'Savings' { Get-SavingsRealized -Subscriptions $subscriptions } + 'SavingsFallback' { Get-SavingsRealized -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + 'UnitEconomics' { Get-UnitEconomics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + } + } | Should -Throw '*incomplete*' + + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and -not [string]::IsNullOrWhiteSpace($Payload) + } + } + } + } + + Context 'Required first responses' { + It 'Does not publish totals after a required first response fails ()' -ForEach @( + @{ Scan = 'Trend' } + @{ Scan = 'TrendPartial' } + @{ Scan = 'Forecast' } + @{ Scan = 'ResourceForecast' } + @{ Scan = 'Savings' } + @{ Scan = 'BudgetHistory' } + @{ Scan = 'AI' } + @{ Scan = 'UnitEconomics' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Scan = $Scan } { + param($Scan) + + $scanName = $Scan + Mock Resolve-CostMgId { if ($scanName -ne 'TrendPartial') { 'test-management-group' } } + Mock Get-StorageAccountUsedGb { 0.0 } + Mock Search-AzGraphSafe { + $rows = if ($scanName -eq 'AI') { + @([pscustomobject]@{ type = 'microsoft.cognitiveservices/accounts'; lkind = 'TextAnalytics' }) + } + else { @() } + [pscustomobject]@{ Data = $rows } + } + Mock Invoke-AzRestMethodWithRetry { + if ($scanName -eq 'ResourceForecast' -and $Path -notlike '*forecast*') { + return [pscustomobject]@{ + StatusCode = 200 + Content = '{"properties":{"columns":[{"name":"Cost"},{"name":"ResourceId"},{"name":"ResourceGroupName"},{"name":"Currency"}],"rows":[[100,"/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/test","test","USD"]]}}' + } + } + if ($scanName -eq 'Forecast' -and $Path -notlike '*forecast*') { + return [pscustomobject]@{ + StatusCode = 200 + Content = '{"properties":{"columns":[{"name":"Cost"},{"name":"Currency"}],"rows":[[100,"USD"]]}}' + } + } + if ($scanName -eq 'TrendPartial' -and $Path -like '/subscriptions/11111111-*') { + return [pscustomobject]@{ + StatusCode = 200 + Content = '{"properties":{"columns":[{"name":"Cost","type":"Number"},{"name":"BillingMonth","type":"DateTime"},{"name":"Currency","type":"String"}],"rows":[[100,"2026-08-01","USD"]]}}' + } + } + [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'first' }) + if ($scanName -eq 'TrendPartial') { + $subscriptions += [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'second' } + } + $budgets = @([pscustomobject]@{ SubscriptionId = $subscriptions[0].Id; Subscription = 'first'; Amount = 1000; BudgetName = 'test'; TimeGrain = 'Monthly' }) + + { + switch ($scanName) { + { $_ -in 'Trend', 'TrendPartial' } { Get-CostTrend -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + 'Forecast' { Get-CostDataPerSubscription -Subscriptions $subscriptions } + 'ResourceForecast' { Get-ResourceCosts -Subscriptions $subscriptions } + 'Savings' { Get-SavingsRealized -Subscriptions $subscriptions } + 'BudgetHistory' { Get-BudgetHistory -Budgets $budgets -MonthsBack 1 } + 'AI' { Get-AIWorkloadMetrics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + 'UnitEconomics' { Get-UnitEconomics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + } + } | Should -Throw '*incomplete*' + } + } + + It 'Requires a successful cost-by-tag batch response (HTTP )' -ForEach @( + @{ StatusCode = 200 } + @{ StatusCode = 503 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ ResponseStatus = $StatusCode } { + param($ResponseStatus) + + Mock Search-AzGraphSafe { [pscustomobject]@{ Data = @() } } + $sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + $sessionState.Variables.Add([System.Management.Automation.Runspaces.SessionStateVariableEntry]::new('FixtureStatus', $ResponseStatus, 'Mock response status')) + $sessionState.Commands.Add([System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new('Invoke-AzRestMethod', @' +param($Path, $Method, $Payload) +[pscustomobject]@{ + StatusCode = $FixtureStatus + Content = '{"properties":{"columns":[{"name":"Cost"},{"name":"ResourceId"},{"name":"Currency"}],"rows":[[125,"/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/test","USD"]]}}' + Headers = @{} +} +'@)) + $pool = [runspacefactory]::CreateRunspacePool(1, 2, $sessionState, $Host) + $previousPool = $script:RunspacePool + try { + $pool.Open() + $script:RunspacePool = $pool + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'first' }) + $arguments = @{ TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Subscriptions = $subscriptions; ExistingTags = @{ CostCenter = @{ TotalResources = 1 } } } + if ($ResponseStatus -eq 503) { + { Get-CostByTag @arguments } | Should -Throw '*503*incomplete*' + } + else { + $result = Get-CostByTag @arguments + ($result.CostByTag.CostCenter | Measure-Object Cost -Sum).Sum | Should -Be 125 + } + } + finally { + $script:RunspacePool = $previousPool + $pool.Dispose() + } + } + } + } + + Context 'Savings and unit totals' { + It 'Completes pages and discards failed MG data (fallback: )' -ForEach @( + @{ Scan = 'Savings'; UseFallback = $false } + @{ Scan = 'Savings'; UseFallback = $true } + @{ Scan = 'UnitEconomics'; UseFallback = $false } + @{ Scan = 'UnitEconomics'; UseFallback = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Scan = $Scan; UseFallback = $UseFallback } { + param($Scan, $UseFallback) + + $scanName = $Scan + $failManagementGroup = $UseFallback + Mock Search-AzGraphSafe { [pscustomobject]@{ Data = @() } } + Mock Resolve-CostMgId { 'test-management-group' } + Mock Get-StorageAccountUsedGb { 0.0 } + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + $isNextPage = $Path -like '*page=2' + if ($failManagementGroup -and $request.type -eq 'AmortizedCost' -and $Path -like '/providers/Microsoft.Management/*' -and $isNextPage) { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $amount = if ($isNextPage) { 25.0 } else { 100.0 } + $dimension = if ($scanName -eq 'UnitEconomics') { 'MeterCategory' } elseif ($request.type -eq 'ActualCost') { 'ChargeType' } else { 'PricingModel' } + $category = switch ($dimension) { + 'MeterCategory' { if ($isNextPage) { 'Storage' } else { 'Virtual Machines' } } + 'ChargeType' { 'UnusedReservation' } + 'PricingModel' { if ($isNextPage) { 'SavingsPlan' } else { 'Reservation' } } + } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = $dimension }, @{ name = 'Currency' }) + rows = @(, @($amount, $category, 'USD')) + } + if (-not $isNextPage) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'first' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'second' } + ) + + $factor = if ($UseFallback) { 2 } else { 1 } + if ($Scan -eq 'Savings') { + $result = Get-SavingsRealized -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -WarningAction SilentlyContinue + $result.CommittedAmortized | Should -Be (125 * $factor) + $result.RISavingsMonthly | Should -Be ([math]::Round(100 * $factor * 0.4 / 0.6, 2)) + $result.SPSavingsMonthly | Should -Be ([math]::Round(25 * $factor * 0.25 / 0.75, 2)) + $waste = @($result.Details | Where-Object Type -EQ 'Waste') + ($waste | Measure-Object -Property Amount -Sum).Sum | Should -Be (125 * $factor) + $continuationCalls = if ($UseFallback) { 6 } else { 2 } + } + else { + $result = Get-UnitEconomics -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -WarningAction SilentlyContinue + $result.ComputeCost | Should -Be (100 * $factor) + $result.StorageCost | Should -Be (25 * $factor) + $result.CostPeriodStartUtc.Kind | Should -Be ([DateTimeKind]::Utc) + $result.CostPeriodEndUtc.Kind | Should -Be ([DateTimeKind]::Utc) + $expectedStart = $result.CostPeriodStartUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + $expectedEnd = $result.CostPeriodEndUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly -ParameterFilter { + $request = $Payload | ConvertFrom-Json + $request.timeframe -ne 'Custom' -or + ([datetime]$request.timePeriod.from).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -ne $expectedStart -or + ([datetime]$request.timePeriod.to).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -ne $expectedEnd + } + $continuationCalls = if ($UseFallback) { 3 } else { 1 } + } + Should -Invoke Invoke-AzRestMethodWithRetry -Times $continuationCalls -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and -not [string]::IsNullOrWhiteSpace($Payload) + } + } + } + } + Context 'Root-level nextLink' { # The Consumption and benefit list APIs return nextLink at the root, # while the Cost Management query API nests it under properties. @@ -79,12 +748,13 @@ Describe 'Cost Management query pagination' { $pages = @(Get-CostQueryResponsePage -FirstResponse $script:RootLinkResponse -RootNextLink) $pages.Count | Should -Be 2 + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + $Method -eq 'GET' -and [string]::IsNullOrEmpty($Payload) + } } - It 'Ignores it by default so the query API behaviour is unchanged' { - $pages = @(Get-CostQueryResponsePage -FirstResponse $script:RootLinkResponse) - - $pages.Count | Should -Be 1 + It 'Rejects a list response without RootNextLink rather than missing its continuation' { + { Get-CostQueryResponsePage -FirstResponse $script:RootLinkResponse } | Should -Throw '*missing query rows or columns*' } } @@ -103,6 +773,8 @@ Describe 'Cost Management query pagination' { @{ Case = 'a foreign host'; Link = 'https://evil.example.com/steal?a=1' } @{ Case = 'a non-https scheme'; Link = 'http://management.azure.com/x' } @{ Case = 'a malformed value'; Link = 'not a url' } + @{ Case = 'a protocol-relative URL'; Link = '//example.com/page2' } + @{ Case = 'a backslash path'; Link = '/\example.com/page2' } @{ Case = 'an empty value'; Link = '' } @{ Case = 'a null value'; Link = $null } ) { diff --git a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 index 927197688..99e1c4c73 100644 --- a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 +++ b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 @@ -293,7 +293,7 @@ Describe 'FinOps Multitool cost math' { } } - It 'Flags the fallback when no forecast is available' { + It 'Rejects an unavailable forecast instead of returning actual spend as a projection' { InModuleScope FinOpsMultitool { Mock Invoke-AzRestMethodWithRetry { if ($Path -like '*forecast*') { @@ -312,9 +312,7 @@ Describe 'FinOps Multitool cost math' { } $subs = @([pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'test' }) - $result = Get-CostDataPerSubscription -Subscriptions $subs - - $result['22222222-2222-2222-2222-222222222222'].ForecastSource | Should -Be 'Actual' + { Get-CostDataPerSubscription -Subscriptions $subs } | Should -Throw '*Forecast*404*incomplete*' } } } @@ -368,4 +366,36 @@ Describe 'FinOps Multitool cost math' { } } } + + Context 'Hourly cost reporting' { + It 'Uses the UTC month instead of a local calendar that is still in August' { + InModuleScope FinOpsMultitool { + Mock Get-Date { [datetime]::new(2026, 9, 1, 2, 0, 0, [DateTimeKind]::Utc) } + Mock Get-Date { [datetime]::new(2026, 8, 1) } -ParameterFilter { $Day -eq 1 } + $data = [pscustomobject]@{ CostPerVCpu = 2.0; Currency = 'USD' } + + $result = Get-KpiComputedValue -KpiId 'hourly-cost-per-cpu-core' -Data $data + + $result.Value | Should -Be 1.0 + } + } + + It 'Uses the captured cost window when the report is rendered later' { + InModuleScope FinOpsMultitool { + Mock Get-Date { [datetime]::new(2026, 10, 2, 0, 0, 0, [DateTimeKind]::Utc) } + Mock Get-Date { [datetime]::new(2026, 10, 1) } -ParameterFilter { $Day -eq 1 } + $data = [pscustomobject]@{ + CostPerVCpu = 384.0 + Currency = 'USD' + CostPeriodStartUtc = [datetime]::new(2026, 9, 1, 0, 0, 0, [DateTimeKind]::Utc) + CostPeriodEndUtc = [datetime]::new(2026, 9, 17, 0, 0, 0, [DateTimeKind]::Utc) + } + + $result = Get-KpiComputedValue -KpiId 'hourly-cost-per-cpu-core' -Data $data + + $result.Value | Should -Be 1.0 + Should -Invoke Get-Date -Times 0 -Exactly + } + } + } } From 787504abd1fc648c6ab665635df6b073447d5c22 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Thu, 17 Sep 2026 17:10:46 -0600 Subject: [PATCH 133/142] fix(multitool): FinOps Multitool Update - Sep 17 Review findings - Separate billed and amortized costs; reject invalid amounts and incomplete coverage. - Preserve subscription scope, currencies, credits, and observed periods. - Keep unknown budget forecasts and unsupported history unavailable. - Surface hub source failures and unverified financial KPIs. --- .../Invoke-FinOpsMultitool.ps1 | 234 +++-- .../modules/Get-AIWorkloadMetrics.ps1 | 4 +- .../modules/Get-BudgetStatus.ps1 | 325 +++---- .../modules/Get-SharedCostAllocation.ps1 | 23 +- .../modules/Get-VmCostBreakdown.ps1 | 20 +- .../modules/helpers/Get-CostExport.ps1 | 266 ++++-- .../modules/helpers/Get-FOHubProvider.ps1 | 135 ++- .../modules/helpers/Get-KpiInsights.ps1 | 109 ++- .../modules/helpers/Read-FinOpsHubData.ps1 | 227 +++-- .../Tests/Unit/BudgetCoverage.Tests.ps1 | 61 +- .../Tests/Unit/CostQueryPagination.Tests.ps1 | 5 +- .../Tests/Unit/FOHubProvider.Tests.ps1 | 76 +- .../Tests/Unit/MultitoolSafety.Tests.ps1 | 871 +++++++++++++++++- 13 files changed, 1793 insertions(+), 563 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index b70afda99..8a75cb17e 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -863,6 +863,7 @@ function Invoke-FinOpsMultitool { $hubRaw = $null $hubTagInventory = $null $hubCostByTag = $null + $hubScanErrors = @{} # Scalable Kusto path: when a FinOps Hub Kusto database is reachable # (a FINOPS_HUB_KUSTO_URI override for an ftklocal emulator or a pinned @@ -888,15 +889,15 @@ function Invoke-FinOpsMultitool { $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)") } + if ($cs -is [System.Collections.IDictionary] -and $cs.Contains('Error') -and $cs.Error) { $hubErrors.Add("cost summary: $($cs.Error)"); $hubScanErrors['Get-CostData'] = $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)") } + if ($rc -is [System.Collections.IDictionary] -and $rc.Contains('Error') -and $rc.Error) { $hubErrors.Add("resource costs: $($rc.Error)"); $hubScanErrors['Get-ResourceCosts'] = $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)") } + if ($ct -is [System.Collections.IDictionary] -and $ct.Contains('Error') -and $ct.Error) { $hubErrors.Add("cost by tag: $($ct.Error)"); $hubScanErrors['Get-CostByTag'] = $ct.Error } else { $hubCostByTag = $ct; $hubOk++ } if ($hubOk -gt 0) { @@ -906,9 +907,7 @@ function Invoke-FinOpsMultitool { 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 + Write-Host ' Hub cost results are unavailable. Select API as the data source to run a separate live scan.' -ForegroundColor Yellow } } elseif ($DataSource.HubStorage) { @@ -928,6 +927,9 @@ function Invoke-FinOpsMultitool { } catch { Write-Host " Hub data load failed: $($_.Exception.Message)" -ForegroundColor Yellow + if ($DataSource.Source -eq 'Hub') { + foreach ($scan in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-AIWorkloadMetrics')) { $hubScanErrors[$scan] = $_.Exception.Message } + } $hubRaw = $null } if ($hubRaw -and @($hubRaw).Count -gt 0) { @@ -978,102 +980,34 @@ function Invoke-FinOpsMultitool { } if ($DataSource.Source -eq 'Hub') { - $hubCostData = ConvertTo-CostDataFromHub -HubData $hubRaw - $hubResourceCosts = ConvertTo-ResourceCostsFromHub -HubData $hubRaw - - # Hub exports are historical actuals — enrich with live forecast from Cost Management API try { - Write-Host " Fetching forecast data from Cost Management API..." -ForegroundColor DarkGray - $now = Get-Date - $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) - $forecastFilled = $false - - # Try MG-scope forecast first - $fctTenantId = (Get-AzContext).Tenant.Id - if ($fctTenantId) { - $fctBody = @{ - type = 'Usage' - timeframe = 'Custom' - timePeriod = @{ - from = $now.ToString('yyyy-MM-dd') - to = $monthEnd.ToString('yyyy-MM-dd') - } - dataset = @{ - granularity = 'None' - aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } - grouping = @(@{ type = 'Dimension'; name = 'SubscriptionId' }) - } - includeActualCost = $false - includeFreshPartialCost = $false - } | ConvertTo-Json -Depth 10 - - $fctPath = "/providers/Microsoft.Management/managementGroups/$fctTenantId/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" - $fctResp = Invoke-AzRestMethodWithRetry -Path $fctPath -Method POST -Payload $fctBody - if ($fctResp.StatusCode -eq 200) { - $fctResult = ($fctResp.Content | ConvertFrom-Json) - if ($fctResult.properties.rows -and $fctResult.properties.rows.Count -gt 0) { - $fctSums = @{} - foreach ($row in $fctResult.properties.rows) { - $subId = $row[1] - if (-not $fctSums.ContainsKey($subId)) { $fctSums[$subId] = 0 } - $fctSums[$subId] += [double]$row[0] - } - foreach ($subId in $fctSums.Keys) { - if ($hubCostData.ContainsKey($subId)) { - # Full-month projection = actual MTD + remaining forecast - $actual = $hubCostData[$subId].Actual - $hubCostData[$subId].Forecast = [math]::Round($actual + $fctSums[$subId], 2) - } - } - $forecastFilled = $true - Write-Host " Forecast data loaded for $($fctSums.Count) subscription(s)" -ForegroundColor Green - } - } - } - - # Per-subscription fallback if MG-scope failed - if (-not $forecastFilled -and $Subscriptions) { - $fctHits = 0 - foreach ($sub in $Subscriptions) { - try { - $fBody = @{ - type = 'Usage' - timeframe = 'Custom' - timePeriod = @{ - from = $now.ToString('yyyy-MM-dd') - to = $monthEnd.ToString('yyyy-MM-dd') - } - dataset = @{ - granularity = 'None' - aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } - } - includeActualCost = $false - includeFreshPartialCost = $false - } | ConvertTo-Json -Depth 10 - $fResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" -Method POST -Payload $fBody - if ($fResp.StatusCode -eq 200) { - $fRes = ($fResp.Content | ConvertFrom-Json) - if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) { - $fctTotal = 0 - foreach ($row in $fRes.properties.rows) { $fctTotal += [double]$row[0] } - if ($hubCostData.ContainsKey($sub.Id)) { - # Full-month projection = actual MTD + remaining forecast - $actual = $hubCostData[$sub.Id].Actual - $hubCostData[$sub.Id].Forecast = [math]::Round($actual + $fctTotal, 2) - $fctHits++ - } - } - } - } - catch { - Write-Verbose "Non-fatal: $($_.Exception.Message)" + $hubCostData = ConvertTo-CostDataFromHub -HubData $hubRaw + $hubResourceCosts = ConvertTo-ResourceCostsFromHub -HubData $hubRaw + } + catch { + foreach ($scan in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag')) { $hubScanErrors[$scan] = $_.Exception.Message } + $hubCostData = $null + $hubResourceCosts = $null + } + $currentMonth = (Get-Date).ToUniversalTime() + $currentMonth = $currentMonth.Date.AddDays(1 - $currentMonth.Day) + $forecastSubscriptions = @($Subscriptions | Where-Object { + $entry = if ($hubCostData) { $hubCostData[$_.Id] } else { $null } + $entry -and $null -ne $entry.ActualPeriodStart -and $null -ne $entry.ActualPeriodEnd -and + $entry.ActualPeriodStart -ge $currentMonth -and $entry.ActualPeriodEnd -lt $currentMonth.AddMonths(1) + }) + if ($hubCostData -and $forecastSubscriptions.Count -gt 0) { + try { + $liveCost = Get-CostData -TenantId $TenantId -Subscriptions $forecastSubscriptions -RestrictToSelected + foreach ($subId in $forecastSubscriptions.Id) { + $forecast = $liveCost[$subId] + if ($forecast -and $forecast.ForecastSource -eq 'Forecast' -and $forecast.Currency -eq $hubCostData[$subId].Currency) { + $hubCostData[$subId].Forecast = $forecast.Forecast + $hubCostData[$subId].ForecastSource = 'Cost Management API (current month)' } } - if ($fctHits -gt 0) { Write-Host " Forecast data loaded for $fctHits subscription(s)" -ForegroundColor Green } } - } - catch { - Write-Host " Forecast data unavailable: $($_.Exception.Message)" -ForegroundColor DarkGray + catch { Write-Host " Live forecast unavailable; hub actuals remain available. $($_.Exception.Message)" -ForegroundColor Yellow } } Write-Host " Hub data loaded: $(@($hubRaw).Count) cost records, $($hubTagInventory.TagCount) tags, $($hubTagInventory.TagCoverage)% coverage" -ForegroundColor Green @@ -1085,8 +1019,10 @@ function Invoke-FinOpsMultitool { else { $hubRaw = $null if ($DataSource.Source -eq 'Hub') { - Write-Host " No Hub data found — falling back to Cost Management API" -ForegroundColor Yellow - $DataSource.Source = 'API' + foreach ($scan in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-AIWorkloadMetrics')) { + if (-not $hubScanErrors.ContainsKey($scan)) { $hubScanErrors[$scan] = 'No hub data is available; cost coverage is incomplete.' } + } + Write-Host ' Hub data is unavailable. Select API as the data source to run a separate live scan.' -ForegroundColor Yellow } } if ($DataSource.Source -eq 'Hub') { Write-Host "" } @@ -1113,6 +1049,7 @@ function Invoke-FinOpsMultitool { try { $fn = $mod.Fn $output = $null + if ($hubScanErrors.ContainsKey($fn)) { throw $hubScanErrors[$fn] } # Route parameters based on what each function expects # Hub shortcut: return pre-loaded Hub data for cost/tag modules @@ -1199,6 +1136,9 @@ function Invoke-FinOpsMultitool { # spend + token volume come from the export, not the # Monitor + Cost Management APIs. $aiParams = @{ TenantId = $TenantId; Subscriptions = $Subscriptions } + if ($DataSource.Source -eq 'Hub' -and (-not $hubRaw -or @($hubRaw).Count -eq 0)) { + throw 'AI metrics are unavailable for the selected Kusto hub source. Select API as the data source for a separate live scan.' + } if ($DataSource.Source -eq 'Hub' -and $hubRaw -and @($hubRaw).Count -gt 0) { $aiParams['HubData'] = $hubRaw } @@ -1634,12 +1574,14 @@ function Invoke-FinOpsMultitool { else { $_.Key.Substring(0, [Math]::Min(36, $_.Key.Length)) } [PSCustomObject]@{ Subscription = $subLabel - Actual = '{0:C0}' -f [double]$_.Value.Actual - Forecast = '{0:C0}' -f [double]$_.Value.Forecast + Actual = Format-BudgetAmount -Value $_.Value.Actual -Currency $_.Value.Currency + ActualPeriod = if ($_.Value.ActualPeriod) { $_.Value.ActualPeriod } else { 'Current month' } + Forecast = if ($_.Value.ForecastSource -eq 'Actual') { 'Unavailable' } else { Format-BudgetAmount -Value $_.Value.Forecast -Currency $_.Value.Currency } + ForecastSource = if ($_.Value.ForecastSource) { $_.Value.ForecastSource } else { 'Unavailable' } Currency = $_.Value.Currency } } - $cols = @('Subscription', 'Actual', 'Forecast', 'Currency') + $cols = @('Subscription', 'Actual', 'ActualPeriod', 'Forecast', 'ForecastSource', 'Currency') } } 'Get-ResourceCosts' { @@ -1649,7 +1591,7 @@ function Invoke-FinOpsMultitool { Resource = $resName ResourceGroup = $_.ResourceGroup ResourceType = ($_.ResourceType -split '/')[-1] - Cost = '{0:C2}' -f [double]$_.Actual + Cost = Format-BudgetAmount -Value $_.Actual -Currency $_.Currency } } $cols = @('Resource', 'ResourceGroup', 'ResourceType', 'Cost') @@ -1663,7 +1605,7 @@ function Invoke-FinOpsMultitool { $rows = foreach ($tag in $data.CostByTag.GetEnumerator()) { foreach ($v in $tag.Value) { $displayVal = if ($v.TagValue.Length -gt 40) { $v.TagValue.Substring(0, 37) + '...' } else { $v.TagValue } - [PSCustomObject]@{ Tag = $tag.Key; Value = $displayVal; Cost = '{0:C0}' -f [double]$v.Cost } + [PSCustomObject]@{ Tag = $tag.Key; Value = $displayVal; Cost = Format-BudgetAmount -Value $v.Cost -Currency $v.Currency } } } $cols = @('Tag', 'Value', 'Cost') @@ -1696,7 +1638,7 @@ function Invoke-FinOpsMultitool { $subName = if ($subNameLookup.ContainsKey($subEntry.Key)) { $subNameLookup[$subEntry.Key] } else { $subEntry.Key } Write-Host " $subName" -ForegroundColor White $subRows = $subEntry.Value | ForEach-Object { - [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency } + [PSCustomObject]@{ Month = $_.Month; Cost = Format-BudgetAmount -Value $_.Cost -Currency $_.Currency; Currency = $_.Currency } } @($subRows) | Format-Table -AutoSize | Out-String | ForEach-Object { $lines = $_.TrimEnd() -split "`n" | Where-Object { $_.Trim() } @@ -1806,13 +1748,15 @@ function Invoke-FinOpsMultitool { $rows = $data.Budgets | ForEach-Object { [PSCustomObject]@{ Budget = $_.BudgetName - Amount = '{0:C0}' -f [double]$_.Amount - Spent = '{0:C0}' -f [double]$_.ActualSpend - PctUsed = "$($_.PctUsed)%" + Amount = Format-BudgetAmount -Value $_.Amount -Currency $_.Currency + Spent = Format-BudgetAmount -Value $_.ActualSpend -Currency $_.Currency + Forecast = Format-BudgetAmount -Value $_.Forecast -Currency $_.Currency + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } Risk = $_.Risk + Note = $_.Note } } - $cols = @('Budget', 'Amount', 'Spent', 'PctUsed', 'Risk') + $cols = @('Budget', 'Amount', 'Spent', 'Forecast', 'PctUsed', 'Risk', 'Note') } 'Get-BudgetHistory' { if ($data -and @($data).Count -gt 0) { @@ -1821,13 +1765,14 @@ function Invoke-FinOpsMultitool { Subscription = $_.Subscription Budget = $_.BudgetName Month = $_.Month - Budgeted = '{0:C0}' -f [double]$_.BudgetAmount - Actual = '{0:C0}' -f [double]$_.ActualSpend - PctUsed = "$($_.PctUsed)%" + Budgeted = Format-BudgetAmount -Value $_.BudgetAmount -Currency $_.Currency + Actual = Format-BudgetAmount -Value $_.ActualSpend -Currency $_.Currency + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } Status = $_.Status + Note = $_.Note } } - $cols = @('Subscription', 'Budget', 'Month', 'Budgeted', 'Actual', 'PctUsed', 'Status') + $cols = @('Subscription', 'Budget', 'Month', 'Budgeted', 'Actual', 'PctUsed', 'Status', 'Note') } else { Write-Host " No budget history available (no budgets configured, or no cost data for the period)." -ForegroundColor DarkGray @@ -1935,9 +1880,11 @@ function Invoke-FinOpsMultitool { } else { $fp = $data.AIFootprint + $periodLabel = if ($data.Period -eq 'MonthToDate') { 'Month to date' } elseif ($data.Period) { [string]$data.Period } else { 'Unknown period' } Write-ColorizedLine -Text " AI footprint — OpenAI/AIServices: $($fp.OpenAIAccounts + $fp.AIServices) ML workspaces: $($fp.MLWorkspaces) AI Search: $($fp.SearchServices) GPU VMs: $($fp.GpuVmCount)" -DefaultColor 'White' - Write-ColorizedLine -Text " Tokens (MTD): $($data.TotalTokens) total ($($data.TotalPromptTokens) in / $($data.TotalGeneratedTokens) out) over $($data.TotalRequests) requests" -DefaultColor 'White' - Write-ColorizedLine -Text " AI spend (MTD): $($data.Currency) $($data.TotalAICost) | $($data.Currency) $($data.CostPer1KTokens)/1K tokens | $($data.Currency) $($data.CostPerRequest)/request" -DefaultColor 'White' + Write-ColorizedLine -Text " Period: $periodLabel" -DefaultColor 'White' + Write-ColorizedLine -Text " Tokens: $($data.TotalTokens) total ($($data.TotalPromptTokens) in / $($data.TotalGeneratedTokens) out) over $($data.TotalRequests) requests" -DefaultColor 'White' + Write-ColorizedLine -Text " AI spend: $($data.Currency) $($data.TotalAICost) | $($data.Currency) $($data.CostPer1KTokens)/1K tokens | $($data.Currency) $($data.CostPerRequest)/request" -DefaultColor 'White' if ($data.Note) { Write-Host " $($data.Note)" -ForegroundColor DarkGray } if ($data.ByModel -and @($data.ByModel).Count -gt 0) { $rows = $data.ByModel @@ -2391,6 +2338,11 @@ function Invoke-FinOpsMultitool { @{ Severity = 'Yellow'; Message = "Re-run against a narrower subscription set, or resolve the access gap, to measure budget coverage exactly."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' } ) } + elseif (@($data.Budgets | Where-Object { $null -eq $_.Amount -or $null -eq $_.ActualSpend -or $null -eq $_.Forecast -or $_.Risk -in @('Unknown', 'Forecast unavailable') }).Count -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Budget health is unverified because an amount, current spend, or forecast is unavailable. Review the notes for each budget.' } + ) + } elseif ($bCoverage -lt 50) { $guidanceItems = @( @{ Severity = 'Red'; Message = "Budget coverage is only $bCoverage%. Most subscriptions have no budget — spending is untracked." } @@ -2520,21 +2472,24 @@ function Invoke-FinOpsMultitool { } } 'Get-AIWorkloadMetrics' { + $periodLabel = if ($data.Period -eq 'MonthToDate') { 'Month to date' } elseif ($data.Period) { [string]$data.Period } else { 'Unknown period' } if (-not $data.HasData) { $guidanceItems = @( @{ Severity = 'Green'; Message = "No AI/LLM workloads detected. No AI-specific cost optimization needed right now." } ) } elseif ($data.CostPer1KTokens -gt 0) { + $periodLabel = if ($data.Period -eq 'MonthToDate') { 'Month to date' } elseif ($data.Period) { [string]$data.Period } else { 'Unknown period' } $guidanceItems = @( - @{ Severity = 'Yellow'; Message = "Effective AI rate: $($data.Currency) $($data.CostPer1KTokens) per 1K tokens across $($data.TotalTokens) tokens (MTD). Track this as your core AI unit-economics KPI." } + @{ Severity = 'Yellow'; Message = "Effective AI rate: $($data.Currency) $($data.CostPer1KTokens) per 1K tokens across $($data.TotalTokens) tokens ($periodLabel). Track this as your core AI unit-economics KPI." } @{ Severity = 'Yellow'; Message = "Compare model deployments above — shift high-volume traffic to cheaper SKUs (e.g., gpt-4o-mini) and reserve premium models for tasks that need them." } @{ Severity = 'Yellow'; Message = "For steady, predictable token volume, evaluate Provisioned Throughput Units (PTUs) — they can beat pay-as-you-go at scale."; Docs = 'https://learn.microsoft.com/azure/ai-services/openai/concepts/provisioned-throughput' } ) } elseif ($data.TotalTokens -gt 0) { + $periodLabel = if ($data.Period -eq 'MonthToDate') { 'Month to date' } elseif ($data.Period) { [string]$data.Period } else { 'Unknown period' } $guidanceItems = @( - @{ Severity = 'Yellow'; Message = "Token usage detected ($($data.TotalTokens) MTD) but cost could not be mapped. Grant Cost Management Reader to compute cost per 1K tokens." } + @{ Severity = 'Yellow'; Message = "Token usage detected ($($data.TotalTokens), $periodLabel) but cost could not be mapped. Grant Cost Management Reader to compute cost per 1K tokens." } ) } else { @@ -2891,14 +2846,21 @@ tr:hover td { background: var(--surface); } if ($data -is [hashtable]) { $htmlRows = $data.GetEnumerator() | ForEach-Object { $sl = if ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] } else { $_.Key } - [PSCustomObject]@{ Subscription = $sl; Actual = '{0:C0}' -f [double]$_.Value.Actual; Forecast = '{0:C0}' -f [double]$_.Value.Forecast; Currency = $_.Value.Currency } + [PSCustomObject]@{ + Subscription = $sl + Actual = Format-BudgetAmount -Value $_.Value.Actual -Currency $_.Value.Currency + ActualPeriod = if ($_.Value.ActualPeriod) { $_.Value.ActualPeriod } else { 'Current month' } + Forecast = if ($_.Value.ForecastSource -eq 'Actual') { 'Unavailable' } else { Format-BudgetAmount -Value $_.Value.Forecast -Currency $_.Value.Currency } + ForecastSource = if ($_.Value.ForecastSource) { $_.Value.ForecastSource } else { 'Unavailable' } + Currency = $_.Value.Currency + } } - $htmlCols = @('Subscription', 'Actual', 'Forecast', 'Currency') + $htmlCols = @('Subscription', 'Actual', 'ActualPeriod', 'Forecast', 'ForecastSource', 'Currency') } } 'Get-ResourceCosts' { $htmlRows = @($data) | Sort-Object { $_.Actual } -Descending | Select-Object -First 50 | ForEach-Object { - [PSCustomObject]@{ ResourceGroup = $_.ResourceGroup; ResourceType = ($_.ResourceType -split '/')[-1]; Cost = '{0:C2}' -f [double]$_.Actual } + [PSCustomObject]@{ ResourceGroup = $_.ResourceGroup; ResourceType = ($_.ResourceType -split '/')[-1]; Cost = Format-BudgetAmount -Value $_.Actual -Currency $_.Currency } } $htmlCols = @('ResourceGroup', 'ResourceType', 'Cost') } @@ -2906,7 +2868,7 @@ tr:hover td { background: var(--surface); } if ($data.CostByTag) { $htmlRows = foreach ($tag in $data.CostByTag.GetEnumerator()) { foreach ($v in $tag.Value) { - [PSCustomObject]@{ Tag = $tag.Key; Value = $v.TagValue; Cost = '{0:C0}' -f [double]$v.Cost } + [PSCustomObject]@{ Tag = $tag.Key; Value = $v.TagValue; Cost = Format-BudgetAmount -Value $v.Cost -Currency $v.Currency } } } $htmlCols = @('Tag', 'Value', 'Cost') @@ -2915,7 +2877,7 @@ tr:hover td { background: var(--surface); } } 'Get-CostTrend' { if ($data.Months) { - $htmlRows = $data.Months | ForEach-Object { [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency } } + $htmlRows = $data.Months | ForEach-Object { [PSCustomObject]@{ Month = $_.Month; Cost = Format-BudgetAmount -Value $_.Cost -Currency $_.Currency; Currency = $_.Currency } } $htmlCols = @('Month', 'Cost', 'Currency') } else { @@ -2991,10 +2953,17 @@ tr:hover td { background: var(--surface); } else { "$($data.BudgetCoverage)%" } [void]$htmlSb.Append("

Budgets: $($data.TotalBudgets)  |  At risk: $($data.AtRiskCount)  |  Over budget: $($data.OverBudgetCount)  |  Coverage: $htmlCoverage

") $htmlRows = $data.Budgets | ForEach-Object { - $riskClass = switch ($_.Risk) { 'Over Budget' { 'severity-red' } 'Forecast Over' { 'severity-yellow' } 'At Risk' { 'severity-yellow' } 'Watch' { 'severity-yellow' } default { 'severity-green' } } - [PSCustomObject]@{ Budget = $_.BudgetName; Amount = '{0:C0}' -f [double]$_.Amount; Spent = '{0:C0}' -f [double]$_.ActualSpend; PctUsed = "$($_.PctUsed)%"; Risk = $_.Risk; _riskClass = $riskClass } + $riskClass = switch ($_.Risk) { 'Over Budget' { 'severity-red' } 'On Track' { 'severity-green' } default { 'severity-yellow' } } + [PSCustomObject]@{ + Budget = $_.BudgetName + Amount = Format-BudgetAmount -Value $_.Amount -Currency $_.Currency + Spent = Format-BudgetAmount -Value $_.ActualSpend -Currency $_.Currency + Forecast = Format-BudgetAmount -Value $_.Forecast -Currency $_.Currency + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } + Risk = $_.Risk; Note = $_.Note; _riskClass = $riskClass + } } - $htmlCols = @('Budget', 'Amount', 'Spent', 'PctUsed', 'Risk') + $htmlCols = @('Budget', 'Amount', 'Spent', 'Forecast', 'PctUsed', 'Risk', 'Note') } 'Get-AnomalyAlerts' { [void]$htmlSb.Append("

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

") @@ -3037,13 +3006,14 @@ tr:hover td { background: var(--surface); } Subscription = $_.Subscription Budget = $_.BudgetName Month = $_.Month - Budgeted = '{0:C0}' -f [double]$_.BudgetAmount - Actual = '{0:C0}' -f [double]$_.ActualSpend - PctUsed = "$($_.PctUsed)%" + Budgeted = Format-BudgetAmount -Value $_.BudgetAmount -Currency $_.Currency + Actual = Format-BudgetAmount -Value $_.ActualSpend -Currency $_.Currency + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } Status = $_.Status + Note = $_.Note } } - $htmlCols = @('Subscription', 'Budget', 'Month', 'Budgeted', 'Actual', 'PctUsed', 'Status') + $htmlCols = @('Subscription', 'Budget', 'Month', 'Budgeted', 'Actual', 'PctUsed', 'Status', 'Note') } 'Get-CarbonMetrics' { $cLatest = [System.Net.WebUtility]::HtmlEncode([string]$data.LatestMonth) @@ -3078,8 +3048,10 @@ tr:hover td { background: var(--surface); } if ($data.HasData) { $fp = $data.AIFootprint $aCur = [System.Net.WebUtility]::HtmlEncode([string]$data.Currency) + $periodLabel = if ($data.Period -eq 'MonthToDate') { 'Month to date' } elseif ($data.Period) { [string]$data.Period } else { 'Unknown period' } + $aPeriod = [System.Net.WebUtility]::HtmlEncode($periodLabel) [void]$htmlSb.Append("

AI footprint — OpenAI/AI Services: $($fp.OpenAIAccounts + $fp.AIServices)  |  ML workspaces: $($fp.MLWorkspaces)  |  AI Search: $($fp.SearchServices)  |  GPU VMs: $($fp.GpuVmCount)

") - [void]$htmlSb.Append("

Tokens (MTD): $($data.TotalTokens) over $($data.TotalRequests) requests  |  AI spend: $aCur $($data.TotalAICost)

") + [void]$htmlSb.Append("

Period: $aPeriod  |  Tokens: $($data.TotalTokens) over $($data.TotalRequests) requests  |  AI spend: $aCur $($data.TotalAICost)

") if ($data.ByModel -and @($data.ByModel | Where-Object { $_ }).Count -gt 0) { $htmlRows = $data.ByModel $htmlCols = @('Deployment', 'PromptTokens', 'GeneratedTokens', 'TotalTokens', 'PctOfTokens') diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 index e73f23971..28448a509 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -168,7 +168,7 @@ resources $hubApprox = $false if ($HubData -and @($HubData).Count -gt 0) { $agg = ConvertTo-AIHubAggregates -HubData $HubData - if ($agg -and ($agg.HasCost -or $agg.HasTokens)) { + if ($agg) { $fromHub = $true $hubApprox = $agg.Approximate $modelTokens = $agg.ModelTokens @@ -388,7 +388,7 @@ resources CostPerRequest = $costPerRequest ByModel = $byModel ByAccount = $byAccount - Period = 'MonthToDate' + Period = if ($fromHub) { $agg.Period } else { 'MonthToDate' } Source = if ($fromHub) { 'FinOpsHub' } else { 'API' } ScannedSubs = $Subscriptions.Count DetectionFailed = $detectionFailed diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 index d06359e97..2bfa88540 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -14,6 +14,17 @@ param() # subscriptions at risk of overrun. ########################################################################### +function Format-BudgetAmount { + param($Value, [string]$Currency) + + if ($null -eq $Value -or [string]::IsNullOrWhiteSpace($Currency)) { return 'Unavailable' } + try { + $amount = Get-HubCostValue -Row ([pscustomobject]@{ Value = $Value }) -Column 'Value' + return '{0} {1:N2}' -f $Currency, $amount + } + catch { return 'Unavailable' } +} + function Get-BudgetStatus { [CmdletBinding()] param( @@ -58,7 +69,9 @@ 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) { - $sampleBudgets = ($resp.Content | ConvertFrom-Json).value + $sampleBudgets = @(foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -RootNextLink -Context "budget sample for $($sub.Name)")) { + ($page.Content | ConvertFrom-Json -ErrorAction Stop).value + }) if ($sampleBudgets -and $sampleBudgets.Count -gt 0) { $sampleHits++ } } else { $sampleErrors++ } @@ -102,38 +115,64 @@ function Get-BudgetStatus { $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET if ($resp.StatusCode -eq 200) { - $data = ($resp.Content | ConvertFrom-Json) - if ($data.value -and $data.value.Count -gt 0) { + $budgetRows = @(foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -RootNextLink -Context "budgets for $($sub.Name)")) { + ($page.Content | ConvertFrom-Json -ErrorAction Stop).value + }) + if ($budgetRows.Count -gt 0) { $subsWithBudget++ - foreach ($budget in $data.value) { + foreach ($budget in $budgetRows) { $bp = $budget.properties - $amount = [math]::Round([double]$bp.amount, 2) + $issues = [System.Collections.Generic.List[string]]::new() + $amount = $null + try { + $value = Get-HubCostValue -Row $bp -Column 'amount' + if ($value -le 0) { throw 'Budget amount must be positive.' } + $amount = $value + } + catch { [void]$issues.Add('Budget amount is missing or invalid.') } $timeGrain = $bp.timeGrain $category = $bp.category - # Current spend from our existing cost data - $actualSpend = 0 - $forecast = 0 - $spendKnown = ($CostData -and $CostData.ContainsKey($sub.Id)) - if ($spendKnown) { - $actualSpend = [math]::Round($CostData[$sub.Id].Actual, 2) - $forecast = [math]::Round($CostData[$sub.Id].Forecast, 2) + $actualSpend = $null + $forecast = $null + $spendKnown = $false + $spendSource = 'Unavailable' + $forecastSource = 'Unavailable' + $spendCurrency = $null + $actualUnit = ([string]$bp.currentSpend.unit).Trim().ToUpperInvariant() + $forecastUnit = ([string]$bp.forecastSpend.unit).Trim().ToUpperInvariant() + if ($actualUnit) { $spendCurrency = $actualUnit } + elseif ($forecastUnit) { $spendCurrency = $forecastUnit } + + if ($bp.currentSpend -and $actualUnit) { + try { + $actualSpend = Get-HubCostValue -Row $bp.currentSpend -Column 'amount' + $spendKnown = $true + $spendSource = 'Budget' + } + catch { [void]$issues.Add('Current spend amount is missing or invalid.') } } + else { [void]$issues.Add('Current spend or its unit is unavailable.') } + if ($bp.forecastSpend -and $forecastUnit -and $forecastUnit -eq $spendCurrency) { + try { + $forecast = Get-HubCostValue -Row $bp.forecastSpend -Column 'amount' + $forecastSource = 'Budget' + } + catch { [void]$issues.Add('Forecast amount is missing or invalid.') } + } + elseif ($bp.forecastSpend) { [void]$issues.Add('Forecast unit is missing or does not match the budget unit.') } + else { [void]$issues.Add('Budget forecast is unavailable.') } + + $pctUsed = if ($spendKnown -and $null -ne $amount) { [math]::Round(($actualSpend / $amount) * 100, 1) } else { $null } + $pctForecast = if ($forecastSource -ne 'Unavailable' -and $null -ne $amount) { [math]::Round(($forecast / $amount) * 100, 1) } else { $null } - # Calculate % used - $pctUsed = if ($amount -gt 0) { [math]::Round(($actualSpend / $amount) * 100, 1) } else { 0 } - $pctForecast = if ($amount -gt 0) { [math]::Round(($forecast / $amount) * 100, 1) } else { 0 } - - # Risk level - # 'Unknown' means we could not read current spend for this - # subscription, so we must NOT claim the budget is On Track. - # 'Over Budget' means actual spend has already exceeded the budget. - # 'Forecast Over' means actual is still within budget but the - # month-end forecast is projected to exceed it (early warning). - $risk = if (-not $spendKnown) { 'Unknown' } + $risk = if ($null -eq $amount) { 'Unknown' } elseif ($pctUsed -gt 100) { 'Over Budget' } elseif ($pctForecast -gt 100) { 'Forecast Over' } + elseif (-not $spendKnown) { 'Unknown' } elseif ($pctForecast -gt 90) { 'At Risk' } + elseif ($pctUsed -gt 90) { 'Near Limit' } + elseif ($forecastSource -eq 'Unavailable') { 'Forecast unavailable' } elseif ($pctForecast -gt 75) { 'Watch' } else { 'On Track' } @@ -182,6 +221,8 @@ function Get-BudgetStatus { Category = $category ActualSpend = $actualSpend Forecast = $forecast + SpendSource = $spendSource + ForecastSource = $forecastSource PctUsed = $pctUsed PctForecast = $pctForecast Risk = $risk @@ -189,7 +230,11 @@ function Get-BudgetStatus { ContactEmails = (($contactEmails | Select-Object -Unique) -join ', ') ContactRoles = (($contactRoles | Select-Object -Unique) -join ', ') TagFilter = $tagFilterStr - Currency = if ($CostData -and $CostData.ContainsKey($sub.Id)) { $CostData[$sub.Id].Currency } else { 'USD' } + Filter = $bp.filter + TimePeriod = $bp.timePeriod + Scope = "/subscriptions/$($sub.Id)" + Currency = $spendCurrency + Note = ($issues -join ' ') }) } } @@ -211,7 +256,7 @@ function Get-BudgetStatus { # OverBudgetCount = actual spend already exceeded budget (urgent / red). # AtRiskCount = forecast-driven warnings (projected over, or trending high). $overBudget = @($budgets | Where-Object { $_.Risk -eq 'Over Budget' }).Count - $atRisk = @($budgets | Where-Object { $_.Risk -in @('Forecast Over', 'At Risk') }).Count + $atRisk = @($budgets | Where-Object { $_.Risk -in @('Forecast Over', 'At Risk', 'Near Limit') }).Count # Either an unqueried sample or an unreadable subscription leaves coverage # unmeasured, so both suppress the percentage rather than rounding down. @@ -257,6 +302,7 @@ function Get-BudgetHistory { [object[]]$Budgets, [Parameter()] + [ValidateRange(1, 36)] [int]$MonthsBack = 6, # Optional Cost Trend result (from Get-CostTrend). When supplied, its @@ -269,156 +315,113 @@ function Get-BudgetHistory { if (-not $Budgets -or $Budgets.Count -eq 0) { return @() } $history = [System.Collections.Generic.List[PSCustomObject]]::new() - - # Build a sub -> { 'yyyy-MM' = cost } lookup from Cost Trend so Budget - # History can avoid hitting Cost Management again. Cost Trend already - # fetched the last 6 months of monthly spend per subscription — exactly the - # data Budget History needs — so reusing it eliminates the redundant, - # 429-prone per-sub queries that were leaving history empty under throttle. - $trendBySub = @{} - if ($CostTrend -and $CostTrend.BySubscription) { - foreach ($k in @($CostTrend.BySubscription.Keys)) { - $lookup = @{} - foreach ($m in $CostTrend.BySubscription[$k]) { - if ($m.MonthDate -is [datetime]) { - $lookup[$m.MonthDate.ToString('yyyy-MM')] = [math]::Round([double]$m.Cost, 2) - } - } - if ($lookup.Count -gt 0) { $trendBySub[$k] = $lookup } + $now = (Get-Date).ToUniversalTime() + $monthStart = $now.Date.AddDays(1 - $now.Day) + $monthDates = @(for ($monthsAgo = $MonthsBack; $monthsAgo -ge 1; $monthsAgo--) { $monthStart.AddMonths(-$monthsAgo) }) + $costCache = @{} + + foreach ($budget in $Budgets) { + $reason = $null + $budgetAmount = $null + $periodStart = $null + $periodEnd = [datetime]::MaxValue + $subId = [string]$budget.SubscriptionId + if ($budget.Filter -or $budget.TagFilter) { $reason = 'Filtered budget history requires costs for the same filter.' } + elseif ($budget.Category -ne 'Cost' -or $budget.TimeGrain -ne 'Monthly') { $reason = 'Subscription monthly costs cannot reconstruct this budget category or period.' } + elseif (-not $budget.Currency) { $reason = 'Budget currency is unavailable.' } + elseif ($budget.Scope -and $budget.Scope -ne "/subscriptions/$subId") { $reason = 'Budget scope differs from the subscription cost scope.' } + try { + $budgetAmount = Get-HubCostValue -Row $budget -Column 'Amount' + if ($budgetAmount -le 0) { throw 'Budget amount must be positive.' } } - } - - # History reports on a fixed window, so cached trend data is only usable when - # it covers that whole window. Cost Trend may hold fewer months than - # -MonthsBack asks for, and the uncovered months would otherwise be filled - # with zero spend and reported as being under budget. - $requiredMonths = [System.Collections.Generic.List[string]]::new() - for ($m = $MonthsBack; $m -ge 1; $m--) { - [void]$requiredMonths.Add((Get-Date).AddMonths(-$m).ToString('yyyy-MM')) - } - - # Group budgets by subscription to minimize API calls - $bySubId = $Budgets | Group-Object SubscriptionId - - foreach ($subGroup in $bySubId) { - $subId = $subGroup.Name - $subName = $subGroup.Group[0].Subscription - - # Prefer reusing Cost Trend data (zero extra API calls). Fall back to a - # live per-sub Cost Management query when trend data is missing or does - # not reach as far back as this report does. - $monthlyCosts = $null - if ($trendBySub.ContainsKey($subId)) { - $cached = $trendBySub[$subId] - $covered = $true - foreach ($rm in $requiredMonths) { - if (-not $cached.ContainsKey($rm)) { $covered = $false; break } - } - if ($covered) { $monthlyCosts = $cached } + catch { $budgetAmount = $null; $reason = 'Budget amount is missing or invalid.' } + try { + if (-not $budget.TimePeriod.startDate) { throw 'Missing start date.' } + $periodStart = ([datetime]$budget.TimePeriod.startDate).ToUniversalTime() + if ($budget.TimePeriod.endDate) { $periodEnd = ([datetime]$budget.TimePeriod.endDate).ToUniversalTime().Date.AddDays(1) } } - if (-not $monthlyCosts) { - # Query monthly costs for this sub over the last N months - $startDate = (Get-Date).AddMonths(-$MonthsBack).ToString('yyyy-MM-01') - $endDate = (Get-Date -Day 1).AddDays(-1).ToString('yyyy-MM-dd') # Last day of previous month - - $body = @{ - type = 'ActualCost' - timeframe = 'Custom' - timePeriod = @{ from = $startDate; to = $endDate } - dataset = @{ - granularity = 'Monthly' - aggregation = @{ - totalCost = @{ name = 'Cost'; function = 'Sum' } + catch { $reason = 'Budget validity period is unavailable.' } + $activeMonths = if (-not $reason) { @($monthDates | Where-Object { $_ -ge $periodStart -and $_.AddMonths(1) -le $periodEnd }) } else { @() } + + if (-not $reason -and $activeMonths.Count -gt 0 -and -not $costCache.ContainsKey($subId)) { + $monthlyCosts = @{} + if ($CostTrend -and $CostTrend.BySubscription -and $CostTrend.BySubscription[$subId]) { + try { + foreach ($entry in $CostTrend.BySubscription[$subId]) { + if ($entry.MonthDate -isnot [datetime] -or -not $entry.Currency) { throw 'Cached cost date or currency is missing.' } + $key = $entry.MonthDate.ToString('yyyy-MM') + $amount = Get-HubCostValue -Row $entry -Column 'Cost' + if (-not $monthlyCosts.ContainsKey($key)) { $monthlyCosts[$key] = @{ Cost = 0.0; Currency = $entry.Currency } } + if ($monthlyCosts[$key].Currency -ne $entry.Currency) { throw 'Cached monthly costs have mixed currencies.' } + $monthlyCosts[$key].Cost += $amount } } - } | ConvertTo-Json -Depth 10 - - $costPath = "/subscriptions/$subId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" - try { - $resp = Invoke-AzRestMethodWithRetry -Path $costPath -Method POST -Payload $body - - $result = Get-CostQueryResult -FirstResponse $resp -Payload $body -Context "budget history for $subName" - if (-not $result.properties -or -not $result.properties.rows) { continue } - - # Parse columns - $cols = $result.properties.columns - $costIdx = -1; $dateIdx = -1; $currIdx = -1 - for ($i = 0; $i -lt $cols.Count; $i++) { - $n = $cols[$i].name.ToLower() - if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'pretaxcost') { $costIdx = $i } - elseif ($n -match 'billingmonth|usagedate') { $dateIdx = $i } - elseif ($n -match 'currency|billingcurrency') { $currIdx = $i } + catch { $monthlyCosts.Clear() } + } + $covered = $true + foreach ($month in $monthDates) { if (-not $monthlyCosts.ContainsKey($month.ToString('yyyy-MM'))) { $covered = $false } } + if (-not $covered) { + $body = @{ + type = 'ActualCost'; timeframe = 'Custom' + timePeriod = @{ from = $monthDates[0].ToString('yyyy-MM-dd'); to = $monthStart.AddDays(-1).ToString('yyyy-MM-dd') } + dataset = @{ granularity = 'Monthly'; aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } } + } | ConvertTo-Json -Depth 10 + $costPath = "/subscriptions/$subId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $response = Invoke-AzRestMethodWithRetry -Path $costPath -Method POST -Payload $body + $result = Get-CostQueryResult -FirstResponse $response -Payload $body -Context "budget history for $($budget.Subscription)" + $costIndex = Get-CostColumnIndex -Columns $result.properties.columns -Names @('cost', 'totalcost', 'pretaxcost') + $dateIndex = Get-CostColumnIndex -Columns $result.properties.columns -Names @('billingmonth', 'usagedate') + $currencyIndex = Get-CostColumnIndex -Columns $result.properties.columns -Names @('currency', 'billingcurrency') + if ($result.properties.rows.Count -gt 0 -and ($costIndex -lt 0 -or $dateIndex -lt 0 -or $currencyIndex -lt 0)) { + throw 'Budget history is missing required cost columns; results are incomplete.' } - if ($costIdx -eq -1) { $costIdx = 0 } - if ($dateIdx -eq -1) { $dateIdx = 1 } - if ($currIdx -eq -1) { $currIdx = 2 } - - # Build month → cost lookup - $monthlyCosts = @{} + $monthlyCosts.Clear() + foreach ($month in $monthDates) { $monthlyCosts[$month.ToString('yyyy-MM')] = @{ Cost = 0.0; Currency = $null } } foreach ($row in $result.properties.rows) { - $cost = [math]::Round([double]$row[$costIdx], 2) - $rawDate = $row[$dateIdx] - # Parse date robustly — API may return a [datetime], a YYYYMMDD - # integer (e.g. 20260101), or a locale-formatted string - # (e.g. "1/1/2026 12:00:00 AM"). Avoid substring slicing, which - # mangles non-ISO date formats and zeroes out actual spend. - $parsed = $null - if ($rawDate -is [datetime]) { - $parsed = $rawDate + $rawDate = $row[$dateIndex] + try { + $date = if ($rawDate -is [datetime]) { $rawDate } + elseif ([string]$rawDate -match '^\d{8}$') { [datetime]::ParseExact([string]$rawDate, 'yyyyMMdd', [cultureinfo]::InvariantCulture) } + else { [datetime]::Parse([string]$rawDate, [cultureinfo]::InvariantCulture) } } - else { - $dateStr = "$rawDate" - $digitsOnly = $dateStr -replace '[^0-9]', '' - if ($digitsOnly.Length -eq 8 -and $dateStr -notmatch '[/\-:]') { - $parsed = [datetime]::ParseExact($digitsOnly, 'yyyyMMdd', $null) - } - else { - try { $parsed = [datetime]::Parse($dateStr) } catch { continue } - } + catch { throw 'Budget history contains an invalid date; results are incomplete.' } + $key = $date.ToString('yyyy-MM') + $currency = [string]$row[$currencyIndex] + if (-not $monthlyCosts.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($currency) -or + ($monthlyCosts[$key].Currency -and $monthlyCosts[$key].Currency -ne $currency)) { + throw 'Budget history has an invalid period or mixed currencies; results are incomplete.' } - $monthKey = $parsed.ToString('yyyy-MM') - $monthlyCosts[$monthKey] = $cost + $monthlyCosts[$key].Currency = $currency + $monthlyCosts[$key].Cost += [double]$row[$costIndex] } } - catch { - throw "Budget history query failed for $subName : $($_.Exception.Message)" - } + $costCache[$subId] = $monthlyCosts } - if (-not $monthlyCosts -or $monthlyCosts.Count -eq 0) { continue } - - # Now create history rows per budget per month - foreach ($budget in $subGroup.Group) { - $budgetAmount = [double]$budget.Amount - # For quarterly/annual budgets, pro-rate to monthly equivalent - $monthlyAmount = switch ($budget.TimeGrain) { - 'Quarterly' { [math]::Round($budgetAmount / 3, 2) } - 'Annually' { [math]::Round($budgetAmount / 12, 2) } - default { $budgetAmount } + foreach ($month in $monthDates) { + $key = $month.ToString('yyyy-MM') + $rowReason = $reason + $actual = $null + $pctUsed = $null + $status = 'Unavailable' + if (-not $rowReason -and ($month -lt $periodStart -or $month.AddMonths(1) -gt $periodEnd)) { + $rowReason = 'The budget was not active for this full month.' } - - for ($m = $MonthsBack; $m -ge 1; $m--) { - $monthDate = (Get-Date).AddMonths(-$m) - $monthKey = $monthDate.ToString('yyyy-MM') - $monthLabel = $monthDate.ToString('MMM yyyy') - $actual = if ($monthlyCosts.ContainsKey($monthKey)) { $monthlyCosts[$monthKey] } else { 0 } - $pctUsed = if ($monthlyAmount -gt 0) { [math]::Round(($actual / $monthlyAmount) * 100, 1) } else { 0 } - $status = if ($pctUsed -gt 100) { 'Over' } - elseif ($pctUsed -gt 90) { 'Near Limit' } - else { 'Under' } - - [void]$history.Add([PSCustomObject]@{ - Subscription = $subName - BudgetName = $budget.BudgetName - Month = $monthLabel - MonthSort = $monthKey - BudgetAmount = $monthlyAmount - ActualSpend = $actual - PctUsed = $pctUsed - Status = $status - Currency = $budget.Currency - }) + if (-not $rowReason) { + $cost = $costCache[$subId][$key] + if ($cost.Currency -and $cost.Currency -ne $budget.Currency) { $rowReason = 'Cost currency does not match the budget currency.' } + else { + $actual = [math]::Round($cost.Cost, 2) + $pctUsed = [math]::Round(100 * $actual / $budgetAmount, 1) + $status = if ($pctUsed -gt 100) { 'Over' } elseif ($pctUsed -gt 90) { 'Near Limit' } else { 'Under' } + } } + [void]$history.Add([PSCustomObject]@{ + Subscription = $budget.Subscription; BudgetName = $budget.BudgetName; Month = $month.ToString('MMM yyyy'); MonthSort = $key + BudgetAmount = if ($budget.TimeGrain -eq 'Monthly') { $budgetAmount } else { $null } + ActualSpend = $actual; PctUsed = $pctUsed; Status = $status; Currency = $budget.Currency + Note = if ($rowReason) { $rowReason } else { 'Compared with the current budget amount; prior budget revisions are unavailable.' } + }) } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 index 88ac1f7e6..305beabdd 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -183,7 +183,7 @@ function Get-AllocationCostMaps { $byResource = @{} $bySub = @{} - $currency = 'USD' + $currency = $null $source = 'LiveApi' $fromHub = ($HubData -and @($HubData).Count -gt 0) @@ -191,9 +191,12 @@ function Get-AllocationCostMaps { if ($fromHub) { $source = 'FinOpsHub' $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData -CostBasis 'AmortizedCost' + $costCol = $costSchema.CostColumn + $currency = $costSchema.Currency foreach ($row in $HubData) { $rid = [string](Get-HubRowValue -Row $row -Names @('ResourceId', 'x_ResourceId', 'InstanceId') -Props $props) - $cost = [double](Get-HubRowValue -Row $row -Names @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost', 'Cost') -Props $props) + $cost = Get-HubCostValue -Row $row -Column $costCol $sub = [string](Get-HubRowValue -Row $row -Names @('SubAccountId', 'SubscriptionId', 'x_SubscriptionId', 'SubscriptionGuid') -Props $props) $cur = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency') -Props $props) if ($cur) { $currency = $cur } @@ -234,10 +237,17 @@ function Get-AllocationCostMaps { $iCost = [array]::IndexOf($cols, 'Cost') $iRes = [array]::IndexOf($cols, 'ResourceId') $iCur = [array]::IndexOf($cols, 'Currency') + if ($data.properties.rows.Count -gt 0 -and ($iCost -lt 0 -or $iRes -lt 0 -or $iCur -lt 0)) { + throw 'Cost, resource, or currency columns are missing; allocation cost coverage is incomplete.' + } foreach ($row in @($data.properties.rows)) { $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 } $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } - if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] } + $rowCurrency = ([string]$row[$iCur]).Trim().ToUpperInvariant() + if (-not $rowCurrency -or ($currency -and $rowCurrency -ne $currency)) { + throw 'Allocation cost currency is missing or mixed; cost coverage is incomplete.' + } + $currency = $rowCurrency $bySub[$sub] += $amount if ($rid) { if (-not $byResource.ContainsKey($rid)) { $byResource[$rid] = 0.0 } @@ -257,6 +267,7 @@ function Get-AllocationCostMaps { BySub = $bySub Currency = $currency Source = $source + Period = if ($fromHub) { $costSchema.Period } else { 'MonthToDate' } } } @@ -374,7 +385,7 @@ function Get-SharedCostAllocation { $costSubs = @($hubSubs + $Spokes | Select-Object -Unique) $maps = Get-AllocationCostMaps -SubscriptionIds $costSubs -HubData $HubData - # -- Size the shared pool from billed cost ---------------------------- + # -- Size the shared pool from amortized cost ------------------------- $poolTotal = 0.0 $poolResources = @() foreach ($p in $pool) { @@ -432,7 +443,7 @@ function Get-SharedCostAllocation { } } if ($maps.Source -eq 'LiveApi') { - $notes += 'Costs are live Cost Management actuals (month-to-date). Per-spoke ExpressRoute attribution cannot come from billing - it relies on the weighting key.' + $notes += 'Costs are live Cost Management amortized costs (month-to-date). Per-spoke ExpressRoute attribution cannot come from billing - it relies on the weighting key.' } # Ready-to-paste targets for set_cost_allocation_rule: each spoke's share of @@ -449,7 +460,7 @@ function Get-SharedCostAllocation { return [PSCustomObject]@{ HasData = $true - Period = 'MonthToDate' + Period = $maps.Period Source = $maps.Source Currency = $maps.Currency WeightingMethod = $WeightingMethod diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 index e0478e7e1..f188eb6a0 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 @@ -220,7 +220,7 @@ function Get-VmCostBreakdown { if ($MeterLabel) { [void]$buckets[$Category].Meters.Add([string]$MeterLabel) } } - $currency = 'USD' + $currency = $null $source = 'LiveApi' $subLevelEgress = 0.0 # bandwidth billed with no resource ID (cannot attribute) $fromHub = ($HubData -and @($HubData).Count -gt 0) @@ -230,12 +230,15 @@ function Get-VmCostBreakdown { $source = 'FinOpsHub' Write-Host " Decomposing from FinOps Hub export..." -ForegroundColor Cyan $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData -CostBasis 'AmortizedCost' + $costCol = $costSchema.CostColumn + $currency = $costSchema.Currency foreach ($row in $HubData) { $rid = [string](Get-HubRowValue -Row $row -Names @('ResourceId', 'x_ResourceId', 'InstanceId') -Props $props) if (-not $rid -or -not $vm.Associated.Contains($rid)) { continue } - $cost = [double](Get-HubRowValue -Row $row -Names @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost', 'Cost') -Props $props) + $cost = Get-HubCostValue -Row $row -Column $costCol $qty = [double](Get-HubRowValue -Row $row -Names @('ConsumedQuantity', 'Quantity', 'UsageQuantity') -Props $props) $cur = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency') -Props $props) if ($cur) { $currency = $cur } @@ -282,13 +285,20 @@ function Get-VmCostBreakdown { $iRes = [array]::IndexOf($cols, 'ResourceId') $iCat = [array]::IndexOf($cols, 'MeterCategory') $iCur = [array]::IndexOf($cols, 'Currency') + if ($data.properties.rows.Count -gt 0 -and ($iCost -lt 0 -or $iRes -lt 0 -or $iCur -lt 0)) { + throw 'Cost, resource, or currency columns are missing; VM cost coverage is incomplete.' + } foreach ($row in @($data.properties.rows)) { $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 } $qty = if ($iQty -ge 0) { [double]$row[$iQty] } else { 0 } $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } $mc = if ($iCat -ge 0) { [string]$row[$iCat] } else { '' } - if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] } + $rowCurrency = ([string]$row[$iCur]).Trim().ToUpperInvariant() + if (-not $rowCurrency -or ($currency -and $rowCurrency -ne $currency)) { + throw 'VM cost currency is missing or mixed; cost coverage is incomplete.' + } + $currency = $rowCurrency # Bandwidth often bills with an empty resource ID at sub # scope - record it as an unattributable caveat. @@ -353,12 +363,12 @@ function Get-VmCostBreakdown { Location = $vm.Location VmSize = $vm.VmSize Currency = $currency - Period = 'MonthToDate' + Period = if ($fromHub) { $costSchema.Period } else { 'MonthToDate' } Source = $source TotalCost = [math]::Round($total, 2) EgressGb = $egressQty Breakdown = $breakdown ResourcesIncluded = @($vm.Associated) - Note = if ($notes.Count -gt 0) { $notes -join ' ' } else { 'Full VM solution cost: compute + disks + network + extensions, month-to-date.' } + Note = if ($notes.Count -gt 0) { $notes -join ' ' } else { "Amortized VM solution cost for $($costSchema.Period): compute + disks + network + extensions." } } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 index c043707c1..f8187f899 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 @@ -263,12 +263,7 @@ function Get-GuidFromString { # culture reads "123.45" as 12345 wherever '.' is the thousands separator. function ConvertTo-ExportAmount { param([string]$Value) - $parsed = 0.0 - $styles = [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands - if ([double]::TryParse($Value, $styles, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$parsed)) { - return $parsed - } - return 0.0 + return Get-HubCostValue -Row ([pscustomobject]@{ Cost = $Value }) -Column 'Cost' } # -- Canonical export column resolver ------------------------------------- @@ -284,7 +279,7 @@ function Resolve-ExportColumns { ResourceGroup = @('ResourceGroup', 'ResourceGroupName', 'x_ResourceGroupName') ResourceId = @('ResourceId', 'InstanceId', 'InstanceName', 'x_ResourceId') ServiceName = @('ServiceName', 'MeterCategory', 'ConsumedService', 'x_ServiceName') - Cost = @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost', 'PreTaxCost', 'Cost', 'CostInUSD') + Cost = @('BilledCost', 'CostInBillingCurrency', 'PreTaxCost', 'Cost', 'CostInUSD') Currency = @('BillingCurrency', 'BillingCurrencyCode', 'Currency') Tags = @('Tags') } @@ -303,6 +298,82 @@ function Resolve-ExportColumns { return $map } +function Select-CostExportData { + param( + [Parameter(Mandatory)][object]$ExportData, + [object[]]$Subscriptions, + [switch]$SkipCoverageCheck + ) + + if ($ExportData.CoverageIncomplete -or $ExportData.Unsupported -or $ExportData.NoData -or -not $ExportData.Rows) { + throw 'Export data is unavailable or incomplete; subscription coverage cannot be verified.' + } + $sourceMap = $ExportData.ColMap + if (-not $sourceMap) { $sourceMap = Resolve-ExportColumns -Header $ExportData.Rows[0].PSObject.Properties.Name } + $resolved = Resolve-ExportColumns -Header $ExportData.Rows[0].PSObject.Properties.Name + $costColumn = $resolved.Cost + if (-not $costColumn -or ($costColumn -ne 'BilledCost' -and $ExportData.CostBasis -ne 'ActualCost')) { + throw 'The export does not provide actual cost; BilledCost or an actual-cost dataset is required.' + } + $expected = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $requestedIds = if ($Subscriptions) { @($Subscriptions | ForEach-Object { $_.Id }) } else { @($ExportData.SelectedSubscriptionIds | Where-Object { $_ }) } + foreach ($subscriptionId in $requestedIds) { + $parsedId = [guid]::Empty + if (-not [guid]::TryParse([string]$subscriptionId, [ref]$parsedId)) { throw 'Invalid subscription ID; refusing to drop the export scope filter.' } + [void]$expected.Add($parsedId.ToString()) + } + $covered = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $rows = [System.Collections.Generic.List[object]]::new() + $currency = $null + $columnMap = @{ Cost = 'Cost'; SubscriptionId = 'SubscriptionId'; Currency = 'Currency' } + $optional = @('Date', 'SubscriptionName', 'ResourceGroup', 'ResourceId', 'ServiceName', 'Tags') + foreach ($column in $optional) { if ($sourceMap.$column) { $columnMap[$column] = $column } } + + foreach ($row in $ExportData.Rows) { + $rawId = if ($sourceMap.SubscriptionId) { [string]$row.($sourceMap.SubscriptionId) } else { '' } + if (-not $rawId -and $sourceMap.ResourceId) { $rawId = [string]$row.($sourceMap.ResourceId) } + $parsedId = [guid]::Empty + if ($rawId -match '^/subscriptions/([0-9a-fA-F-]{36})(?:/|$)') { $rawId = $Matches[1] } + if (-not [guid]::TryParse($rawId, [ref]$parsedId)) { throw 'An export row has no valid subscription ID; coverage is incomplete.' } + $subscriptionId = $parsedId.ToString() + if ($expected.Count -gt 0 -and -not $expected.Contains($subscriptionId)) { continue } + + $amount = Get-HubCostValue -Row $row -Column $costColumn + $rowCurrency = if ($costColumn -eq 'CostInUSD') { 'USD' } + elseif ($sourceMap.Currency) { [string]$row.($sourceMap.Currency) } + else { [string]$ExportData.Currency } + if ([string]::IsNullOrWhiteSpace($rowCurrency)) { throw 'An export row has no billing currency; cost results are incomplete.' } + $rowCurrency = $rowCurrency.Trim().ToUpperInvariant() + if ($currency -and $currency -ne $rowCurrency) { throw 'Multiple billing currencies cannot be combined into one export cost total.' } + $currency = $rowCurrency + $normalized = [ordered]@{ Cost = $amount; SubscriptionId = $subscriptionId; Currency = $currency } + foreach ($column in $optional) { + $sourceColumn = $sourceMap.$column + if ($sourceColumn -and $row.PSObject.Properties.Name -notcontains $sourceColumn) { + throw "Export row schema is missing '$sourceColumn'; cost results are incomplete." + } + $normalized[$column] = if ($sourceColumn) { $row.$sourceColumn } else { $null } + } + [void]$rows.Add([pscustomobject]$normalized) + [void]$covered.Add($subscriptionId) + } + if (-not $SkipCoverageCheck) { + foreach ($subscriptionId in $expected) { + if (-not $covered.Contains($subscriptionId)) { throw "No rows for selected subscription '$subscriptionId'; export coverage is incomplete." } + } + } + $period = if ($rows.Count -gt 0) { Get-HubCostSchema -HubData $rows.ToArray() } else { @{ Period = 'Unknown'; PeriodStart = $null; PeriodEnd = $null } } + return [pscustomobject]@{ + Rows = $rows.ToArray(); ColMap = $columnMap; Currency = $currency; DataDate = $ExportData.DataDate + ActualPeriod = $period.Period; ActualPeriodStart = $period.PeriodStart; ActualPeriodEnd = $period.PeriodEnd + PeriodsBySubscription = $period.PeriodsBySubscription + RowCount = $rows.Count; NoData = ($rows.Count -eq 0); CostBasis = 'ActualCost' + CoveredSubscriptionIds = @($covered); SelectedSubscriptionIds = @($expected) + ExportCount = $ExportData.ExportCount + Headers = @($columnMap.Keys); NoCostColumn = $false; CoverageIncomplete = $false + } +} + # -- Parse an export Tags cell into a hashtable --------------------------- # Handles both classic ("env": "prod", "owner": "team") and FOCUS JSON # ({"env":"prod"}) tag encodings. @@ -310,11 +381,13 @@ function ConvertFrom-ExportTagString { param([string]$Raw) $out = @{} if ([string]::IsNullOrWhiteSpace($Raw)) { return $out } - $text = $Raw.Trim().Trim('{', '}') - foreach ($m in [regex]::Matches($text, '"([^"]+)"\s*:\s*"([^"]*)"')) { - $k = $m.Groups[1].Value - $v = $m.Groups[2].Value - if ($k) { $out[$k] = $v } + $text = $Raw.Trim() + if (-not $text.StartsWith('{')) { $text = '{' + $text + '}' } + $parsed = $text | ConvertFrom-Json -ErrorAction Stop + if ($parsed -isnot [pscustomobject]) { throw 'Export tags must be a JSON object; tag cost coverage is incomplete.' } + foreach ($property in $parsed.PSObject.Properties) { + if ($null -ne $property.Value -and $property.Value -isnot [string]) { throw 'Export tag values must be strings; tag cost coverage is incomplete.' } + $out[$property.Name] = [string]$property.Value } return $out } @@ -610,25 +683,29 @@ function Get-CostExportData { foreach ($part in $runParts) { $blobUri = "$blobBase/$container/$([uri]::EscapeUriString($part.Name))" $bytes = Get-StorageBlobBytes -Uri $blobUri -StorageToken $token - if (-not $bytes) { continue } + if (-not $bytes) { throw "Export part '$($part.Name)' could not be read; cost coverage is incomplete." } $csvText = $null if ($part.Name -match '\.gz$') { $csvText = Expand-GzipText -Content $bytes } else { $csvText = [System.Text.Encoding]::UTF8.GetString($bytes) } - if (-not $csvText) { continue } + if (-not $csvText) { throw "Export part '$($part.Name)' could not be decoded; cost coverage is incomplete." } - $parsed = @($csvText | ConvertFrom-Csv) - if ($parsed.Count -eq 0) { continue } + $parsed = @($csvText | ConvertFrom-Csv -ErrorAction Stop) + if ($parsed.Count -eq 0) { throw "Export part '$($part.Name)' contains no cost rows; coverage is unverified." } if (-not $colMap) { $firstHeader = @($parsed[0].PSObject.Properties.Name) $colMap = Resolve-ExportColumns -Header $firstHeader } + $headerSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$firstHeader, [System.StringComparer]::OrdinalIgnoreCase) + if (-not $headerSet.SetEquals([string[]]$parsed[0].PSObject.Properties.Name)) { + throw 'Export part schemas differ; cost coverage is incomplete.' + } foreach ($r in $parsed) { [void]$rows.Add($r) } } # Determine currency from the first row that has one - $currency = 'USD' + $currency = if ($colMap.Cost -eq 'CostInUSD') { 'USD' } else { $null } if ($colMap -and $colMap.Currency) { $c = ($rows | Where-Object { $_.$($colMap.Currency) } | Select-Object -First 1) if ($c) { $currency = $c.$($colMap.Currency) } @@ -643,6 +720,7 @@ function Get-CostExportData { Headers = $firstHeader NoCostColumn = ($colMap -and -not $colMap.Cost) NoData = ($rows.Count -eq 0) + CostBasis = if ($Export.ScopeKind -eq 'Storage') { 'Unknown' } else { $Export.Type } } } @@ -662,6 +740,7 @@ function ConvertTo-CostDataFromExport { [Parameter(Mandatory)][object]$ExportData, [Parameter(Mandatory)][object[]]$Subscriptions ) + $ExportData = Select-CostExportData -ExportData $ExportData -Subscriptions $Subscriptions $costMap = @{} $cm = $ExportData.ColMap if (-not $cm -or -not $cm.Cost) { return $costMap } @@ -673,9 +752,6 @@ function ConvertTo-CostDataFromExport { if ($g) { $guidToKey[$g.ToLower()] = $s.Id } } - # Seed every selected sub so the UI shows them even at $0 - foreach ($s in $Subscriptions) { $costMap[$s.Id] = @{ Actual = 0; Forecast = 0; Currency = $ExportData.Currency } } - $skippedRows = 0 foreach ($r in $ExportData.Rows) { # SubscriptionId may be a bare GUID (classic) or a /subscriptions/ @@ -684,11 +760,20 @@ function ConvertTo-CostDataFromExport { if ([string]::IsNullOrWhiteSpace($rawSub) -and $cm.ResourceId) { $rawSub = "$($r.$($cm.ResourceId))" } $g = Get-GuidFromString -Value $rawSub if (-not $g) { continue } + + # An export is written at its own scope, which is usually the whole billing + # account. A row for a subscription the user did not select is out of scope, + # so skipping it keeps the total matching the requested scope. if (-not $guidToKey.ContainsKey($g.ToLower())) { $skippedRows++; continue } $key = $guidToKey[$g.ToLower()] + $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" if (-not $costMap.ContainsKey($key)) { - $costMap[$key] = @{ Actual = 0; Forecast = 0; Currency = $ExportData.Currency } + $subscriptionPeriod = $ExportData.PeriodsBySubscription[$g] + $costMap[$key] = @{ + Actual = 0; Forecast = $null; ForecastSource = 'Unavailable'; Currency = $ExportData.Currency + ActualPeriod = $subscriptionPeriod.Period; ActualPeriodStart = $subscriptionPeriod.PeriodStart; ActualPeriodEnd = $subscriptionPeriod.PeriodEnd + } } $costMap[$key].Actual += $cost } @@ -697,13 +782,8 @@ function ConvertTo-CostDataFromExport { Write-Verbose " Export covers a wider scope: ignored $skippedRows row(s) for unselected subscriptions." } - # Linear month-to-date projection for a sensible forecast - $now = Get-Date - $daysInMo = [DateTime]::DaysInMonth($now.Year, $now.Month) - $dayOfMo = [math]::Max(1, $now.Day) foreach ($k in @($costMap.Keys)) { $costMap[$k].Actual = [math]::Round($costMap[$k].Actual, 2) - $costMap[$k].Forecast = [math]::Round($costMap[$k].Actual / $dayOfMo * $daysInMo, 2) } return $costMap } @@ -715,9 +795,10 @@ function ConvertTo-ResourceCostsFromExport { [Parameter(Mandatory)][object]$ExportData, [Parameter(Mandatory)][object[]]$Subscriptions ) + $ExportData = Select-CostExportData -ExportData $ExportData -Subscriptions $Subscriptions $out = [System.Collections.Generic.List[PSCustomObject]]::new() $cm = $ExportData.ColMap - if (-not $cm -or -not $cm.Cost -or -not $cm.ResourceId) { return $out } + if (-not $cm.ResourceId) { throw 'Resource IDs are unavailable for part of this export; resource cost coverage is incomplete.' } $subNameMap = @{} foreach ($s in $Subscriptions) { $subNameMap[$s.Id.ToLower()] = $s.Name } @@ -725,44 +806,36 @@ function ConvertTo-ResourceCostsFromExport { $agg = @{} foreach ($r in $ExportData.Rows) { $rid = if ($cm.ResourceId) { "$($r.$($cm.ResourceId))".Trim() } else { '' } - if (-not $rid) { continue } + $subId = [string]$r.($cm.SubscriptionId) $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" - $key = $rid.ToLower() + $key = if ($rid) { $rid.ToLower() } else { "$subId|non-resource charges" } if (-not $agg.ContainsKey($key)) { - $subId = '' - if ($rid -match '/subscriptions/([^/]+)/') { $subId = $Matches[1].ToLower() } $rg = if ($cm.ResourceGroup) { "$($r.$($cm.ResourceGroup))" } else { '' } if (-not $rg -and $rid -match '/resourcegroups/([^/]+)/') { $rg = $Matches[1] } $agg[$key] = @{ - ResourcePath = $rid + ResourcePath = if ($rid) { $rid } else { '(non-resource charges)' } ResourceGroup = $rg - ResourceType = Get-ExportResourceType -ResourceId $rid - Subscription = if ($subId -and $subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { '' } + ResourceType = if ($rid) { Get-ExportResourceType -ResourceId $rid } else { 'Non-resource charge' } + Subscription = if ($subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { $subId } + ActualPeriod = $ExportData.PeriodsBySubscription[$subId].Period Cost = 0.0 } } $agg[$key].Cost += $cost } - # Linear month-to-date projection so the per-resource forecast matches the - # subscription-level export forecast (ConvertTo-CostDataFromExport). Without - # this, Forecast == Actual (MTD) and downstream views (e.g. AHB "With AHB - # (Mo.)") show a flat month-to-date number instead of a month-end projection. - $now = Get-Date - $daysInMo = [DateTime]::DaysInMonth($now.Year, $now.Month) - $dayOfMo = [math]::Max(1, $now.Day) - foreach ($v in $agg.Values) { $c = [math]::Round($v.Cost, 2) - $fc = [math]::Round($c / $dayOfMo * $daysInMo, 2) [void]$out.Add([PSCustomObject]@{ Subscription = $v.Subscription ResourceGroup = $v.ResourceGroup ResourceType = $v.ResourceType ResourcePath = $v.ResourcePath Actual = $c - Forecast = $fc + Forecast = $null + ForecastSource = 'Unavailable' Currency = $ExportData.Currency + ActualPeriod = $v.ActualPeriod }) } return @($out | Sort-Object Actual -Descending) @@ -773,27 +846,32 @@ function ConvertTo-CostByTagFromExport { [CmdletBinding()] param( [Parameter(Mandatory)][object]$ExportData, - [hashtable]$ExistingTags = @{} + [hashtable]$ExistingTags = @{}, + [object[]]$Subscriptions ) + $ExportData = Select-CostExportData -ExportData $ExportData -Subscriptions $Subscriptions $cm = $ExportData.ColMap $results = @{} - if (-not $cm -or -not $cm.Cost -or -not $cm.Tags) { - return [PSCustomObject]@{ TagsQueried = @(); CostByTag = $results; NoTagsFound = $true; UsedTimeframe = 'Export' } - } + if (-not $cm.Tags) { throw 'Tags are unavailable for part of this export; tag cost coverage is incomplete.' } # tagKey -> ( tagValue -> cost ) $byKey = @{} + $keys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($tagKey in $ExistingTags.Keys) { [void]$keys.Add($tagKey) } + $tagRows = [System.Collections.Generic.List[object]]::new() foreach ($r in $ExportData.Rows) { $raw = "$($r.$($cm.Tags))" - if ([string]::IsNullOrWhiteSpace($raw)) { continue } $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" - if ($cost -eq 0) { continue } $tags = ConvertFrom-ExportTagString -Raw $raw - foreach ($tk in $tags.Keys) { - $tv = $tags[$tk] - if (-not $byKey.ContainsKey($tk)) { $byKey[$tk] = @{} } + if ($ExistingTags.Count -eq 0) { foreach ($tagKey in $tags.Keys) { [void]$keys.Add($tagKey) } } + [void]$tagRows.Add(@{ Cost = $cost; Tags = $tags }) + } + foreach ($row in $tagRows) { + foreach ($tk in $keys) { + $tv = if ($row.Tags.ContainsKey($tk)) { if ($row.Tags[$tk]) { $row.Tags[$tk] } else { '(empty)' } } else { '(untagged)' } + if (-not $byKey.ContainsKey($tk)) { $byKey[$tk] = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::Ordinal) } if (-not $byKey[$tk].ContainsKey($tv)) { $byKey[$tk][$tv] = 0.0 } - $byKey[$tk][$tv] += $cost + $byKey[$tk][$tv] += $row.Cost } } @@ -821,20 +899,22 @@ function ConvertTo-CostByTagFromExport { # show whatever months the export's date range contains. function ConvertTo-CostTrendFromExport { [CmdletBinding()] - param([Parameter(Mandatory)][object]$ExportData) + param( + [Parameter(Mandatory)][object]$ExportData, + [object[]]$Subscriptions + ) + $ExportData = Select-CostExportData -ExportData $ExportData -Subscriptions $Subscriptions $cm = $ExportData.ColMap $months = [System.Collections.Generic.List[PSCustomObject]]::new() $bySub = @{} - if (-not $cm -or -not $cm.Cost -or -not $cm.Date) { - return [PSCustomObject]@{ Months = @(); BySubscription = $bySub; HasData = $false } - } + if (-not $cm.Date) { throw 'Dates are unavailable for part of this export; cost trend coverage is incomplete.' } $agg = @{} # yyyy-MM -> @{ Cost; Date } $subAgg = @{} # subId -> ( yyyy-MM -> @{ Cost; Date } ) foreach ($r in $ExportData.Rows) { $dt = $null - try { $dt = [datetime]"$($r.$($cm.Date))" } catch { continue } + try { $dt = [datetime]"$($r.$($cm.Date))" } catch { throw 'An export row has an invalid date; cost trend coverage is incomplete.' } $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" $firstOfMo = Get-Date -Year $dt.Year -Month $dt.Month -Day 1 -Hour 0 -Minute 0 -Second 0 $key = $dt.ToString('yyyy-MM') @@ -892,51 +972,47 @@ function Get-MergedCostExportData { [CmdletBinding()] param( [Parameter(Mandatory)][object[]]$Exports, - [string]$Environment = 'AzureCloud' + [string]$Environment = 'AzureCloud', + [object[]]$Subscriptions ) - # Dedupe by subscription: keep the newest-run export per SubId so two - # exports covering the same subscription do not double-count. $bestBySub = @{} - $noSub = [System.Collections.Generic.List[object]]::new() + $sourceIndex = 0 foreach ($exp in $Exports) { - if (-not $exp) { continue } - $sid = "$($exp.SubId)" - if ([string]::IsNullOrWhiteSpace($sid)) { [void]$noSub.Add($exp); continue } - $existing = $bestBySub[$sid] - if (-not $existing) { $bestBySub[$sid] = $exp; continue } - $a = if ($exp.LastRunDate) { [datetime]$exp.LastRunDate } else { [datetime]::MinValue } - $b = if ($existing.LastRunDate) { [datetime]$existing.LastRunDate } else { [datetime]::MinValue } - if ($a -gt $b) { $bestBySub[$sid] = $exp } + if (-not $exp) { throw 'An export descriptor is missing; cost coverage is incomplete.' } + $sourceIndex++ + $rawData = Get-CostExportData -Export $exp -Environment $Environment + if (-not $rawData) { throw "Export '$($exp.Name)' could not be read; cost coverage is incomplete." } + $data = Select-CostExportData -ExportData $rawData -Subscriptions $Subscriptions -SkipCoverageCheck + $runDate = if ($data.DataDate) { [datetime]$data.DataDate } + elseif ($exp.LastRunDate) { [datetime]$exp.LastRunDate } + else { [datetime]::MinValue } + foreach ($group in ($data.Rows | Group-Object SubscriptionId)) { + $existing = $bestBySub[$group.Name] + if (-not $existing -or $runDate -gt $existing.RunDate) { + $bestBySub[$group.Name] = @{ Rows = @($group.Group); ColMap = $data.ColMap; RunDate = $runDate; SourceIndex = $sourceIndex } + } + } } - $chosen = @($bestBySub.Values) + @($noSub) $allRows = [System.Collections.Generic.List[object]]::new() $colMap = $null - $headers = @() - $currency = 'USD' $dataDate = $null - $readAny = $false - - foreach ($exp in $chosen) { - $data = Get-CostExportData -Export $exp -Environment $Environment - if (-not $data -or $data.NoData -or -not $data.Rows -or @($data.Rows).Count -eq 0) { continue } - $readAny = $true - if (-not $colMap -and $data.ColMap) { $colMap = $data.ColMap; $headers = $data.Headers } - if ($data.Currency) { $currency = $data.Currency } - if ($data.DataDate -and (-not $dataDate -or [datetime]$data.DataDate -gt $dataDate)) { $dataDate = [datetime]$data.DataDate } - foreach ($r in $data.Rows) { [void]$allRows.Add($r) } + $usedSources = [System.Collections.Generic.HashSet[int]]::new() + foreach ($data in $bestBySub.Values) { + if (-not $colMap) { $colMap = $data.ColMap.Clone() } + else { + foreach ($column in @($colMap.Keys)) { + if (-not $data.ColMap.ContainsKey($column)) { $colMap.Remove($column) } + } + } + if (-not $dataDate -or $data.RunDate -gt $dataDate) { $dataDate = $data.RunDate } + [void]$usedSources.Add($data.SourceIndex) + foreach ($row in $data.Rows) { [void]$allRows.Add($row) } } - - return [PSCustomObject]@{ - Rows = $allRows - ColMap = $colMap - DataDate = $dataDate - Currency = $currency - RowCount = $allRows.Count - Headers = $headers - NoCostColumn = ($colMap -and -not $colMap.Cost) - NoData = (-not $readAny -or $allRows.Count -eq 0) - ExportCount = @($chosen).Count + $merged = [pscustomobject]@{ + Rows = $allRows.ToArray(); ColMap = $colMap; DataDate = $dataDate; ExportCount = $usedSources.Count + NoData = ($allRows.Count -eq 0); CostBasis = 'ActualCost' } + return Select-CostExportData -ExportData $merged -Subscriptions $Subscriptions } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 index 9a04f12e7..fe7b04750 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 @@ -34,9 +34,7 @@ param() # exposes) and returns the SAME shape the storage converters # (ConvertTo-*FromHub) produce, so the cost tools are unchanged. # -# Cost parity: the storage converters treat a 0 BilledCost as falsy and fall -# through to EffectiveCost. The KQL below replicates that exact coalescing so -# the Kusto path and the storage path return identical numbers. +# Cost parity: actual-cost summaries use BilledCost, including measured zero. # # ── Functions ─────────────────────────────────────────────────── # Resolve-FOHubProvider Decide provider (override | discovered | none) @@ -53,9 +51,7 @@ param() ########################################################################### # -- Shared KQL snippets -------------------------------------------------- -# Replicates the storage converter's cost selection: BilledCost unless it is -# null or 0, in which case EffectiveCost (matches `if ($row.BilledCost)`). -$script:FOHubCostExpr = 'todouble(iff(isnull(BilledCost) or BilledCost == 0, EffectiveCost, BilledCost))' +$script:FOHubCostExpr = 'todouble(BilledCost)' function Get-FOHubAnchorLet { # Anchor the reporting window to the latest month that actually has data @@ -70,7 +66,7 @@ function Get-FOHubWindowClause { # Trailing N calendar months ending at the latest data month (_anchor). param([int]$Months = 1) $back = [math]::Max(0, $Months - 1) - return "| where ChargePeriodStart >= datetime_add('month', -$back, _anchor)" + return "| where isnull(ChargePeriodStart) or ChargePeriodStart >= datetime_add('month', -$back, _anchor)" } function Get-FOHubScopeClause { @@ -108,6 +104,51 @@ function Invoke-FOHubProviderQuery { return Invoke-FOHubKustoQuery -ClusterUri $Provider.ClusterUri -Database $Provider.Database -Query $Query -AccessToken $token } +function Invoke-FOHubCostQuery { + param( + [Parameter(Mandatory)][hashtable]$Provider, + [Parameter(Mandatory)][string]$Query, + [string[]]$SubscriptionIds, + [int]$Months = 1 + ) + + $scope = Get-FOHubScopeClause -SubscriptionIds $SubscriptionIds + $expectedIds = ConvertTo-Json -InputObject @($SubscriptionIds | Where-Object { $_ } | ForEach-Object { ([guid]$_).ToString() }) -Compress + $validatedQuery = @" +$(Get-FOHubAnchorLet) +let src = Costs +$(Get-FOHubWindowClause -Months $Months) +$scope +| extend _cost = $($script:FOHubCostExpr), _sub = tolower(extract('([0-9a-fA-F-]{36})', 1, SubAccountId)); +let validation = src +| summarize _InvalidCosts = countif(isnull(_cost) or not(isfinite(_cost)) or isempty(BillingCurrency) or isnull(ChargePeriodStart)), + _CurrencyCount = array_length(make_set(BillingCurrency, 2)), _SourceRows = count(), + _MissingSubscriptions = array_length(set_difference(dynamic($expectedIds), make_set(_sub))) +| extend _CostValidation = true; +union validation, ( +$Query +) +"@ + $result = Invoke-FOHubProviderQuery -Provider $Provider -Query $validatedQuery + if (-not $result.Ok) { return $result } + $validation = @($result.Rows | Where-Object { $_._CostValidation -eq $true }) + if ($validation.Count -ne 1 -or $null -eq $validation[0]._InvalidCosts -or $null -eq $validation[0]._MissingSubscriptions -or + $null -eq $validation[0]._SourceRows -or $null -eq $validation[0]._CurrencyCount -or + $validation[0]._InvalidCosts -ne 0 -or $validation[0]._MissingSubscriptions -ne 0 -or + ($validation[0]._SourceRows -gt 0 -and $validation[0]._CurrencyCount -ne 1)) { + return @{ Ok = $false; Rows = @(); Error = 'Hub cost validation failed: missing or invalid amounts, currency, dates, or subscription coverage.' } + } + $rows = @($result.Rows | Where-Object { $_._CostValidation -ne $true }) + try { + foreach ($row in $rows) { + $column = if ($row.PSObject.Properties.Name -contains 'Actual') { 'Actual' } else { 'Cost' } + $null = Get-HubCostValue -Row $row -Column $column + } + } + catch { return @{ Ok = $false; Rows = @(); Error = $_.Exception.Message } } + return @{ Ok = $true; Rows = $rows; RowCount = $rows.Count; Error = $null } +} + # -- Provider resolution -------------------------------------------------- function Resolve-FOHubProvider { [CmdletBinding()] @@ -187,18 +228,12 @@ function Get-FOHubCostSummary { [string[]]$SubscriptionIds, [int]$Months = 1 ) - $window = Get-FOHubWindowClause -Months $Months - $scope = Get-FOHubScopeClause -SubscriptionIds $SubscriptionIds $query = @" -$(Get-FOHubAnchorLet) -Costs -$window -$scope -| extend _sub = extract('([0-9a-fA-F-]{36})', 1, tolower(SubAccountId)) -| extend _cost = $($script:FOHubCostExpr) -| summarize Actual = sum(_cost), Currency = take_any(BillingCurrency), Name = take_any(SubAccountName) by _sub +src +| summarize Actual = sum(_cost), Currency = take_any(BillingCurrency), Name = take_any(SubAccountName), + ActualPeriodStart = min(ChargePeriodStart), ActualPeriodEnd = max(ChargePeriodStart) by _sub "@ - $r = Invoke-FOHubProviderQuery -Provider $Provider -Query $query + $r = Invoke-FOHubCostQuery -Provider $Provider -Query $query -SubscriptionIds $SubscriptionIds -Months $Months if (-not $r.Ok) { return @{ Error = $r.Error; Source = 'Kusto' } } $costMap = @{} @@ -211,9 +246,11 @@ $scope $subName = if ($row.Name) { [string]$row.Name } else { '' } $costMap[$subId] = @{ Actual = [math]::Round([double]$row.Actual, 2) - Forecast = 0.0 + Forecast = $null + ForecastSource = 'Unavailable' Currency = $currency Name = $subName + ActualPeriod = if ($row.ActualPeriodStart -and $row.ActualPeriodEnd) { '{0:yyyy-MM-dd} to {1:yyyy-MM-dd}' -f [datetime]$row.ActualPeriodStart, [datetime]$row.ActualPeriodEnd } else { 'Unknown' } } } return $costMap @@ -228,20 +265,14 @@ function Get-FOHubResourceCosts { [int]$Months = 1, [int]$Top = 500 ) - $window = Get-FOHubWindowClause -Months $Months - $scope = Get-FOHubScopeClause -SubscriptionIds $SubscriptionIds $query = @" -$(Get-FOHubAnchorLet) -Costs -$window -$scope -| extend _cost = $($script:FOHubCostExpr) +src | summarize Actual = sum(_cost), Currency = take_any(BillingCurrency) by Subscription = SubAccountName, ResourceGroup = x_ResourceGroupName, ResourceType, ResourcePath = ResourceId | order by Actual desc | take $Top "@ - $r = Invoke-FOHubProviderQuery -Provider $Provider -Query $query + $r = Invoke-FOHubCostQuery -Provider $Provider -Query $query -SubscriptionIds $SubscriptionIds -Months $Months if (-not $r.Ok) { return @{ Error = $r.Error; Source = 'Kusto' } } $out = foreach ($row in $r.Rows) { @@ -251,7 +282,7 @@ $scope ResourceType = if ($row.ResourceType) { [string]$row.ResourceType } else { 'unknown' } ResourcePath = [string]$row.ResourcePath Actual = [math]::Round([double]$row.Actual, 2) - Forecast = 0.0 + Forecast = $null Currency = if ($row.Currency) { [string]$row.Currency } else { 'USD' } } } @@ -269,35 +300,7 @@ function Get-FOHubCostByTag { [int]$Months = 1, [string[]]$TagKeys ) - $window = Get-FOHubWindowClause -Months $Months - $scope = Get-FOHubScopeClause -SubscriptionIds $SubscriptionIds - - # Discover tag keys when the caller didn't supply a set to report on. $keys = @($TagKeys | Where-Object { $_ }) - if ($keys.Count -eq 0) { - $keyQuery = @" -$(Get-FOHubAnchorLet) -Costs -$window -$scope -| mv-expand k = bag_keys(Tags) to typeof(string) -| distinct k -| take 200 -"@ - $kr = Invoke-FOHubProviderQuery -Provider $Provider -Query $keyQuery - if (-not $kr.Ok) { return @{ Error = $kr.Error; Source = 'Kusto' } } - $keys = @($kr.Rows | ForEach-Object { [string]$_.k } | Where-Object { $_ }) - } - - if ($keys.Count -eq 0) { - return [PSCustomObject]@{ - TagsQueried = @() - CostByTag = @{} - NoTagsFound = $true - UsedTimeframe = 'Hub query period' - Source = 'Kusto' - } - } # One snapshot: a sentinel *TOTAL* row plus per-(key,value) cost. Untagged # cost per key is derived in PowerShell as total minus the key's tagged sum @@ -305,21 +308,16 @@ $scope # 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 ', ' + $tagFilter = if ($keys.Count -gt 0) { "| where k in~ ($keyList)" } else { '' } $query = @" -$(Get-FOHubAnchorLet) -let src = Costs -$window -$scope -| extend _cost = $($script:FOHubCostExpr); -let total = src | summarize Cost = sum(_cost), Currency = take_any(BillingCurrency) | extend TagKey = '*TOTAL*', TagValue = '*TOTAL*'; -let perTag = src +union (src | summarize Cost = sum(_cost), Currency = take_any(BillingCurrency) | extend TagKey = '*TOTAL*', TagValue = '*TOTAL*'), +(src | mv-expand k = bag_keys(Tags) to typeof(string) -| where k in ($keyList) +$tagFilter | extend TagValue = tostring(Tags[k]) -| summarize Cost = sum(_cost), Currency = take_any(BillingCurrency) by TagKey = k, TagValue; -union total, perTag +| summarize Cost = sum(_cost), Currency = take_any(BillingCurrency) by TagKey = k, TagValue) "@ - $r = Invoke-FOHubProviderQuery -Provider $Provider -Query $query + $r = Invoke-FOHubCostQuery -Provider $Provider -Query $query -SubscriptionIds $SubscriptionIds -Months $Months if (-not $r.Ok) { return @{ Error = $r.Error; Source = 'Kusto' } } $currency = 'USD' @@ -330,12 +328,13 @@ union total, perTag $cost = [double]$row.Cost if ($row.Currency) { $currency = [string]$row.Currency } if ($tk -eq '*TOTAL*') { $total = $cost; continue } - if (-not $tagged.ContainsKey($tk)) { $tagged[$tk] = @{} } + if (-not $tagged.ContainsKey($tk)) { $tagged[$tk] = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::Ordinal) } $tv = if ($null -ne $row.TagValue -and "$($row.TagValue)" -ne '') { [string]$row.TagValue } else { '(empty)' } if (-not $tagged[$tk].ContainsKey($tv)) { $tagged[$tk][$tv] = 0.0 } $tagged[$tk][$tv] += $cost } + if ($keys.Count -eq 0) { $keys = @($tagged.Keys) } $costByTagOut = @{} foreach ($key in $keys) { $values = if ($tagged.ContainsKey($key)) { $tagged[$key] } else { @{} } @@ -346,7 +345,7 @@ union total, perTag $entries = @($values.GetEnumerator() | ForEach-Object { [PSCustomObject]@{ TagValue = $_.Key; Cost = [math]::Round($_.Value, 2); Currency = $currency } }) - if ($untagged -gt 0) { + if ($untagged -ne 0) { $entries += [PSCustomObject]@{ TagValue = '(untagged)'; Cost = $untagged; Currency = $currency } } $costByTagOut[$key] = @($entries | Sort-Object Cost -Descending) diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index df270620f..3aaf00143 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -76,6 +76,9 @@ function New-KpiValue { [PSCustomObject]@{ Display = $Display; Value = $Value } } +# Returns the utilization figures that were actually measured. A family with no +# commitments reports 0, which would otherwise read as a measured 0% and halve +# the average for anyone who owns reservations but no savings plans. function Get-CommitmentUtilizationValue { param($Data) @@ -91,6 +94,55 @@ function Get-CommitmentUtilizationValue { return $vals } +function Get-BudgetKpiData { + param($Data) + + $budgets = @(Get-ScanField $Data 'Budgets') + if (-not $budgets -or (Get-ScanField $Data 'CoverageIncomplete')) { + return @{ Error = 'Unavailable: budget inventory is incomplete.' } + } + $currency = $null + $timeGrain = $null + $subscriptions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $percentages = [System.Collections.Generic.List[double]]::new() + $totalBudget = 0.0 + $totalActual = 0.0 + $now = (Get-Date).ToUniversalTime() + $monthStart = $now.Date.AddDays(1 - $now.Day) + foreach ($budget in $budgets) { + if ($budget.Category -ne 'Cost' -or -not $budget.Currency -or -not $budget.TimeGrain -or + $budget.SpendSource -eq 'Unavailable' -or -not $budget.SubscriptionId) { + return @{ Error = 'Unavailable: budget amounts, scope, units, or current spend are unknown.' } + } + try { + if ($budget.TimeGrain -ne 'Monthly' -or -not $budget.TimePeriod.startDate -or + ([datetime]$budget.TimePeriod.startDate).ToUniversalTime() -gt $monthStart -or + ($budget.TimePeriod.endDate -and ([datetime]$budget.TimePeriod.endDate).ToUniversalTime() -lt $now)) { + return @{ Error = 'Unavailable: budgets do not have a verified common current-month window.' } + } + } + catch { return @{ Error = 'Unavailable: a budget reporting period is invalid.' } } + if (($currency -and $currency -ne $budget.Currency) -or ($timeGrain -and $timeGrain -ne $budget.TimeGrain)) { + return @{ Error = 'Unavailable: budget currencies or reporting periods differ.' } + } + if (-not $subscriptions.Add([string]$budget.SubscriptionId)) { + return @{ Error = 'Unavailable: multiple budgets can overlap within a subscription.' } + } + try { + $amount = Get-HubCostValue -Row $budget -Column 'Amount' + $actual = Get-HubCostValue -Row $budget -Column 'ActualSpend' + if ($amount -le 0) { throw 'Budget amount must be positive.' } + } + catch { return @{ Error = 'Unavailable: a budget amount or current spend is invalid.' } } + $currency = $budget.Currency + $timeGrain = $budget.TimeGrain + $totalBudget += $amount + $totalActual += $actual + [void]$percentages.Add(100 * $actual / $amount) + } + return @{ Error = $null; Currency = $currency; TotalBudget = $totalBudget; TotalActual = $totalActual; Percentages = $percentages.ToArray() } +} + function Get-KpiComputedValue { param([string]$KpiId, $Data, $Catalog) @@ -122,9 +174,15 @@ function Get-KpiComputedValue { 'effective-avg-compute-cost-per-core' { $v = Get-ScanField $Data 'CostPerVCpu' $cur = Get-ScanField $Data 'Currency' + # Month-to-date, not a full month, so say so rather than implying a run rate. if ($null -ne $v -and $v -gt 0) { return (New-KpiValue "$cur $v per vCPU (month-to-date)" ([double]$v)) } } 'commitment-utilization-score' { + # Get-CommitmentUtilization seeds both averages to 0 and only fills the + # ones it found, so 0 usually means "none of this kind" (or access + # denied) rather than a measured 0%. Gate on the counts: a real 0% with + # commitments present still counts, an absent family does not drag the + # average down. $vals = @(Get-CommitmentUtilizationValue -Data $Data) if ($vals.Count -gt 0) { $avg = [math]::Round(($vals | Measure-Object -Average).Average, 1) @@ -166,34 +224,24 @@ function Get-KpiComputedValue { } } 'budget-burn-rate' { - # Unweighted mean of per-budget percentages, so a large budget does not dominate. - $budgets = Get-ScanField $Data 'Budgets' - if ($budgets) { - $pcts = @($budgets | ForEach-Object { $_.PctUsed } | Where-Object { $null -ne $_ }) - if ($pcts.Count -gt 0) { - $avg = [math]::Round(($pcts | Measure-Object -Average).Average, 1) - $word = if ($pcts.Count -eq 1) { 'budget' } else { 'budgets' } - return (New-KpiValue "$avg% of budget consumed (average of $($pcts.Count) $word)" $avg) - } - } + $budgetData = Get-BudgetKpiData -Data $Data + if ($budgetData.Error) { return (New-KpiValue $budgetData.Error) } + $pcts = $budgetData.Percentages + $avg = [math]::Round(($pcts | Measure-Object -Average).Average, 1) + $word = if ($pcts.Count -eq 1) { 'budget' } else { 'budgets' } + return (New-KpiValue "$avg% of budget consumed (average of $($pcts.Count) comparable $word)" $avg) } 'variance-budget-vs-actual' { - # Weighted by budget size, unlike budget-burn-rate which averages percentages. - $budgets = Get-ScanField $Data 'Budgets' - if ($budgets) { - $totBudget = ($budgets | Measure-Object -Property Amount -Sum).Sum - $totActual = ($budgets | Measure-Object -Property ActualSpend -Sum).Sum - $cur = Get-ScanField $Data 'Currency' - if (-not $cur) { $cur = 'USD' } - if ($totBudget -and $totBudget -gt 0) { - $pctOfPlan = [math]::Round(100 * $totActual / $totBudget, 1) - $spend = '{0:N0}' -f [math]::Round([double]$totActual, 0) - $plan = '{0:N0}' -f [math]::Round([double]$totBudget, 0) - # Score on distance from plan in either direction. - $variance = [math]::Abs([math]::Round(100 * ($totActual - $totBudget) / $totBudget, 1)) - return (New-KpiValue "Actual is $pctOfPlan% of planned ($cur $spend of $cur $plan, all budgets combined)" $variance) - } - } + $budgetData = Get-BudgetKpiData -Data $Data + if ($budgetData.Error) { return (New-KpiValue $budgetData.Error) } + $totBudget = $budgetData.TotalBudget + $totActual = $budgetData.TotalActual + $cur = $budgetData.Currency + $pctOfPlan = [math]::Round(100 * $totActual / $totBudget, 1) + $spend = '{0:N0}' -f [math]::Round([double]$totActual, 0) + $plan = '{0:N0}' -f [math]::Round([double]$totBudget, 0) + $variance = [math]::Abs([math]::Round(100 * ($totActual - $totBudget) / $totBudget, 1)) + return (New-KpiValue "Actual is $pctOfPlan% of planned ($cur $spend of $cur $plan, comparable budgets)" $variance) } 'effective-savings-rate' { # Realized monthly savings from commitments + AHB (proxy: a true rate @@ -229,8 +277,11 @@ function Get-KpiComputedValue { $cur = Get-ScanField $Data 'Currency' if (-not $cur) { $cur = 'USD' } if ($null -ne $tokens -and [long]$tokens -gt 0) { - $costStr = if ($null -ne $cost -and [double]$cost -gt 0) { " for $cur $([math]::Round([double]$cost, 2)) (MTD)" } else { '' } - return (New-KpiValue "$('{0:N0}' -f [long]$tokens) tokens$costStr" ([long]$tokens)) + $costStr = if ($null -ne $cost -and [double]$cost -gt 0) { " for $cur $([math]::Round([double]$cost, 2))" } else { '' } + $period = Get-ScanField $Data 'Period' + if ($period -eq 'MonthToDate') { $period = 'Month to date' } + elseif (-not $period) { $period = 'Unknown period' } + return (New-KpiValue "$('{0:N0}' -f [long]$tokens) tokens$costStr ($period)" ([long]$tokens)) } } 'cost-per-api-call' { @@ -323,7 +374,7 @@ function Add-KpiInsights { foreach ($kpi in $matched) { $value = $null if ($kpi.compute) { $value = Get-KpiComputedValue -KpiId $kpi.id -Data $data -Catalog $catalog } - $status = if ($value) { 'computed' } else { 'informational' } + $status = if ($value -and $null -ne $value.Value) { 'computed' } elseif ($value) { 'unavailable' } else { 'informational' } $insights += [PSCustomObject]@{ kpiId = $kpi.id kpiName = $kpi.name diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index 34253f952..668ea8ce5 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -455,8 +455,7 @@ function Read-ParquetFile { return $results } catch { - Write-Warning "Failed to read parquet file $Path`: $($_.Exception.Message)" - return @() + throw "Failed to read Parquet file '$Path'; cost coverage is incomplete. $($_.Exception.Message)" } } @@ -615,6 +614,7 @@ function Read-FinOpsHubData { [string]$ResourceGroupName, [Parameter()] + [ValidateRange(1, 36)] [int]$Months = 1, # Restrict returned rows to these subscriptions. A hub holds every @@ -626,12 +626,17 @@ function Read-FinOpsHubData { Write-Host " Connecting to Hub storage: $StorageAccountName" -ForegroundColor DarkGray + $wanted = @{} + foreach ($subscriptionId in $SubscriptionIds) { + $parsedId = [guid]::Empty + if (-not [guid]::TryParse($subscriptionId, [ref]$parsedId)) { throw 'Invalid subscription ID; refusing an unscoped hub read.' } + $wanted[$parsedId.ToString()] = $true + } try { $ctx = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount -ErrorAction Stop } catch { - Write-Host " Failed to connect to Hub storage: $($_.Exception.Message)" -ForegroundColor Yellow - return $null + throw "Failed to connect to hub storage; cost coverage is incomplete. $($_.Exception.Message)" } $allData = [System.Collections.Generic.List[PSCustomObject]]::new() @@ -644,9 +649,11 @@ function Read-FinOpsHubData { for ($m = 0; $m -lt $Months; $m++) { $d = $now.AddMonths(-$m) $basePath = "Costs/$($d.ToString('yyyy'))/$($d.ToString('MM'))" + $listed = $false try { $blobs = @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'ingestion' -Path $basePath -Recurse -ErrorAction Stop | Where-Object { -not $_.IsDirectory -and $_.Name -like '*.parquet' }) + $listed = $true if ($blobs.Count -gt 0) { # Install parquet reader on first parquet file encountered @@ -666,7 +673,7 @@ function Read-FinOpsHubData { $localFile = Join-Path $tempDir "$([guid]::NewGuid().ToString('N')).parquet" try { Get-AzDataLakeGen2ItemContent -Context $ctx -FileSystem 'ingestion' -Path $blob.Path -Destination $localFile -Force -ErrorAction Stop | Out-Null - $rows = Read-ParquetFile -Path $localFile + $rows = @(Read-ParquetFile -Path $localFile -ErrorAction Stop) if ($rows -and @($rows).Count -gt 0) { foreach ($row in $rows) { $allData.Add($row) } Write-Host " Loaded $(@($rows).Count) rows from $(Split-Path $blob.Path -Leaf)" -ForegroundColor DarkGray @@ -679,8 +686,8 @@ function Read-FinOpsHubData { } } catch { - # Path doesn't exist yet — that's OK - Write-Verbose "Non-fatal: $($_.Exception.Message)" + if (-not $listed -and $_.Exception.Message -match 'PathNotFound|FilesystemNotFound|\b404\b') { continue } + throw "Hub ingestion read failed; cost coverage is incomplete. $($_.Exception.Message)" } } @@ -688,7 +695,7 @@ function Read-FinOpsHubData { if ($allData.Count -eq 0) { Write-Host " No parquet in ingestion — reading CSV from msexports..." -ForegroundColor DarkGray - $csvBlobs = @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'msexports' -Recurse -ErrorAction SilentlyContinue | + $csvBlobs = @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'msexports' -Recurse -ErrorAction Stop | Where-Object { -not $_.IsDirectory -and $_.Path -like '*.csv' }) if ($csvBlobs.Count -gt 0) { @@ -729,14 +736,14 @@ function Read-FinOpsHubData { $localFile = Join-Path $tempDir "$([guid]::NewGuid().ToString('N'))-$(Split-Path $blob.Path -Leaf)" try { Get-AzDataLakeGen2ItemContent -Context $ctx -FileSystem 'msexports' -Path $blob.Path -Destination $localFile -Force -ErrorAction Stop | Out-Null - $rows = Import-Csv -Path $localFile + $rows = Import-Csv -Path $localFile -ErrorAction Stop if ($rows -and @($rows).Count -gt 0) { foreach ($row in $rows) { $allData.Add($row) } $periodRows += @($rows).Count } } catch { - Write-Warning "Failed to read CSV: $($_.Exception.Message)" + throw "Hub CSV read failed; cost coverage is incomplete. $($_.Exception.Message)" } finally { Remove-Item $localFile -Force -ErrorAction SilentlyContinue @@ -766,11 +773,22 @@ 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 } } + if ($wanted.Count -gt 0) { $before = $allData.Count - $allData = @($allData | Where-Object { $wanted.ContainsKey((Get-FinOpsHubRowSubscriptionId $_)) }) + $covered = @{} + $selectedRows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($row in $allData) { + $rowSubscription = Get-FinOpsHubRowSubscriptionId $row + if (-not $rowSubscription) { throw 'A hub row has no subscription ID; cost coverage is incomplete.' } + if ($wanted.ContainsKey($rowSubscription)) { + [void]$selectedRows.Add($row) + $covered[$rowSubscription] = $true + } + } + foreach ($subscriptionId in $wanted.Keys) { + if (-not $covered.ContainsKey($subscriptionId)) { throw "No hub rows for selected subscription '$subscriptionId'; cost coverage is incomplete." } + } + $allData = $selectedRows.ToArray() Write-Host " Scoped to $($SubscriptionIds.Count) selected subscription(s): $($allData.Count) of $before rows" -ForegroundColor DarkGray } @@ -788,6 +806,8 @@ function ConvertTo-CostDataFromHub { # that Get-CostData returns: @{ subscriptionId = @{ Actual; Forecast; Currency } } $costMap = @{} $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData + $costCol = $costSchema.CostColumn foreach ($row in $HubData) { $subId = if ($props -contains 'SubAccountId' -and $row.SubAccountId) { $row.SubAccountId } @@ -805,17 +825,19 @@ function ConvertTo-CostDataFromHub { elseif ($props -contains 'SubscriptionName' -and $row.SubscriptionName) { [string]$row.SubscriptionName } else { '' } - $cost = if ($props -contains 'CostInBillingCurrency' -and $row.CostInBillingCurrency) { [double]$row.CostInBillingCurrency } - elseif ($props -contains 'BilledCost' -and $row.BilledCost) { [double]$row.BilledCost } - elseif ($props -contains 'EffectiveCost' -and $row.EffectiveCost) { [double]$row.EffectiveCost } - else { 0 } + $cost = Get-HubCostValue -Row $row -Column $costCol $currency = if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $row.BillingCurrency } elseif ($props -contains 'BillingCurrencyCode' -and $row.BillingCurrencyCode) { $row.BillingCurrencyCode } - else { 'USD' } + else { $costSchema.Currency } if (-not $costMap.ContainsKey($subId)) { - $costMap[$subId] = @{ Actual = 0.0; Forecast = 0.0; Currency = $currency; Name = $subName } + $subscriptionPeriod = $costSchema.PeriodsBySubscription[$subId] + $costMap[$subId] = @{ + Actual = 0.0; Forecast = $null; ForecastSource = 'Unavailable'; Currency = $currency; Name = $subName + ActualPeriodStart = $subscriptionPeriod.PeriodStart; ActualPeriodEnd = $subscriptionPeriod.PeriodEnd + ActualPeriod = if ($subscriptionPeriod) { $subscriptionPeriod.Period } else { 'Unknown' } + } } $costMap[$subId].Actual += $cost } @@ -833,6 +855,8 @@ function ConvertTo-ResourceCostsFromHub { # Aggregate by resource and return in the same format as Get-ResourceCosts $resourceMap = @{} $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData + $costCol = $costSchema.CostColumn foreach ($row in $HubData) { $subName = if ($props -contains 'SubAccountName' -and $row.SubAccountName) { $row.SubAccountName } @@ -854,25 +878,24 @@ function ConvertTo-ResourceCostsFromHub { elseif ($props -contains 'x_ResourceId' -and $row.x_ResourceId) { $row.x_ResourceId } else { "$rg/$resType" } - $cost = if ($props -contains 'CostInBillingCurrency' -and $row.CostInBillingCurrency) { [double]$row.CostInBillingCurrency } - elseif ($props -contains 'BilledCost' -and $row.BilledCost) { [double]$row.BilledCost } - elseif ($props -contains 'EffectiveCost' -and $row.EffectiveCost) { [double]$row.EffectiveCost } - else { 0 } + $cost = Get-HubCostValue -Row $row -Column $costCol $currency = if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $row.BillingCurrency } elseif ($props -contains 'BillingCurrencyCode' -and $row.BillingCurrencyCode) { $row.BillingCurrencyCode } - else { 'USD' } + else { $costSchema.Currency } $key = $resId if (-not $resourceMap.ContainsKey($key)) { + $subscriptionPeriod = $costSchema.PeriodsBySubscription[(Get-FinOpsHubRowSubscriptionId $row)] $resourceMap[$key] = [PSCustomObject]@{ Subscription = $subName ResourceGroup = $rg ResourceType = $resType ResourcePath = $resId Actual = 0.0 - Forecast = 0.0 + Forecast = $null Currency = $currency + ActualPeriod = if ($subscriptionPeriod) { $subscriptionPeriod.Period } else { 'Unknown' } } } $resourceMap[$key].Actual += $cost @@ -897,6 +920,109 @@ function Get-HubRowValue { return $null } +function Resolve-HubCostColumn { + param( + [string[]]$Props, + [ValidateSet('ActualCost', 'AmortizedCost')] + [string]$CostBasis = 'ActualCost' + ) + + $candidates = if ($CostBasis -eq 'AmortizedCost') { @('EffectiveCost') } + else { @('BilledCost', 'CostInBillingCurrency', 'PreTaxCost', 'Cost') } + foreach ($column in $candidates) { + if ($Props -contains $column) { return $column } + } + throw "No $CostBasis column is available; cost results are incomplete." +} + +function Get-HubCostValue { + param( + [Parameter(Mandatory)][object]$Row, + [string]$Column + ) + if (-not $Column) { throw 'No cost column was selected; cost results are incomplete.' } + $raw = $Row.$Column + $text = ([string]$raw).Trim() + if ($text.Contains(',') -and $text -notmatch '^[+-]?\d{1,3}(,\d{3})+(\.\d+)?([eE][+-]?\d+)?$') { + throw "Invalid numeric grouping in '$Column'; cost results are incomplete." + } + $amount = 0.0 + $styles = [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands + if ($null -eq $raw -or + -not [double]::TryParse($text, $styles, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$amount) -or + [double]::IsNaN($amount) -or [double]::IsInfinity($amount)) { + throw "Missing or invalid cost in '$Column'; cost results are incomplete." + } + return $amount +} + +function Get-HubCostSchema { + param( + [Parameter(Mandatory)][object[]]$HubData, + [ValidateSet('ActualCost', 'AmortizedCost')] + [string]$CostBasis = 'ActualCost' + ) + + $headers = [System.Collections.Generic.HashSet[string]]::new([string[]]$HubData[0].PSObject.Properties.Name, [System.StringComparer]::OrdinalIgnoreCase) + $costColumn = Resolve-HubCostColumn -Props @($headers) -CostBasis $CostBasis + $currency = $null + $periodStart = $null + $periodEnd = $null + $periodKnown = $true + $periodsBySubscription = @{} + foreach ($row in $HubData) { + if (-not $headers.SetEquals([string[]]$row.PSObject.Properties.Name)) { + throw 'Hub row schemas differ; cost results are incomplete.' + } + $null = Get-HubCostValue -Row $row -Column $costColumn + $rowCurrency = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency')) + if ([string]::IsNullOrWhiteSpace($rowCurrency)) { + throw 'Billing currency is missing; cost results are incomplete.' + } + $rowCurrency = $rowCurrency.Trim().ToUpperInvariant() + if ($currency -and $currency -ne $rowCurrency) { + throw 'Multiple billing currencies cannot be combined into one cost total.' + } + $currency = $rowCurrency + $rawDate = Get-HubRowValue -Row $row -Names @('ChargePeriodStart', 'Date', 'UsageDate', 'UsageDateTime') + $date = $null + try { + $date = if ($rawDate -is [datetime]) { $rawDate.ToUniversalTime() } + elseif ([string]$rawDate -match '^\d{8}$') { [datetime]::ParseExact([string]$rawDate, 'yyyyMMdd', [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime() } + else { [datetimeoffset]::Parse([string]$rawDate, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal).UtcDateTime } + if ($null -eq $periodStart -or $date -lt $periodStart) { $periodStart = $date } + if ($null -eq $periodEnd -or $date -gt $periodEnd) { $periodEnd = $date } + } + catch { $periodKnown = $false } + $subscriptionId = Get-FinOpsHubRowSubscriptionId $row + if ($subscriptionId) { + if (-not $periodsBySubscription.ContainsKey($subscriptionId)) { + $periodsBySubscription[$subscriptionId] = @{ PeriodStart = $null; PeriodEnd = $null; Known = $true } + } + $subscriptionPeriod = $periodsBySubscription[$subscriptionId] + if ($null -eq $date) { $subscriptionPeriod.Known = $false } + else { + if ($null -eq $subscriptionPeriod.PeriodStart -or $date -lt $subscriptionPeriod.PeriodStart) { $subscriptionPeriod.PeriodStart = $date } + if ($null -eq $subscriptionPeriod.PeriodEnd -or $date -gt $subscriptionPeriod.PeriodEnd) { $subscriptionPeriod.PeriodEnd = $date } + } + } + } + if (-not $periodKnown) { $periodStart = $null; $periodEnd = $null } + foreach ($subscriptionPeriod in $periodsBySubscription.Values) { + if (-not $subscriptionPeriod.Known) { $subscriptionPeriod.PeriodStart = $null; $subscriptionPeriod.PeriodEnd = $null } + $subscriptionPeriod.Period = if ($subscriptionPeriod.Known) { + '{0:yyyy-MM-dd} to {1:yyyy-MM-dd}' -f $subscriptionPeriod.PeriodStart, $subscriptionPeriod.PeriodEnd + } + else { 'Unknown' } + } + return @{ + CostColumn = $costColumn; Currency = $currency; CostBasis = $CostBasis + PeriodStart = $periodStart; PeriodEnd = $periodEnd + PeriodsBySubscription = $periodsBySubscription + Period = if ($null -ne $periodStart -and $null -ne $periodEnd) { '{0:yyyy-MM-dd} to {1:yyyy-MM-dd}' -f $periodStart, $periodEnd } else { 'Unknown' } + } +} + # Helper: convert a billing unit string (e.g. "1K", "1M", "1,000", # "100 Tokens") into the number of tokens one unit of quantity represents. # Azure OpenAI token meters are billed per-1K tokens, so an unqualified @@ -970,6 +1096,9 @@ function ConvertTo-AIHubAggregates { } $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData -CostBasis 'AmortizedCost' + $costCol = $costSchema.CostColumn + $currency = $costSchema.Currency foreach ($row in $HubData) { $resType = Get-HubRowValue -Row $row -Props $props -Names @('ResourceType', 'x_ResourceType', 'ConsumedService') @@ -982,8 +1111,7 @@ function ConvertTo-AIHubAggregates { $name = Get-HubRowValue -Row $row -Props $props -Names @('ResourceName') if (-not $name) { $name = Split-Path "$rid" -Leaf } - $cost = Get-HubRowValue -Row $row -Props $props -Names @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost') - $cost = if ($null -ne $cost) { [double]$cost } else { 0.0 } + $cost = Get-HubCostValue -Row $row -Column $costCol $cur = Get-HubRowValue -Row $row -Props $props -Names @('BillingCurrency', 'BillingCurrencyCode') if ($cur) { $currency = "$cur" } @@ -1044,6 +1172,7 @@ function ConvertTo-AIHubAggregates { HasTokens = ($totalTokens -gt 0) HasCost = ($aiCost -gt 0) Approximate = $approximate + Period = $costSchema.Period } } @@ -1172,42 +1301,32 @@ function ConvertTo-CostByTagFromHub { # Aggregate cost by tag key/value from FOCUS cost data # Returns same structure as Get-CostByTag $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData + $costCol = $costSchema.CostColumn $costByTag = @{} - $currency = 'USD' - - # Determine which tags to report on - $targetTags = if ($ExistingTags -and $ExistingTags.Count -gt 0) { - @($ExistingTags.Keys) - } - else { @() } + $currency = $costSchema.Currency + $targetTags = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($tagKey in $ExistingTags.Keys) { [void]$targetTags.Add($tagKey) } + $tagRows = [System.Collections.Generic.List[object]]::new() foreach ($row in $HubData) { - $cost = if ($props -contains 'CostInBillingCurrency' -and $row.CostInBillingCurrency) { [double]$row.CostInBillingCurrency } - elseif ($props -contains 'BilledCost' -and $row.BilledCost) { [double]$row.BilledCost } - elseif ($props -contains 'EffectiveCost' -and $row.EffectiveCost) { [double]$row.EffectiveCost } - else { 0 } - - if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $currency = $row.BillingCurrency } - + $cost = Get-HubCostValue -Row $row -Column $costCol $tagsJson = if ($props -contains 'Tags') { $row.Tags } else { $null } - $tagDict = $null - if ($tagsJson -and $tagsJson.Trim() -ne '' -and $tagsJson.Trim() -ne '{}') { - try { $tagDict = ConvertTo-HashtableFromJson -Json $tagsJson } catch { - Write-Verbose "Non-fatal: $($_.Exception.Message)" - } - } - - if ($targetTags.Count -eq 0 -and $tagDict -and $tagDict.Count -gt 0) { - $targetTags = @($tagDict.Keys) + $tagDict = ConvertFrom-ExportTagString -Raw $tagsJson + if (-not $ExistingTags -or $ExistingTags.Count -eq 0) { + foreach ($tagKey in $tagDict.Keys) { [void]$targetTags.Add($tagKey) } } + [void]$tagRows.Add(@{ Cost = $cost; Tags = $tagDict }) + } + foreach ($row in $tagRows) { foreach ($tagKey in $targetTags) { - if (-not $costByTag.ContainsKey($tagKey)) { $costByTag[$tagKey] = @{} } - $tagVal = if ($tagDict -and $tagDict.ContainsKey($tagKey)) { "$($tagDict[$tagKey])" } else { '(untagged)' } + if (-not $costByTag.ContainsKey($tagKey)) { $costByTag[$tagKey] = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::Ordinal) } + $tagVal = if ($row.Tags.ContainsKey($tagKey)) { [string]$row.Tags[$tagKey] } else { '(untagged)' } if (-not $tagVal -or $tagVal -eq '') { $tagVal = '(empty)' } if (-not $costByTag[$tagKey].ContainsKey($tagVal)) { $costByTag[$tagKey][$tagVal] = 0.0 } - $costByTag[$tagKey][$tagVal] += $cost + $costByTag[$tagKey][$tagVal] += $row.Cost } } diff --git a/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 index d94881a4f..4f141f1b0 100644 --- a/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 +++ b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 @@ -101,19 +101,23 @@ Describe 'Budget coverage reporting' { BudgetName = 'monthly-budget' Amount = 100 TimeGrain = 'Monthly' + Category = 'Cost' + Currency = 'USD' + Filter = $null + TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-2); endDate = (Get-Date).ToUniversalTime().Date.AddYears(2) } } ) $script:Trend2 = [PSCustomObject]@{ BySubscription = @{ $script:SubA = @(2, 1 | ForEach-Object { - [PSCustomObject]@{ MonthDate = (Get-Date).AddMonths(-$_); Cost = 10 } + [PSCustomObject]@{ MonthDate = (Get-Date).AddMonths(-$_); Cost = 10; Currency = 'USD' } }) } } $script:Trend6 = [PSCustomObject]@{ BySubscription = @{ $script:SubA = @(6, 5, 4, 3, 2, 1 | ForEach-Object { - [PSCustomObject]@{ MonthDate = (Get-Date).AddMonths(-$_); Cost = 10 } + [PSCustomObject]@{ MonthDate = (Get-Date).AddMonths(-$_); Cost = 10; Currency = 'USD' } }) } } @@ -174,5 +178,58 @@ Describe 'Budget coverage reporting' { $Path -like '*page=2' -and $Method -eq 'POST' -and ($Payload | ConvertFrom-Json).dataset.granularity -eq 'Monthly' } } + + It 'Does not compare subscription history with a budget' -ForEach @( + @{ Case = 'filtered'; Change = 'Filter' } + @{ Case = 'quarterly'; Change = 'Quarter' } + @{ Case = 'usage'; Change = 'Usage' } + @{ Case = 'missing amount'; Change = 'Amount' } + @{ Case = 'unknown currency'; Change = 'Currency' } + @{ Case = 'unknown validity period'; Change = 'Period' } + ) { + $budget = $script:HistBudget[0] | Select-Object * + switch ($Change) { + 'Filter' { $budget.Filter = @{ tags = @{ name = 'CostCenter'; operator = 'In'; values = @('team') } } } + 'Quarter' { $budget.TimeGrain = 'Quarterly' } + 'Usage' { $budget.Category = 'Usage' } + 'Amount' { $budget.Amount = $null } + 'Currency' { $budget.Currency = $null } + 'Period' { $budget.TimePeriod = $null } + } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw 'Unsupported budgets must not query unfiltered history.' } + + $rows = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6) + + $rows.Count | Should -Be 2 + foreach ($row in $rows) { + $row.ActualSpend | Should -BeNullOrEmpty + $row.PctUsed | Should -BeNullOrEmpty + $row.Status | Should -Be 'Unavailable' + $row.Note | Should -Not -BeNullOrEmpty + } + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + } + + It 'Does not present spend before the budget start as being under budget' { + $budget = $script:HistBudget[0] | Select-Object * + $budget.TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddDays(1 - (Get-Date).ToUniversalTime().Day) } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw 'No active historical months.' } + + $rows = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6) + + @($rows | Where-Object Status -EQ 'Unavailable').Count | Should -Be 2 + $rows[0].ActualSpend | Should -BeNullOrEmpty + } + + It 'Rejects a cached currency mismatch instead of relabeling it' { + $budget = $script:HistBudget[0] | Select-Object * + $budget.Currency = 'EUR' + + $rows = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6) + + @($rows | Where-Object Status -EQ 'Unavailable').Count | Should -Be 2 + $rows[0].Note | Should -Match 'currenc' + $rows[0].ActualSpend | Should -BeNullOrEmpty + } } } diff --git a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 index 9d6140de5..5d0529a90 100644 --- a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 +++ b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 @@ -600,7 +600,10 @@ Describe 'Cost Management query pagination' { if ($scanName -eq 'TrendPartial') { $subscriptions += [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'second' } } - $budgets = @([pscustomobject]@{ SubscriptionId = $subscriptions[0].Id; Subscription = 'first'; Amount = 1000; BudgetName = 'test'; TimeGrain = 'Monthly' }) + $budgets = @([pscustomobject]@{ + SubscriptionId = $subscriptions[0].Id; Subscription = 'first'; Amount = 1000; BudgetName = 'test'; TimeGrain = 'Monthly' + Category = 'Cost'; Currency = 'USD'; TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-2) } + }) { switch ($scanName) { diff --git a/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 b/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 index 065ef6bc8..1b7349c5f 100644 --- a/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 +++ b/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 @@ -129,8 +129,9 @@ Describe 'FinOps Hub Kusto provider' { RowCount = 2 Error = $null Rows = @( + [PSCustomObject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 1; _SourceRows = 2; _MissingSubscriptions = 0 } [PSCustomObject]@{ _sub = 'aaaaaaaa-1111-2222-3333-444444444444'; Actual = 123.456; Currency = 'USD' } - [PSCustomObject]@{ _sub = 'bbbbbbbb-1111-2222-3333-444444444444'; Actual = 10.0; Currency = 'EUR' } + [PSCustomObject]@{ _sub = 'bbbbbbbb-1111-2222-3333-444444444444'; Actual = 10.0; Currency = 'USD' } ) } } @@ -140,9 +141,13 @@ Describe 'FinOps Hub Kusto provider' { $map | Should -BeOfType ([hashtable]) $map.Keys.Count | Should -Be 2 $map['aaaaaaaa-1111-2222-3333-444444444444'].Actual | Should -Be 123.46 - $map['aaaaaaaa-1111-2222-3333-444444444444'].Forecast | Should -Be 0.0 + $map['aaaaaaaa-1111-2222-3333-444444444444'].Forecast | Should -BeNullOrEmpty $map['aaaaaaaa-1111-2222-3333-444444444444'].Currency | Should -Be 'USD' - $map['bbbbbbbb-1111-2222-3333-444444444444'].Currency | Should -Be 'EUR' + $map['bbbbbbbb-1111-2222-3333-444444444444'].Currency | Should -Be 'USD' + Should -Invoke Invoke-FOHubKustoQuery -Times 1 -Exactly -ParameterFilter { + $Query.Contains('todouble(BilledCost)') -and -not $Query.Contains('EffectiveCost') -and + $Query.Contains('isnull(_cost) or not(isfinite(_cost))') + } } } @@ -165,6 +170,7 @@ Describe 'FinOps Hub Kusto provider' { RowCount = 2 Error = $null Rows = @( + [PSCustomObject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 1; _SourceRows = 2; _MissingSubscriptions = 0 } [PSCustomObject]@{ Subscription = 'Sub A'; ResourceGroup = 'rg1'; ResourceType = 'Microsoft.Compute/virtualMachines'; ResourcePath = '/subscriptions/x/rg1/vm1'; Actual = 50.0; Currency = 'USD' } [PSCustomObject]@{ Subscription = 'Sub A'; ResourceGroup = 'rg2'; ResourceType = 'Microsoft.Storage/storageAccounts'; ResourcePath = '/subscriptions/x/rg2/sa1'; Actual = 200.0; Currency = 'USD' } ) @@ -177,12 +183,50 @@ Describe 'FinOps Hub Kusto provider' { $rows[0].PSObject.Properties.Name | Should -Contain 'ResourcePath' $rows[0].PSObject.Properties.Name | Should -Contain 'Forecast' $rows[0].Actual | Should -Be 200.0 - $rows[0].Forecast | Should -Be 0.0 + $rows[0].Forecast | Should -BeNullOrEmpty } } } Context 'Get-FOHubCostByTag shape' { + It 'Preserves tag value case and negative untagged credits' { + InModuleScope FinOpsMultitool { + Mock Invoke-FOHubKustoQuery { + @{ Ok = $true; Rows = @( + [pscustomobject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 1; _SourceRows = 3; _MissingSubscriptions = 0 } + [pscustomobject]@{ TagKey = '*TOTAL*'; Cost = 100; Currency = 'USD' } + [pscustomobject]@{ TagKey = 'env'; TagValue = 'Prod'; Cost = 50; Currency = 'USD' } + [pscustomobject]@{ TagKey = 'env'; TagValue = 'prod'; Cost = 70; Currency = 'USD' } + ) } + } + $provider = @{ UseAuth = $false; ClusterUri = 'http://localhost:8082'; Database = 'Hub' } + + $result = Get-FOHubCostByTag -Provider $provider -TagKeys @('env') + + @($result.CostByTag.env).Count | Should -Be 3 + ($result.CostByTag.env | Where-Object { $_.TagValue -ceq 'Prod' }).Cost | Should -Be 50 + ($result.CostByTag.env | Where-Object { $_.TagValue -ceq 'prod' }).Cost | Should -Be 70 + ($result.CostByTag.env | Where-Object TagValue -EQ '(untagged)').Cost | Should -Be -20 + ($result.CostByTag.env | Measure-Object Cost -Sum).Sum | Should -Be 100 + } + } + + It 'Validates coverage even when no tags are discovered' { + InModuleScope FinOpsMultitool { + Mock Invoke-FOHubKustoQuery { + if ($Query.Contains('_CostValidation')) { + return @{ Ok = $true; Rows = @([pscustomobject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 0; _SourceRows = 0; _MissingSubscriptions = 1 }) } + } + @{ Ok = $true; Rows = @() } + } + $provider = @{ UseAuth = $false; ClusterUri = 'http://localhost:8082'; Database = 'Hub' } + + $result = Get-FOHubCostByTag -Provider $provider -SubscriptionIds @('44444444-4444-4444-4444-444444444444') + + $result.Error | Should -BeLike '*validation failed*' + } + } + It 'Derives (untagged) cost per key from the TOTAL sentinel' { InModuleScope FinOpsMultitool { Mock Invoke-FOHubKustoQuery { @@ -191,6 +235,7 @@ Describe 'FinOps Hub Kusto provider' { RowCount = 3 Error = $null Rows = @( + [PSCustomObject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 1; _SourceRows = 3; _MissingSubscriptions = 0 } [PSCustomObject]@{ TagKey = '*TOTAL*'; TagValue = '*TOTAL*'; Cost = 1000.0; Currency = 'USD' } [PSCustomObject]@{ TagKey = 'env'; TagValue = 'prod'; Cost = 600.0; Currency = 'USD' } [PSCustomObject]@{ TagKey = 'env'; TagValue = 'dev'; Cost = 300.0; Currency = 'USD' } @@ -211,4 +256,27 @@ Describe 'FinOps Hub Kusto provider' { } } } + + It 'Rejects failed whole-scope cost validation ()' -ForEach @( + @{ Case = 'unknown charge date'; InvalidCosts = 1; CurrencyCount = 1; MissingSubscriptions = 0 } + @{ Case = 'invalid amount outside top results'; InvalidCosts = 1; CurrencyCount = 1; MissingSubscriptions = 0 } + @{ Case = 'mixed currencies'; InvalidCosts = 0; CurrencyCount = 2; MissingSubscriptions = 0 } + @{ Case = 'missing selected subscription'; InvalidCosts = 0; CurrencyCount = 1; MissingSubscriptions = 1 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ InvalidCosts = $InvalidCosts; CurrencyCount = $CurrencyCount; MissingSubscriptions = $MissingSubscriptions } { + param($InvalidCosts, $CurrencyCount, $MissingSubscriptions) + $validationRow = [pscustomobject]@{ _CostValidation = $true; _InvalidCosts = $InvalidCosts; _CurrencyCount = $CurrencyCount; _SourceRows = 10; _MissingSubscriptions = $MissingSubscriptions } + Mock Invoke-FOHubKustoQuery { + @{ Ok = $true; Rows = @($validationRow, [pscustomobject]@{ Actual = 100; Currency = 'USD'; _sub = 'aaaaaaaa-1111-2222-3333-444444444444' }) } + } + $provider = @{ UseAuth = $false; ClusterUri = 'http://localhost:8082'; Database = 'Hub' } + (Get-FOHubCostSummary -Provider $provider).Error | Should -BeLike '*validation failed*' + (Get-FOHubResourceCosts -Provider $provider -Top 1).Error | Should -BeLike '*validation failed*' + (Get-FOHubCostByTag -Provider $provider -TagKeys @('env')).Error | Should -BeLike '*validation failed*' + Should -Invoke Invoke-FOHubKustoQuery -Times 3 -Exactly -ParameterFilter { + $Query.Contains('where isnull(ChargePeriodStart) or') -and + $Query.Contains('or isnull(ChargePeriodStart))') + } + } + } } diff --git a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 index 99e1c4c73..fa7266f7c 100644 --- a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 +++ b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 @@ -96,8 +96,8 @@ Describe 'FinOps Multitool safety' { It 'Reports failure instead of inventing percentages' { (ConvertTo-AllocationPercentage -Targets @()).Ok | Should -BeFalse (ConvertTo-AllocationPercentage -Targets @( - @{ subscriptionId = 'a'; allocatedShared = 0 } - )).Ok | Should -BeFalse + @{ subscriptionId = 'a'; allocatedShared = 0 } + )).Ok | Should -BeFalse } } @@ -119,9 +119,10 @@ Describe 'FinOps Multitool safety' { } } - It 'Returns zero for values that are not numbers' { - ConvertTo-ExportAmount 'not-a-number' | Should -Be 0 - ConvertTo-ExportAmount '' | Should -Be 0 + It 'Rejects unreadable values and malformed numeric grouping' { + { ConvertTo-ExportAmount 'not-a-number' } | Should -Throw '*cost*' + { ConvertTo-ExportAmount '' } | Should -Throw '*cost*' + { ConvertTo-ExportAmount '123,45' } | Should -Throw '*grouping*' } } @@ -203,6 +204,9 @@ Describe 'FinOps Multitool cost math' { Context 'Cost column resolution' { + # 'CostStatus' contains 'cost'. A substring match picked it up and, because + # the loop kept going, the later match won -- so the cost index pointed at + # a column holding the text 'Actual' or 'Forecast'. It 'Resolves Cost and not CostStatus when both are present' { InModuleScope FinOpsMultitool { $columns = @( @@ -293,6 +297,8 @@ Describe 'FinOps Multitool cost math' { } } + # Month-to-date spend presented as a forecast understates the full month, + # so callers need to be able to tell the two apart. It 'Rejects an unavailable forecast instead of returning actual spend as a projection' { InModuleScope FinOpsMultitool { Mock Invoke-AzRestMethodWithRetry { @@ -317,14 +323,140 @@ Describe 'FinOps Multitool cost math' { } } + Context 'Budget spend source' { + + # An annual budget compared against one month of subscription spend reads as + # roughly a twelfth of its real consumption, so an exhausted budget reports + # On Track. Azure already computes currentSpend for the budget's own scope, + # filter and time grain. + It 'Prefers the budget currentSpend over subscription month-to-date' { + InModuleScope FinOpsMultitool { + $budget = [pscustomobject]@{ + name = 'annual-budget' + properties = [pscustomobject]@{ + amount = 12000 + timeGrain = 'Annually' + category = 'Cost' + currentSpend = [pscustomobject]@{ amount = 11400; unit = 'USD' } + forecastSpend = [pscustomobject]@{ amount = 13000; unit = 'USD' } + } + } + Mock Invoke-AzRestMethodWithRetry { + [pscustomobject]@{ + StatusCode = 200 + Content = (@{ value = @($budget) } | ConvertTo-Json -Depth 10) + } + } + + $subs = @([pscustomobject]@{ Id = '33333333-3333-3333-3333-333333333333'; Name = 'test' }) + # Subscription month-to-date is a small fraction of the annual budget. + $costData = @{ '33333333-3333-3333-3333-333333333333' = @{ Actual = 900; Forecast = 1000 } } + + $res = Get-BudgetStatus -Subscriptions $subs -CostData $costData + $b = @($res.Budgets)[0] + + $b.SpendSource | Should -Be 'Budget' + $b.ActualSpend | Should -Be 11400 + # 11400/12000 = 95%, and the forecast exceeds the budget. + $b.Risk | Should -Be 'Forecast Over' + } + } + } + Context 'Export scope' { + It 'Keeps unattributed resource charges in the selected subscription total' { + InModuleScope FinOpsMultitool { + $subscriptionId = '44444444-4444-4444-4444-444444444444' + $data = [pscustomobject]@{ CostBasis = 'ActualCost'; Rows = @( + [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = 10; Currency = 'USD'; ResourceId = "/subscriptions/$subscriptionId/resourceGroups/test/providers/Microsoft.Compute/disks/test" } + [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = 20; Currency = 'USD'; ResourceId = '' } + [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = -5; Currency = 'USD'; ResourceId = $null } + ) } + $subscriptions = @([pscustomobject]@{ Id = $subscriptionId; Name = 'test' }) + $rows = @(ConvertTo-ResourceCostsFromExport -ExportData $data -Subscriptions $subscriptions) + + ($rows | Measure-Object Actual -Sum).Sum | Should -Be 25 + ($rows | Where-Object ResourcePath -EQ '(non-resource charges)').Actual | Should -Be 15 + ($rows | Where-Object ResourcePath -EQ '(non-resource charges)').Subscription | Should -Be 'test' + } + } + + It 'Retains each subscription period in summaries' -ForEach @( + @{ Source = 'Hub' } + @{ Source = 'Export' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Source = $Source } { + param($Source) + $currentId = '44444444-4444-4444-4444-444444444444' + $staleId = '55555555-5555-5555-5555-555555555555' + $rows = @( + [pscustomobject]@{ SubAccountId = $currentId; BilledCost = 10; BillingCurrency = 'USD'; ChargePeriodStart = '2026-09-16' } + [pscustomobject]@{ SubAccountId = $staleId; BilledCost = 20; BillingCurrency = 'USD'; ChargePeriodStart = '2026-08-31' } + ) + $result = if ($Source -eq 'Hub') { ConvertTo-CostDataFromHub -HubData $rows } + else { ConvertTo-CostDataFromExport -ExportData ([pscustomobject]@{ Rows = $rows }) -Subscriptions @([pscustomobject]@{ Id = $currentId }, [pscustomobject]@{ Id = $staleId }) } + + $result[$currentId].ActualPeriod | Should -Be '2026-09-16 to 2026-09-16' + $result[$staleId].ActualPeriod | Should -Be '2026-08-31 to 2026-08-31' + $result[$currentId].Actual | Should -Be 10 + $result[$staleId].Actual | Should -Be 20 + } + } + + It 'Reports historical actuals without synthesizing a current-month forecast' { + InModuleScope FinOpsMultitool { + $subscriptionId = '44444444-4444-4444-4444-444444444444' + $data = [pscustomobject]@{ + CostBasis = 'ActualCost' + Rows = @([pscustomobject]@{ + SubscriptionId = $subscriptionId; Cost = 100; Currency = 'EUR'; Date = '2026-08-31' + ResourceId = "/subscriptions/$subscriptionId/resourceGroups/test/providers/Microsoft.Compute/disks/test" + }) + } + $subscriptions = @([pscustomobject]@{ Id = $subscriptionId; Name = 'test' }) + + $summary = (ConvertTo-CostDataFromExport -ExportData $data -Subscriptions $subscriptions)[$subscriptionId] + $resource = @(ConvertTo-ResourceCostsFromExport -ExportData $data -Subscriptions $subscriptions)[0] + + foreach ($entry in @($summary, $resource)) { + $entry.Actual | Should -Be 100 + $entry.Forecast | Should -BeNullOrEmpty + $entry.ForecastSource | Should -Be 'Unavailable' + $entry.ActualPeriod | Should -Be '2026-08-31 to 2026-08-31' + } + } + } + + It 'Requires a verified basis for a classic export ()' -ForEach @( + @{ Basis = $null } + @{ Basis = 'Usage' } + @{ Basis = 'AmortizedCost' } + @{ Basis = 'ActualCost' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Basis = $Basis } { + param($Basis) + $data = [pscustomobject]@{ + CostBasis = $Basis; Currency = 'USD' + Rows = @([pscustomobject]@{ SubscriptionId = '44444444-4444-4444-4444-444444444444'; CostInBillingCurrency = 100 }) + } + if ($Basis -eq 'ActualCost') { + (Select-CostExportData -ExportData $data).Rows[0].Cost | Should -Be 100 + } + else { { Select-CostExportData -ExportData $data } | Should -Throw '*actual cost*' } + } + } + + # An export is written at its own scope, usually the whole billing account. + # Treating an unrecognised subscription as a new entry reported cost for + # every subscription in the file, not the ones the user asked to scan. It 'Ignores rows for subscriptions that were not selected' { InModuleScope FinOpsMultitool { $selected = '44444444-4444-4444-4444-444444444444' $other = '55555555-5555-5555-5555-555555555555' $exportData = [pscustomobject]@{ + CostBasis = 'ActualCost' Currency = 'USD' ColMap = [pscustomobject]@{ Cost = 'Cost'; SubscriptionId = 'SubscriptionId'; ResourceId = $null } Rows = @( @@ -341,10 +473,497 @@ Describe 'FinOps Multitool cost math' { $map[$selected].Actual | Should -Be 10 } } + + It 'Uses the same selected billed-cost rows for ' -ForEach @( + @{ View = 'Summary' } + @{ View = 'Resources' } + @{ View = 'Tags' } + @{ View = 'Trend' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ View = $View } { + param($View) + $selected = '44444444-4444-4444-4444-444444444444' + $other = '55555555-5555-5555-5555-555555555555' + $rows = @( + [pscustomobject]@{ SubscriptionId = $selected; BilledCost = 10; EffectiveCost = 1; BillingCurrency = 'USD'; Date = '2026-09-01'; ResourceId = "/subscriptions/$selected/resourceGroups/test/providers/Microsoft.Compute/disks/one"; Tags = '{"CostCenter":"test"}' } + [pscustomobject]@{ SubscriptionId = $other; BilledCost = 990; EffectiveCost = 99; BillingCurrency = 'EUR'; Date = '2026-09-01'; ResourceId = "/subscriptions/$other/resourceGroups/test/providers/Microsoft.Compute/disks/two"; Tags = '{"CostCenter":"other"}' } + ) + $data = [pscustomobject]@{ Rows = $rows; ColMap = (Resolve-ExportColumns -Header $rows[0].PSObject.Properties.Name); Currency = 'USD' } + $subscriptions = @([pscustomobject]@{ Id = $selected; Name = 'selected' }) + $total = switch ($View) { + 'Summary' { (ConvertTo-CostDataFromExport -ExportData $data -Subscriptions $subscriptions)[$selected].Actual } + 'Resources' { (ConvertTo-ResourceCostsFromExport -ExportData $data -Subscriptions $subscriptions | Measure-Object Actual -Sum).Sum } + 'Tags' { ((ConvertTo-CostByTagFromExport -ExportData $data -Subscriptions $subscriptions).CostByTag.CostCenter | Measure-Object Cost -Sum).Sum } + 'Trend' { ((ConvertTo-CostTrendFromExport -ExportData $data -Subscriptions $subscriptions).Months | Measure-Object Cost -Sum).Sum } + } + $total | Should -Be 10 + } + } + + It 'Does not report an uncovered selected subscription as zero spend' { + InModuleScope FinOpsMultitool { + $selected = '44444444-4444-4444-4444-444444444444' + $missing = '55555555-5555-5555-5555-555555555555' + $data = [pscustomobject]@{ + CostBasis = 'ActualCost' + Currency = 'USD'; ColMap = @{ Cost = 'Cost'; SubscriptionId = 'SubscriptionId' } + Rows = @([pscustomobject]@{ Cost = 10; SubscriptionId = $selected }) + } + $subscriptions = @([pscustomobject]@{ Id = $selected }, [pscustomobject]@{ Id = $missing }) + { ConvertTo-CostDataFromExport -ExportData $data -Subscriptions $subscriptions } | Should -Throw '*coverage*' + } + } + + It 'Rejects unreadable export costs instead of using zero' -ForEach @( + @{ Value = '' } + @{ Value = 'bad' } + @{ Value = 'NaN' } + @{ Value = 'Infinity' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Value = $Value } { + param($Value) + $rawValue = $Value + { ConvertTo-ExportAmount -Value $rawValue } | Should -Throw '*cost*' + } + } + + It 'Merges resource and date aliases using row subscription IDs instead of storage ownership' { + InModuleScope FinOpsMultitool { + $selected = '44444444-4444-4444-4444-444444444444' + $other = '55555555-5555-5555-5555-555555555555' + Mock Get-CostExportData { + $row = if ($Export.Name -eq 'first') { + [pscustomobject]@{ SubscriptionId = $selected; BilledCost = 10; Currency = 'USD'; ResourceId = "/subscriptions/$selected/resourceGroups/test/providers/Microsoft.Compute/disks/one"; Date = '2026-09-01' } + } + else { + [pscustomobject]@{ SubAccountId = "/subscriptions/$other"; BilledCost = 20; BillingCurrency = 'USD'; x_ResourceId = "/subscriptions/$other/resourceGroups/test/providers/Microsoft.Compute/disks/two"; ChargePeriodStart = '2026-09-01' } + } + [pscustomobject]@{ Rows = @($row); ColMap = (Resolve-ExportColumns -Header $row.PSObject.Properties.Name); DataDate = [datetime]'2026-09-15'; Currency = 'USD' } + } + $exports = @([pscustomobject]@{ Name = 'first'; SubId = 'storage-owner'; ScopeKind = 'Storage' }, [pscustomobject]@{ Name = 'second'; SubId = 'storage-owner'; ScopeKind = 'Storage' }) + $subscriptions = @([pscustomobject]@{ Id = $selected; Name = 'first' }, [pscustomobject]@{ Id = $other; Name = 'second' }) + + $merged = Get-MergedCostExportData -Exports $exports -Subscriptions $subscriptions + + $merged.ExportCount | Should -Be 2 + @($merged.CoveredSubscriptionIds).Count | Should -Be 2 + (ConvertTo-ResourceCostsFromExport -ExportData $merged -Subscriptions $subscriptions | Measure-Object Actual -Sum).Sum | Should -Be 30 + ((ConvertTo-CostTrendFromExport -ExportData $merged).Months | Measure-Object Cost -Sum).Sum | Should -Be 30 + } + } + + It 'Rejects an unreadable or incompatible export rather than omitting it ()' -ForEach @( + @{ Case = 'unreadable'; Currency = $null } + @{ Case = 'different currency'; Currency = 'EUR' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Currency = $Currency } { + param($Currency) + $secondCurrency = $Currency + Mock Get-CostExportData { + if ($Export.Name -eq 'second' -and -not $secondCurrency) { return [pscustomobject]@{ Rows = @(); NoData = $true; AccessDenied = $true } } + $subscriptionId = if ($Export.Name -eq 'first') { '44444444-4444-4444-4444-444444444444' } else { '55555555-5555-5555-5555-555555555555' } + $rowCurrency = if ($Export.Name -eq 'first') { 'USD' } else { $secondCurrency } + [pscustomobject]@{ CostBasis = 'ActualCost'; Rows = @([pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = 10; Currency = $rowCurrency }); ColMap = @{ SubscriptionId = 'SubscriptionId'; Cost = 'Cost'; Currency = 'Currency' } } + } + { Get-MergedCostExportData -Exports @([pscustomobject]@{ Name = 'first' }, [pscustomobject]@{ Name = 'second' }) } | Should -Throw + } + } + + It 'Rejects a failed CSV partition instead of returning the readable part' { + InModuleScope FinOpsMultitool { + Mock Get-PlainAccessToken { 'test-token' } + Mock Get-StorageBlobList { + @{ Listed = $true; Blobs = @( + [pscustomobject]@{ Name = 'export/run/part1.csv'; LastModified = [datetime]'2026-09-15' } + [pscustomobject]@{ Name = 'export/run/part2.csv'; LastModified = [datetime]'2026-09-15' } + ) } + } + Mock Get-StorageBlobBytes { + if ($Uri -like '*part2.csv') { return $null } + [System.Text.Encoding]::UTF8.GetBytes("SubscriptionId,Cost,Currency`n44444444-4444-4444-4444-444444444444,10,USD") + } + $export = [pscustomobject]@{ Name = 'export'; Format = 'Csv'; RootFolder = ''; Container = 'exports'; StorageResourceId = '/subscriptions/test/resourceGroups/test/providers/Microsoft.Storage/storageAccounts/test' } + { Get-CostExportData -Export $export } | Should -Throw '*part2*incomplete*' + } + } + + It 'Keeps untagged charges and escaped JSON values in the selected tag total' { + InModuleScope FinOpsMultitool { + $subscriptionId = '44444444-4444-4444-4444-444444444444' + $rows = @( + [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = 10; Currency = 'USD'; Tags = '{"CostCenter":"A\"B"}' } + [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = 20; Currency = 'USD'; Tags = '' } + ) + $data = [pscustomobject]@{ CostBasis = 'ActualCost'; Rows = $rows; ColMap = (Resolve-ExportColumns -Header $rows[0].PSObject.Properties.Name) } + $result = ConvertTo-CostByTagFromExport -ExportData $data -ExistingTags @{ CostCenter = @{} } + ($result.CostByTag.CostCenter | Measure-Object Cost -Sum).Sum | Should -Be 30 + ($result.CostByTag.CostCenter | Where-Object TagValue -EQ '(untagged)').Cost | Should -Be 20 + ($result.CostByTag.CostCenter | Where-Object TagValue -EQ 'A"B').Cost | Should -Be 10 + } + } + + It 'Does not present missing date or tag columns as complete empty breakdowns' { + InModuleScope FinOpsMultitool { + $data = [pscustomobject]@{ CostBasis = 'ActualCost'; Rows = @([pscustomobject]@{ SubscriptionId = '44444444-4444-4444-4444-444444444444'; Cost = 10; Currency = 'USD' }) } + { ConvertTo-CostByTagFromExport -ExportData $data } | Should -Throw '*coverage*' + { ConvertTo-CostTrendFromExport -ExportData $data } | Should -Throw '*coverage*' + } + } + } + + Context 'Commitment cost basis' { + + # FOCUS records a reservation twice on purpose: once as BilledCost on the + # Purchase row, and again amortized across the Usage rows it covers, which + # EC9.1 requires to sum to the same amount. Choosing the cost column per row + # picked BilledCost on the purchase and EffectiveCost on the usage, so every + # commitment landed in the total twice. + It 'Counts a commitment once rather than twice' { + InModuleScope FinOpsMultitool { + $sub = '66666666-6666-6666-6666-666666666666' + $hubData = @( + # Reservation purchase: billed in full, zero amortized (EC6). + [pscustomobject]@{ + SubAccountId = $sub; SubAccountName = 'test' + BilledCost = 12000; EffectiveCost = 0; BillingCurrency = 'USD' + } + # Usage the reservation covers: nothing billed, amortized share only. + [pscustomobject]@{ + SubAccountId = $sub; SubAccountName = 'test' + BilledCost = 0; EffectiveCost = 9000; BillingCurrency = 'USD' + } + [pscustomobject]@{ + SubAccountId = $sub; SubAccountName = 'test' + BilledCost = 0; EffectiveCost = 3000; BillingCurrency = 'USD' + } + ) + + $map = ConvertTo-CostDataFromHub -HubData $hubData + + # 12000 amortized, not 12000 purchase + 12000 amortized. + $map[$sub].Actual | Should -Be 12000 + } + } + + It 'Resolves one cost column for the whole dataset' { + InModuleScope FinOpsMultitool { + Resolve-HubCostColumn -Props @('BilledCost', 'EffectiveCost') | Should -Be 'BilledCost' + Resolve-HubCostColumn -Props @('BilledCost', 'EffectiveCost') -CostBasis 'AmortizedCost' | Should -Be 'EffectiveCost' + Resolve-HubCostColumn -Props @('CostInBillingCurrency') | Should -Be 'CostInBillingCurrency' + { Resolve-HubCostColumn -Props @('ResourceId') } | Should -Throw '*cost*' + } + } + + It 'Uses billed cost for actual spend even when amortization differs' { + InModuleScope FinOpsMultitool { + $rows = @( + [pscustomobject]@{ SubAccountId = '66666666-6666-6666-6666-666666666666'; BilledCost = 12000; EffectiveCost = 0; BillingCurrency = 'USD' } + [pscustomobject]@{ SubAccountId = '66666666-6666-6666-6666-666666666666'; BilledCost = 0; EffectiveCost = 1000; BillingCurrency = 'USD' } + ) + + $result = ConvertTo-CostDataFromHub -HubData $rows + + $result['66666666-6666-6666-6666-666666666666'].Actual | Should -Be 12000 + } + } + + It 'Rejects an unreadable selected cost without switching bases ()' -ForEach @( + @{ Case = 'null'; Amount = $null } + @{ Case = 'blank'; Amount = '' } + @{ Case = 'invalid'; Amount = 'invalid' } + @{ Case = 'NaN'; Amount = 'NaN' } + @{ Case = 'infinity'; Amount = 'Infinity' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Amount = $Amount } { + param($Amount) + $row = [pscustomobject]@{ EffectiveCost = $Amount; BilledCost = 500 } + { Get-HubCostValue -Row $row -Column 'EffectiveCost' } | Should -Throw '*cost*' + } + } + + It 'Rejects a missing selected column on a later row' { + InModuleScope FinOpsMultitool { + $rows = @( + [pscustomobject]@{ SubAccountId = '66666666-6666-6666-6666-666666666666'; BilledCost = 10; BillingCurrency = 'USD' } + [pscustomobject]@{ SubAccountId = '66666666-6666-6666-6666-666666666666'; CostInBillingCurrency = 20; BillingCurrency = 'USD' } + ) + { ConvertTo-CostDataFromHub -HubData $rows } | Should -Throw '*cost*' + } + } + + It 'Preserves a measured zero and negative credit in the selected basis' { + InModuleScope FinOpsMultitool { + Get-HubCostValue -Row ([pscustomobject]@{ BilledCost = 0; EffectiveCost = 500 }) -Column 'BilledCost' | Should -Be 0 + Get-HubCostValue -Row ([pscustomobject]@{ BilledCost = -25 }) -Column 'BilledCost' | Should -Be -25 + } + } + + It 'Keeps billed reporting separate from amortized allocation and unit costs' { + InModuleScope FinOpsMultitool { + $subscriptionId = '66666666-6666-6666-6666-666666666666' + $targetResourceId = "/subscriptions/$subscriptionId/resourceGroups/test/providers/Microsoft.CognitiveServices/accounts/test" + $rows = @([pscustomobject]@{ + SubAccountId = $subscriptionId; SubAccountName = 'test'; BilledCost = 12000; EffectiveCost = 1000 + BillingCurrency = 'USD'; ResourceId = $targetResourceId; ResourceType = 'microsoft.cognitiveservices/accounts' + Tags = '{"CostCenter":"test"}'; ConsumedQuantity = 0 + ChargePeriodStart = '2026-08-31T00:00:00Z' + }) + Mock Resolve-VmAssociation { + $associated = [System.Collections.Generic.HashSet[string]]::new() + [void]$associated.Add($targetResourceId) + [pscustomobject]@{ Id = $targetResourceId; Name = 'test'; Associated = $associated; SubscriptionId = $subscriptionId } + } + + @((ConvertTo-ResourceCostsFromHub -HubData $rows))[0].Actual | Should -Be 12000 + (ConvertTo-CostByTagFromHub -HubData $rows).CostByTag.CostCenter[0].Cost | Should -Be 12000 + (Get-AllocationCostMaps -SubscriptionIds @($subscriptionId) -HubData $rows).BySub[$subscriptionId] | Should -Be 1000 + (Get-AllocationCostMaps -SubscriptionIds @($subscriptionId) -HubData $rows).Period | Should -Be '2026-08-31 to 2026-08-31' + (Get-VmCostBreakdown -VmName 'test' -HubData $rows).TotalCost | Should -Be 1000 + (Get-VmCostBreakdown -VmName 'test' -HubData $rows).Period | Should -Be '2026-08-31 to 2026-08-31' + (ConvertTo-AIHubAggregates -HubData $rows).AICost | Should -Be 1000 + (ConvertTo-AIHubAggregates -HubData $rows).Period | Should -Be '2026-08-31 to 2026-08-31' + { Resolve-HubCostColumn -Props @('BilledCost') -CostBasis 'AmortizedCost' } | Should -Throw '*AmortizedCost*' + { Resolve-HubCostColumn -Props @('EffectiveCost') -CostBasis 'ActualCost' } | Should -Throw '*ActualCost*' + } + } + + It 'Rejects inconsistent currency instead of returning a labeled cost total ()' -ForEach @( + @{ Currency = 'EUR' } + @{ Currency = '' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Currency = $Currency } { + param($Currency) + $rows = @( + [pscustomobject]@{ SubAccountId = '66666666-6666-6666-6666-666666666666'; BilledCost = 10; BillingCurrency = 'USD' } + [pscustomobject]@{ SubAccountId = '66666666-6666-6666-6666-666666666666'; BilledCost = 20; BillingCurrency = $Currency } + ) + { ConvertTo-CostDataFromHub -HubData $rows } | Should -Throw '*currenc*' + { ConvertTo-CostByTagFromHub -HubData $rows -ExistingTags @{ CostCenter = @{} } } | Should -Throw '*currenc*' + } + } + + It 'Rejects resource column drift even when the cost column is unchanged' { + InModuleScope FinOpsMultitool { + $rows = @( + [pscustomobject]@{ BilledCost = 10; BillingCurrency = 'USD'; ResourceId = 'resource-a' } + [pscustomobject]@{ BilledCost = 20; BillingCurrency = 'USD'; x_ResourceId = 'resource-b' } + ) + { ConvertTo-ResourceCostsFromHub -HubData $rows } | Should -Throw '*schemas differ*' + } + } + + It 'Discovers all hub tag keys and keeps case-distinct values' { + InModuleScope FinOpsMultitool { + $rows = @( + [pscustomobject]@{ BilledCost = 10; BillingCurrency = 'USD'; Tags = '{"env":"Prod"}' } + [pscustomobject]@{ BilledCost = 20; BillingCurrency = 'USD'; Tags = '{"env":"prod","Project":"test"}' } + [pscustomobject]@{ BilledCost = -5; BillingCurrency = 'USD'; Tags = '' } + ) + + $result = ConvertTo-CostByTagFromHub -HubData $rows + + $result.TagsQueried | Should -Contain 'Project' + ($result.CostByTag.env | Where-Object { $_.TagValue -ceq 'Prod' }).Cost | Should -Be 10 + ($result.CostByTag.env | Where-Object { $_.TagValue -ceq 'prod' }).Cost | Should -Be 20 + ($result.CostByTag.Project | Measure-Object Cost -Sum).Sum | Should -Be 25 + ($result.CostByTag.env | Measure-Object Cost -Sum).Sum | Should -Be 25 + } + } + } + + Context 'Amortized query currency' { + It 'Refuses missing or mixed live currencies ()' -ForEach @( + @{ Case = 'blank'; Currency = '' } + @{ Case = 'mixed'; Currency = 'EUR' } + @{ Case = 'missing column'; Currency = $null } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Currency = $Currency } { + param($Currency) + $targetId = '/subscriptions/44444444-4444-4444-4444-444444444444/resourceGroups/test/providers/Microsoft.Compute/virtualMachines/test' + $columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'Currency' }) + $rows = @(@(100.0, $targetId, 'USD'), @(100.0, $targetId, $Currency)) + if ($null -eq $Currency) { + $columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }) + $rows = @(, @(100.0, $targetId)) + } + $responseContent = @{ properties = @{ columns = $columns; rows = $rows } } | ConvertTo-Json -Depth 10 + Mock Invoke-AzRestMethodWithRetry { [pscustomobject]@{ StatusCode = 200; Content = $responseContent } } + Mock Resolve-VmAssociation { + $associated = [System.Collections.Generic.HashSet[string]]::new() + [void]$associated.Add($targetId) + [pscustomobject]@{ Id = $targetId; Name = 'test'; SubscriptionId = '44444444-4444-4444-4444-444444444444'; ResourceGroup = 'test'; Associated = $associated } + } + + { Get-AllocationCostMaps -SubscriptionIds @('44444444-4444-4444-4444-444444444444') } | Should -Throw '*currency*incomplete*' + $vm = Get-VmCostBreakdown -VmName 'test' + $vm.HasData | Should -BeFalse + $vm.TotalCost | Should -BeNullOrEmpty + $vm.Note | Should -BeLike '*currency*incomplete*' + } + } + } + + Context 'Hub storage completeness' { + It 'Propagates a Parquet parsing failure instead of returning an empty dataset' { + $missingFile = Join-Path $TestDrive 'unreadable.parquet' + { Read-ParquetFile -Path $missingFile } | Should -Throw '*Parquet*incomplete*' + } + + It 'Only attaches a forecast to current-month hub actuals ()' -ForEach @( + @{ Period = 'Current'; ChargeDate = '2026-09-16T00:00:00Z' } + @{ Period = 'Mixed'; ChargeDate = '2026-09-16T00:00:00Z' } + @{ Period = 'Stale'; ChargeDate = '2026-08-31T00:00:00Z' } + @{ Period = 'Unknown'; ChargeDate = $null } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Period = $Period; ChargeDate = $ChargeDate; ModuleRoot = $script:ModuleRoot } { + param($Period, $ChargeDate, $ModuleRoot) + $fixtureDate = $ChargeDate + $mixedPeriods = $Period -eq 'Mixed' + $scriptAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + foreach ($definition in $scriptAst.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -in @('Invoke-SelectedScans', 'Write-SectionHeader') }, $true)) { + . ([scriptblock]::Create($definition.Extent.Text)) + } + Mock Get-Date { [datetime]::new(2026, 9, 17, 12, 0, 0, [DateTimeKind]::Utc) } + Mock Resolve-FOHubProvider { @{ Found = $false } } + Mock Read-FinOpsHubData { + [pscustomobject]@{ BilledCost = 310; BillingCurrency = 'USD'; SubAccountId = '44444444-4444-4444-4444-444444444444'; ChargePeriodStart = $fixtureDate; Tags = '' } + if ($mixedPeriods) { + [pscustomobject]@{ BilledCost = 50; BillingCurrency = 'USD'; SubAccountId = '55555555-5555-5555-5555-555555555555'; ChargePeriodStart = '2026-08-31T00:00:00Z'; Tags = '' } + } + } + Mock ConvertTo-TagInventoryFromHub { [pscustomobject]@{ TagCount = 0; TagCoverage = 0 } } + Mock Invoke-AzRestMethodWithRetry { [pscustomobject]@{ StatusCode = 403; Content = '{}' } } + Mock Get-CostData { @{ '44444444-4444-4444-4444-444444444444' = @{ Actual = 100; Forecast = 180; ForecastSource = 'Forecast'; Currency = 'USD' } } } + $modules = @([pscustomobject]@{ Name = 'Costs'; Fn = 'Get-CostData'; Selected = $true }) + $subscriptions = @([pscustomobject]@{ Id = '44444444-4444-4444-4444-444444444444'; Name = 'test' }) + if ($mixedPeriods) { $subscriptions += [pscustomobject]@{ Id = '55555555-5555-5555-5555-555555555555'; Name = 'older' } } + + $result = Invoke-SelectedScans -Modules $modules -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -DataSource @{ Source = 'Hub'; HubStorage = @{ name = 'test'; resourceGroup = 'test' } } + + $entry = $result['Get-CostData'][$subscriptions[0].Id] + $entry.Actual | Should -Be 310 + if ($Period -in @('Current', 'Mixed')) { + $entry.Forecast | Should -Be 180 + $entry.ActualPeriod | Should -Match '2026-09' + Should -Invoke Get-CostData -Times 1 -Exactly + Should -Invoke Get-CostData -Times 1 -Exactly -ParameterFilter { + $Subscriptions.Count -eq 1 -and $Subscriptions[0].Id -eq '44444444-4444-4444-4444-444444444444' + } + if ($mixedPeriods) { + $older = $result['Get-CostData']['55555555-5555-5555-5555-555555555555'] + $older.ActualPeriod | Should -Be '2026-08-31 to 2026-08-31' + $older.Forecast | Should -BeNullOrEmpty + } + } + else { + $entry.Forecast | Should -BeNullOrEmpty + $entry.ActualPeriod | Should -Be $(if ($Period -eq 'Stale') { '2026-08-31 to 2026-08-31' } else { 'Unknown' }) + Should -Invoke Get-CostData -Times 0 -Exactly + } + } + } + + It 'Does not return partial hub data after a file fails' -ForEach @( + @{ Source = 'Parquet' } + @{ Source = 'CSV' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Source = $Source } { + param($Source) + $useCsv = $Source -eq 'CSV' + Mock New-AzStorageContext { $null } + Mock Install-ParquetReader { $true } + Mock Get-AzDataLakeGen2ChildItem { + if ($FileSystem -eq 'ingestion' -and $useCsv) { return @() } + if ($FileSystem -eq 'msexports' -and -not $useCsv) { throw 'Unexpected CSV fallback.' } + $extension = if ($useCsv) { 'csv' } else { 'parquet' } + @( + [pscustomobject]@{ Name = "part1.$extension"; Path = "export/20260901-20260930/202609170001/run/part1.$extension"; IsDirectory = $false } + [pscustomobject]@{ Name = "part2.$extension"; Path = "export/20260901-20260930/202609170001/run/part2.$extension"; IsDirectory = $false } + ) + } + Mock Get-AzDataLakeGen2ItemContent { if ($Path -like '*part2*') { throw 'Simulated download failure.' } } + Mock Read-ParquetFile { [pscustomobject]@{ BilledCost = 100; BillingCurrency = 'USD'; SubAccountId = '44444444-4444-4444-4444-444444444444' } } + Mock Import-Csv { [pscustomobject]@{ BilledCost = 100; BillingCurrency = 'USD'; SubAccountId = '44444444-4444-4444-4444-444444444444' } } + + { Read-FinOpsHubData -StorageAccountName 'test' -ResourceGroupName 'test' -SubscriptionIds @('44444444-4444-4444-4444-444444444444') } | + Should -Throw '*incomplete*' + } + } + + It 'Does not accept missing selected subscriptions as complete hub coverage' { + InModuleScope FinOpsMultitool { + Mock New-AzStorageContext { $null } + Mock Install-ParquetReader { $true } + Mock Get-AzDataLakeGen2ChildItem { [pscustomobject]@{ Name = 'one.parquet'; Path = 'one.parquet'; IsDirectory = $false } } + Mock Get-AzDataLakeGen2ItemContent { } + Mock Read-ParquetFile { [pscustomobject]@{ BilledCost = 100; BillingCurrency = 'USD'; SubAccountId = '44444444-4444-4444-4444-444444444444' } } + + { Read-FinOpsHubData -StorageAccountName 'test' -ResourceGroupName 'test' -SubscriptionIds @('44444444-4444-4444-4444-444444444444', '55555555-5555-5555-5555-555555555555') } | + Should -Throw '*coverage*' + } + } + + It 'Keeps a hub failure visible to the scan runner without an API fallback' -ForEach @( + @{ Source = 'Storage' } + @{ Source = 'Kusto' } + @{ Source = 'KustoAI' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Source = $Source; ModuleRoot = $script:ModuleRoot } { + param($Source, $ModuleRoot) + $sourceName = $Source + $scriptAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $runner = $scriptAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Invoke-SelectedScans' }, $true) + . ([scriptblock]::Create($runner.Extent.Text)) + $sectionHeader = $scriptAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Write-SectionHeader' }, $true) + . ([scriptblock]::Create($sectionHeader.Extent.Text)) + Mock Resolve-FOHubProvider { @{ Found = ($sourceName -in @('Kusto', 'KustoAI')); Mode = 'Kusto' } } + Mock Read-FinOpsHubData { throw 'Hub coverage incomplete.' } + Mock Get-FOHubCostSummary { @{ Error = 'Hub coverage incomplete.' } } + Mock Get-FOHubResourceCosts { @{ Error = 'Hub coverage incomplete.' } } + Mock Get-FOHubCostByTag { @{ Error = 'Hub coverage incomplete.' } } + Mock Get-CostData { @{ unexpected = @{ Actual = 100; Currency = 'USD' } } } + Mock Get-AIWorkloadMetrics { [pscustomobject]@{ HasData = $true; TotalAICost = 100; Source = 'API' } } + $scanName = if ($Source -eq 'KustoAI') { 'Get-AIWorkloadMetrics' } else { 'Get-CostData' } + $modules = @([pscustomobject]@{ Name = 'Costs'; Fn = $scanName; Selected = $true }) + $subscriptions = @([pscustomobject]@{ Id = '44444444-4444-4444-4444-444444444444'; Name = 'test' }) + $dataSource = @{ Source = 'Hub'; HubStorage = @{ name = 'test'; resourceGroup = 'test' } } + + $result = Invoke-SelectedScans -Modules $modules -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -DataSource $dataSource + + $expectedError = if ($Source -eq 'KustoAI') { '*unavailable*selected Kusto hub source*' } else { '*coverage incomplete*' } + $result["_error_$scanName"] | Should -BeLike $expectedError + @($result[$scanName]).Count | Should -Be 0 + Should -Invoke Get-CostData -Times 0 -Exactly + Should -Invoke Get-AIWorkloadMetrics -Times 0 -Exactly + } + } + + It 'Keeps a measured zero AI hub result on the selected source' { + InModuleScope FinOpsMultitool { + Mock Search-AzGraphSafe { + @{ Data = @([pscustomobject]@{ type = 'microsoft.cognitiveservices/accounts'; lkind = 'OpenAI'; id = '/subscriptions/test/providers/Microsoft.CognitiveServices/accounts/ai'; name = 'ai' }) } + } + Mock Invoke-AzRestMethodWithRetry { throw 'A hub result must not call the live cost API.' } + Mock Get-PlainAccessToken { throw 'A hub result must not call live metrics.' } + $hubRows = @([pscustomobject]@{ EffectiveCost = 0; BillingCurrency = 'USD'; ResourceId = '/subscriptions/test/providers/Microsoft.CognitiveServices/accounts/ai'; ResourceType = 'microsoft.cognitiveservices/accounts'; ChargePeriodStart = '2026-08-31'; ConsumedQuantity = 0 }) + + $result = Get-AIWorkloadMetrics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -HubData $hubRows + + $result.Source | Should -Be 'FinOpsHub' + $result.TotalAICost | Should -Be 0 + $result.Period | Should -Be '2026-08-31 to 2026-08-31' + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly + Should -Invoke Get-PlainAccessToken -Times 0 -Exactly + } + } } Context 'Commitment utilization' { + # Get-CommitmentUtilization seeds both averages to 0 and only fills the ones + # it found. Treating that 0 as a measurement halves the score for anyone who + # owns reservations but no savings plans, and reports 100% waste when the + # utilization read was denied. It 'Ignores a family that has no commitments' { InModuleScope FinOpsMultitool { $data = [pscustomobject]@{ RICount = 10; RIAvgUtilization = 100; SPCount = 0; SPAvgUtilization = 0 } @@ -398,4 +1017,246 @@ Describe 'FinOps Multitool cost math' { } } } + + Context 'Budget KPI completeness' { + It 'Does not label an unavailable budget KPI as computed' { + InModuleScope FinOpsMultitool { + Mock Get-KpiCatalog { + @{ kpis = @([pscustomobject]@{ id = 'variance-budget-vs-actual'; sourceTool = 'scan_budget_status'; compute = $true }) } + } + $result = Add-KpiInsights -Result @{ tool = 'scan_budget_status'; data = @{ CoverageIncomplete = $true; Budgets = @() } } + + $result.kpiInsights[0].status | Should -Be 'unavailable' + $result.kpiInsights[0].numericValue | Should -BeNullOrEmpty + $result.kpiInsights[0].yourValue | Should -BeLike '*Unavailable*' + } + } + + It 'Leaves combined KPIs unscored for ' -ForEach @( + @{ Case = 'unknown spend'; Change = 'Unknown' } + @{ Case = 'mixed currencies'; Change = 'Currency' } + @{ Case = 'different periods'; Change = 'Period' } + @{ Case = 'overlapping scopes'; Change = 'Scope' } + @{ Case = 'incomplete inventory'; Change = 'Coverage' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Change = $Change } { + param($Change) + $first = [pscustomobject]@{ Amount = 1000; ActualSpend = 500; PctUsed = 50; Currency = 'EUR'; TimeGrain = 'Monthly'; Category = 'Cost'; SubscriptionId = 'one'; SpendSource = 'Budget' } + $second = [pscustomobject]@{ Amount = 9000; ActualSpend = 4500; PctUsed = 50; Currency = 'EUR'; TimeGrain = 'Monthly'; Category = 'Cost'; SubscriptionId = 'two'; SpendSource = 'Budget' } + $first | Add-Member -NotePropertyName TimePeriod -NotePropertyValue @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-1) } + $second | Add-Member -NotePropertyName TimePeriod -NotePropertyValue @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-1) } + switch ($Change) { + 'Unknown' { $second.ActualSpend = $null; $second.PctUsed = $null; $second.SpendSource = 'Unavailable' } + 'Currency' { $second.Currency = 'USD' } + 'Period' { $second.TimeGrain = 'Annually' } + 'Scope' { $second.SubscriptionId = 'one' } + } + $data = [pscustomobject]@{ Budgets = @($first, $second); CoverageIncomplete = ($Change -eq 'Coverage') } + + $variance = Get-KpiComputedValue -KpiId 'variance-budget-vs-actual' -Data $data + $burn = Get-KpiComputedValue -KpiId 'budget-burn-rate' -Data $data + + $variance.Value | Should -BeNullOrEmpty + $burn.Value | Should -BeNullOrEmpty + $variance.Display | Should -Match 'Unavailable' + } + } + + It 'Reports comparable known budgets using their currency' { + InModuleScope FinOpsMultitool { + $data = [pscustomobject]@{ Budgets = @( + [pscustomobject]@{ Amount = 1000; ActualSpend = 500; Currency = 'EUR'; TimeGrain = 'Monthly'; Category = 'Cost'; SubscriptionId = 'one'; TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-1) } } + [pscustomobject]@{ Amount = 1000; ActualSpend = 1500; Currency = 'EUR'; TimeGrain = 'Monthly'; Category = 'Cost'; SubscriptionId = 'two'; TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-1) } } + ) } + $variance = Get-KpiComputedValue -KpiId 'variance-budget-vs-actual' -Data $data + $burn = Get-KpiComputedValue -KpiId 'budget-burn-rate' -Data $data + + $variance.Value | Should -Be 0 + $variance.Display | Should -Match 'EUR' + $variance.Display | Should -Not -Match 'USD' + $burn.Value | Should -Be 100 + } + } + } + + Context 'Observed-period reporting' { + It 'Uses the AI result period in terminal, HTML, guidance, and KPI text' { + InModuleScope FinOpsMultitool -Parameters @{ ModuleRoot = $script:ModuleRoot } { + param($ModuleRoot) + $scriptAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $formatter = $scriptAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Write-ColorizedLine' }, $true) + if ($formatter) { . ([scriptblock]::Create($formatter.Extent.Text)) } + $switches = $scriptAst.FindAll({ $args[0] -is [System.Management.Automation.Language.SwitchStatementAst] }, $true) + $branches = @($switches.Clauses | Where-Object { $_.Item1.Value -eq 'Get-AIWorkloadMetrics' -and $_.Item2.Extent.Text.Contains('$data.HasData') }) + $branches.Count | Should -Be 3 + $captured = [System.Collections.Generic.List[string]]::new() + Mock Write-ColorizedLine { [void]$captured.Add($Text) } + $data = [pscustomobject]@{ + HasData = $true; Period = '2026-08-01 to 2026-08-31'; Currency = 'EUR'; TotalTokens = 1000; TotalAICost = 25; CostPer1KTokens = 25 + TotalPromptTokens = 800; TotalGeneratedTokens = 200; TotalRequests = 0; CostPerRequest = $null + AIFootprint = @{ OpenAIAccounts = 1; AIServices = 0; MLWorkspaces = 0; SearchServices = 0; GpuVmCount = 0 } + } + $htmlSb = [System.Text.StringBuilder]::new() + $guidanceItems = @() + foreach ($branch in $branches) { + $body = ($branch.Item2.Statements | ForEach-Object { $_.Extent.Text }) -join "`n" + . ([scriptblock]::Create("param(`$data, `$htmlSb)`n$body")) $data $htmlSb + } + + ($captured -join ' ') | Should -Match '2026-08-01 to 2026-08-31' + ($captured -join ' ') | Should -Not -Match 'MTD' + $htmlSb.ToString() | Should -Match '2026-08-01 to 2026-08-31' + $htmlSb.ToString() | Should -Not -Match 'MTD' + ($guidanceItems.Message -join ' ') | Should -Match '2026-08-01 to 2026-08-31' + $kpi = Get-KpiComputedValue -KpiId 'token-consumption-metrics' -Data $data + $kpi.Display | Should -Match '2026-08-01 to 2026-08-31' + $kpi.Display | Should -Not -Match 'MTD' + } + } + } + + Context 'Budget reporting' { + + It 'Does not call budgets healthy when forecasts are unavailable' { + $scriptAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $switches = $scriptAst.FindAll({ $args[0] -is [System.Management.Automation.Language.SwitchStatementAst] }, $true) + $branch = @($switches.Clauses | Where-Object { $_.Item1.Value -eq 'Get-BudgetStatus' -and $_.Item2.Extent.Text.Contains('$bCoverage') }) + $branch.Count | Should -Be 1 + $data = [pscustomobject]@{ + AtRiskCount = 0; OverBudgetCount = 0; BudgetCoverage = 100; CoverageIncomplete = $false + Budgets = @([pscustomobject]@{ Amount = 1000; ActualSpend = 500; Forecast = $null; Risk = 'Forecast unavailable' }) + } + $guidanceItems = @() + $body = ($branch[0].Item2.Statements | ForEach-Object { $_.Extent.Text }) -join "`n" + + . ([scriptblock]::Create("param(`$data)`n$body")) $data + + $guidanceItems.Count | Should -BeGreaterThan 0 + @($guidanceItems | Where-Object Severity -EQ 'Green').Count | Should -Be 0 + $guidanceItems[0].Message | Should -Match 'unavailable' + } + + It 'Displays unknown amounts explicitly while preserving known currency and zero' { + InModuleScope FinOpsMultitool { + Format-BudgetAmount -Value $null -Currency 'USD' | Should -Be 'Unavailable' + Format-BudgetAmount -Value 500 -Currency '' | Should -Be 'Unavailable' + Format-BudgetAmount -Value 'NaN' -Currency 'USD' | Should -Be 'Unavailable' + Format-BudgetAmount -Value 0 -Currency 'EUR' | Should -Match '^EUR 0[.,]00$' + Format-BudgetAmount -Value 500 -Currency 'EUR' | Should -Match '^EUR ' + } + } + + It 'Keeps an unavailable forecast separate from known current spend' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { + [pscustomobject]@{ StatusCode = 200; Content = '{"value":[{"name":"monthly","properties":{"amount":1000,"timeGrain":"Monthly","category":"Cost","currentSpend":{"amount":500,"unit":"EUR"}}}]}' } + } + $subscriptions = @([pscustomobject]@{ Id = '88888888-8888-8888-8888-888888888888'; Name = 'test' }) + + $budget = (Get-BudgetStatus -Subscriptions $subscriptions).Budgets[0] + + $budget.ActualSpend | Should -Be 500 + $budget.PctUsed | Should -Be 50 + $budget.Forecast | Should -BeNullOrEmpty + $budget.PctForecast | Should -BeNullOrEmpty + $budget.ForecastSource | Should -Be 'Unavailable' + $budget.Risk | Should -Not -Be 'On Track' + $budget.Currency | Should -Be 'EUR' + } + } + + It 'Does not turn an invalid budget denominator into an on-track budget ()' -ForEach @( + @{ Case = 'missing'; Amount = $null } + @{ Case = 'zero'; Amount = 0 } + @{ Case = 'negative'; Amount = -1 } + @{ Case = 'NaN'; Amount = 'NaN' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Amount = $Amount } { + param($Amount) + $properties = @{ amount = $Amount; timeGrain = 'Monthly'; category = 'Cost'; currentSpend = @{ amount = 500; unit = 'USD' } } + $content = @{ value = @(@{ name = 'test'; properties = $properties }) } | ConvertTo-Json -Depth 8 + Mock Invoke-AzRestMethodWithRetry { [pscustomobject]@{ StatusCode = 200; Content = $content } } + $budget = (Get-BudgetStatus -Subscriptions @([pscustomobject]@{ Id = '88888888-8888-8888-8888-888888888888' })).Budgets[0] + + $budget.Amount | Should -BeNullOrEmpty + $budget.PctUsed | Should -BeNullOrEmpty + $budget.Risk | Should -Be 'Unknown' + $budget.Note | Should -BeLike '*amount*' + } + } + + It 'Does not compare a forecast denominated in another unit to the budget' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { + [pscustomobject]@{ StatusCode = 200; Content = '{"value":[{"name":"monthly","properties":{"amount":1000,"timeGrain":"Monthly","category":"Cost","currentSpend":{"amount":500,"unit":"USD"},"forecastSpend":{"amount":1500,"unit":"EUR"}}}]}' } + } + $budget = (Get-BudgetStatus -Subscriptions @([pscustomobject]@{ Id = '88888888-8888-8888-8888-888888888888' })).Budgets[0] + + $budget.Currency | Should -Be 'USD' + $budget.Forecast | Should -BeNullOrEmpty + $budget.Risk | Should -Not -Be 'Forecast Over' + $budget.Note | Should -BeLike '*unit*' + } + } + + It 'Preserves the budget filter and leaves unknown spend null' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { + [pscustomobject]@{ StatusCode = 200; Content = '{"value":[{"name":"filtered","properties":{"amount":1000,"timeGrain":"Monthly","category":"Cost","filter":{"tags":{"name":"CostCenter","operator":"In","values":["team"]}},"timePeriod":{"startDate":"2026-01-01T00:00:00Z","endDate":"2026-12-31T00:00:00Z"}}}]}' } + } + $budget = (Get-BudgetStatus -Subscriptions @([pscustomobject]@{ Id = '88888888-8888-8888-8888-888888888888' })).Budgets[0] + + $budget.ActualSpend | Should -BeNullOrEmpty + $budget.Forecast | Should -BeNullOrEmpty + $budget.Filter.tags.name | Should -Be 'CostCenter' + $budget.TimePeriod.startDate | Should -Not -BeNullOrEmpty + $budget.Currency | Should -BeNullOrEmpty + } + } + + # A budget whose spend could not be read must not average into burn-rate KPIs + # as though it were untouched. + It 'Leaves percentages null when spend is unknown' { + InModuleScope FinOpsMultitool { + $budget = [pscustomobject]@{ + name = 'quarterly-filtered' + properties = [pscustomobject]@{ + amount = 5000; timeGrain = 'Quarterly'; category = 'Cost' + filter = [pscustomobject]@{ tags = @{} } + } + } + Mock Invoke-AzRestMethodWithRetry { + [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @($budget) } | ConvertTo-Json -Depth 10) } + } + + $subs = @([pscustomobject]@{ Id = '88888888-8888-8888-8888-888888888888'; Name = 'test' }) + $costData = @{ '88888888-8888-8888-8888-888888888888' = @{ Actual = 900; Forecast = 1000; Currency = 'USD' } } + + $b = @((Get-BudgetStatus -Subscriptions $subs -CostData $costData).Budgets)[0] + $b.SpendSource | Should -Be 'Unavailable' + $b.Risk | Should -Be 'Unknown' + $b.PctUsed | Should -BeNullOrEmpty + } + } + + # currentSpend carries its own unit; the subscription's currency may differ. + It 'Reports the currency that belongs to the budget amount' { + InModuleScope FinOpsMultitool { + $budget = [pscustomobject]@{ + name = 'eur-budget' + properties = [pscustomobject]@{ + amount = 1000; timeGrain = 'Monthly'; category = 'Cost' + currentSpend = [pscustomobject]@{ amount = 500; unit = 'EUR' } + } + } + Mock Invoke-AzRestMethodWithRetry { + [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @($budget) } | ConvertTo-Json -Depth 10) } + } + + $subs = @([pscustomobject]@{ Id = '99999999-9999-9999-9999-999999999999'; Name = 'test' }) + $b = @((Get-BudgetStatus -Subscriptions $subs -CostData @{}).Budgets)[0] + $b.Currency | Should -Be 'EUR' + } + } + } } From 791de7df81ca1572a8d78c349b1724951e89457c Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Fri, 18 Sep 2026 12:04:30 -0600 Subject: [PATCH 134/142] fix(multitool): FinOps Multitool Update - Sep 17-18 Review findings - Respect explicit data-source selection and retain Kusto provenance. - Validate savings currencies and exclude non-usage charges. - Separate month-to-date commitment estimates from the AHB estimate. - Export nested CSV summaries once and use invariant amounts and ISO dates. - Align public help and documentation with the Microsoft style guide. - Add source, savings, export, and pagination regression coverage. Validation: 2812 unit tests passed (7 skipped), 7144 lint checks passed, and no ScriptAnalyzer findings. Two independent reviews approved the batch. Successful online Hub validation remains blocked by a 403 response. --- .../multitool/finops-multitool-commands.md | 18 +- .../multitool/start-finopsmultitool.md | 22 +- .../Invoke-FinOpsMultitool.ps1 | 286 ++++++--- .../Private/FinOpsMultitool/README.md | 125 ++-- .../FinOpsMultitool/kpi/kpi-catalog.json | 10 +- .../modules/Get-SavingsRealized.ps1 | 181 +++--- .../modules/helpers/Get-KpiInsights.ps1 | 32 +- .../Public/Start-FinOpsMultitool.ps1 | 10 +- .../Tests/Unit/CostQueryPagination.Tests.ps1 | 6 +- .../Tests/Unit/MultitoolSafety.Tests.ps1 | 579 ++++++++++++++++++ .../Unit/Start-FinOpsMultitool.Tests.ps1 | 263 ++++++++ 11 files changed, 1266 insertions(+), 266 deletions(-) diff --git a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md index 68402cc1a..5b57dfbfe 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: 09/16/2026 +ms.date: 09/18/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -37,17 +37,21 @@ The multitool includes 30 scan modules across the following categories: - **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, 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. +- **Commitments** – Reservation advice, commitment utilization, and estimated savings. - **Monitoring** – Budget status, budget history, and anomaly alerts. - **Advisor** – Azure Advisor cost recommendations. - **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. Most need Reader or Cost Management Reader access. Account scans also need Billing Reader, or Enterprise Administrator (reader) on an Enterprise Agreement. Commitment utilization reads reservation and savings plan usage at billing account or billing profile scope, so it needs that same billing access. The carbon scan needs Reader or Carbon Optimization Reader assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. +Analysis scans are read-only. Most need Reader or Cost Management Reader access. Account scans also need agreement-specific billing access, such as Billing account reader or Billing profile reader for a Microsoft Customer Agreement, or Enterprise Administrator (read only) for an Enterprise Agreement. Commitment utilization reads reservation and savings plan usage at billing account or billing profile scope, so it needs that billing access. The carbon scan needs Reader or Carbon Optimization Reader assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. When a scan can't read every subscription you selected, it tells you instead of treating the gap as a result. Budget status reports coverage as unverified rather than a percentage. +The scan keeps the **Savings Realized** menu name for compatibility. It estimates savings using assumed discounts. It doesn't measure realized savings or calculate a savings percentage. Results include `IsEstimate` and `EstimateBasis`. Compare the estimates with matching pay-as-you-go rates and benefit usage before reporting realized savings. + +Commitment estimates cover usage charges in the reported UTC month-to-date period and retain the billing currency. Purchases, refunds, and unused commitment charges are excluded. Unknown, nonmonetary, or mixed currencies and negative usage adjustments stop the estimate. Azure Hybrid Benefit uses a separate USD estimate for 730 hours on the current VM inventory. The scan doesn't combine or annualize these amounts. For scripts, use `RISavingsMonthToDate`, `SPSavingsMonthToDate`, and `CommitmentSavingsMonthToDate` with `Currency` and `Period`. The old monthly commitment fields and combined monthly and annual totals remain empty. +
## FinOps hub data paths @@ -55,9 +59,13 @@ When a scan can't read every subscription you selected, it tells you instead of When a [FinOps hub](../../hubs/finops-hubs-overview.md) is present, cost scans read from the hub and choose the path automatically: - **Kusto database (used when available)** – When the hub has an Azure Data Explorer or Microsoft Fabric cluster, the multitool discovers it through Azure Resource Graph and pushes aggregation into the engine, returning only summarized results. This scales to large datasets without loading raw cost rows into PowerShell. To query a local hub on your own hardware, set the `FINOPS_HUB_KUSTO_URI` environment variable to a local Kusto endpoint (optionally set `FINOPS_HUB_KUSTO_DB`, which defaults to `Hub`). -- **Storage reader (small-dataset fallback)** – When no Kusto cluster is reachable, the multitool reads the hub's storage export and aggregates in PowerShell. Use this for smaller datasets. Reading Parquet exports installs a reader the first time you read one, using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. If neither is available, the multitool reads the CSV exports instead and tells you why. +- **Storage reader (small-dataset fallback)**: when no Kusto endpoint is configured or discovered, the multitool reads the hub's storage export and aggregates in PowerShell. Use this for smaller datasets. Reading Parquet exports installs a reader the first time you read one, using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. The tool reports an unreadable export as an error instead of treating it as zero cost. + +Storage reads require Storage Blob Data Reader or equivalent data access. Kusto queries require database query access. Both paths need network access to the endpoint. Local Kusto queries are anonymous, but the public launcher still uses Azure context and resource metadata. + +An explicit `-DataSource API` or `-DataSource GraphOnly` takes precedence over `FINOPS_HUB_KUSTO_URI` and doesn't preload hub data. A configured Kusto URI can select a hub without a discovered storage account. An explicit `-DataSource Hub` reports an error if no configured endpoint or hub storage is available. -If no hub is available, cost scans use the live Cost Management API. +When no hub is available, the tool offers the Cost Management API. Once you select **FinOps Hub**, the tool reports any read or query failure as an error. Select **Cost Management API** to run a separate live scan. Kusto-only hubs don't currently support the AI workload scan. When forecasts are available for current-month storage data, the tool shows them as separate full-month API totals. It doesn't add forecasts to hub actuals.
diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md index 29a2a4ae0..0df418d6e 100644 --- a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -3,7 +3,7 @@ title: Start-FinOpsMultitool command 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: 09/16/2026 +ms.date: 09/18/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -11,6 +11,8 @@ ms.reviewer: micflan #customer intent: As a FinOps user, I want to understand how to use the Start-FinOpsMultitool command in the FinOpsToolkit module. --- + + # 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. Scans cover cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance. @@ -39,13 +41,13 @@ Start-FinOpsMultitool ` ## Parameters -| Name | Description | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `‑SubscriptionId` | Optional. Scopes the scan to a single subscription. When omitted, all accessible subscriptions are discovered. If the subscription can't be resolved and nothing can answer a prompt, the command returns an error rather than scanning every subscription. | -| `‑OutputPath` | Optional. Directory for exported result files. Defaults to a `FinOpsResults` folder in your home directory. | -| `‑Scans` | Optional. Runs the specified scans instead of the default selection. Accepts a scan command name, such as `Get-OrphanedResources`, or its menu label, such as `Orphaned Resources`. Use `All` to select every scan. An unrecognized name returns an error. | -| `‑DataSource` | Optional. Sets the data source and skips the data source prompt. Valid values are `Hub`, `API`, and `GraphOnly`. `Hub` falls back to `API` when no FinOps hub is found in scope. | -| `‑NonInteractive` | Optional. Runs without prompting. Every choice comes from the parameters or their defaults, and results are exported only when you set `-OutputPath`. | +| Name | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `‑SubscriptionId` | Optional. Scopes the scan to a single subscription. When omitted, all accessible subscriptions are discovered. If the subscription can't be resolved and nothing can answer a prompt, the command returns an error rather than scanning every subscription. | +| `‑OutputPath` | Optional. Directory for exported result files. Defaults to a `FinOpsResults` folder in your home directory. | +| `‑Scans` | Optional. Runs the specified scans instead of the default selection. Accepts a scan command name, such as `Get-OrphanedResources`, or its menu label, such as `Orphaned Resources`. Use `All` to select every scan. An unrecognized name returns an error. | +| `‑DataSource` | Optional. Sets the data source and skips the data source prompt. Valid values are `Hub`, `API`, and `GraphOnly`. `API` and `GraphOnly` take precedence over `FINOPS_HUB_KUSTO_URI` and don't preload hub data. An explicit `Hub` selection fails if no configured Kusto endpoint or hub storage is available. Select `API` separately for a live scan. | +| `‑NonInteractive` | Optional. Runs without prompting. Every choice comes from the parameters or their defaults, and results are exported only when you set `-OutputPath`. |
@@ -102,9 +104,9 @@ Use `-NonInteractive` when nothing can answer a prompt, such as a build agent. ## 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 you select [FinOps Hub](../../hubs/finops-hubs-overview.md), the tool prefers the configured or discovered Kusto database. Kusto aggregates the data and returns summaries without loading raw cost records into PowerShell. To query a local hub, set `FINOPS_HUB_KUSTO_URI` to its endpoint. A configured endpoint doesn't require a discovered storage account. When no Kusto endpoint is configured or discovered, the tool reads hub storage exports, which is intended for smaller datasets. A failed query remains an error; it doesn't silently switch sources. For more information, see [FinOps multitool commands](finops-multitool-commands.md). -Reading Parquet exports installs a reader the first time you read one, using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. If neither is available, the multitool reads the CSV exports instead and tells you why. +Reading Parquet exports installs a reader the first time you read one, using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. An unreadable export is reported as an error instead of being treated as zero cost.
diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 8a75cb17e..63a7ea181 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -19,8 +19,8 @@ param() # Invoke-FinOpsMultitool -OutputPath './results' # # Requirements: -# - PowerShell 5.1+ (Windows) or 7+ (cross-platform) -# - Az PowerShell modules: Az.Accounts, Az.Resources, Az.ResourceGraph +# - PowerShell 7+ +# - Az PowerShell modules: Az.Accounts, Az.ResourceGraph, Az.Storage # - Azure RBAC: Reader + Cost Management Reader on target scope ########################################################################### @@ -157,8 +157,8 @@ function Invoke-FinOpsMultitool { 'Get-UnitEconomics' = @{ Role = 'Cost Management Reader + Reader'; Scope = 'Management Group'; API = 'Cost Management Query API + Azure Resource Graph + Azure Monitor metrics'; Reason = 'Requires amortized cost (Cost Management), capacity counts (Resource Graph), and storage-account used capacity (Monitor UsedCapacity metric) to compute $/vCPU, $/GB RAM and $/GB stored.' } 'Get-AIWorkloadMetrics' = @{ Role = 'Cost Management Reader + Reader'; Scope = 'Management Group'; API = 'Azure Resource Graph + Monitor Metrics + Cost Management Query API'; Reason = 'Requires Reader to detect AI resources and read Azure OpenAI token metrics, plus Cost Management Reader to map token usage to spend. Skips the deep scan when no AI workloads are present.' } 'Get-ReservationAdvice' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Reservation Recommendations API'; Reason = 'Requires Microsoft.Consumption/reservationRecommendations/read to retrieve reservation purchase advice.' } - 'Get-CommitmentUtilization' = @{ Role = 'Billing Reader, or Enterprise Administrator (reader) on an EA'; Scope = 'Billing account or billing profile'; API = 'Consumption Reservation Summaries + Cost Management Benefit Utilization APIs'; Reason = 'Reservation and savings plan utilization is published at billing scope only; a subscription-scoped read returns 404. Without billing access, the scan reports that no billing scope was resolved rather than reporting zero commitments.' } - 'Get-SavingsRealized' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Benefit Utilization API'; Reason = 'Requires Microsoft.CostManagement/benefitUtilizationSummaries/read. Returns empty if no active reservations or savings plans.' } + 'Get-CommitmentUtilization' = @{ Role = 'MCA Billing account reader or Billing profile reader, or EA Enterprise Administrator (read only)'; Scope = 'Billing account or billing profile'; API = 'Consumption Reservation Summaries + Cost Management Benefit Utilization APIs'; Reason = 'Reservation and savings plan utilization is published at billing scope only; a subscription-scoped read returns 404. Without billing access, the scan reports that no billing scope was resolved rather than reporting zero commitments.' } + 'Get-SavingsRealized' = @{ Role = 'Cost Management Reader + Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API + Azure Resource Graph'; Reason = 'Requires Microsoft.CostManagement/query/action for commitment spend and Reader access for Azure Hybrid Benefit inventory. Savings amounts use assumed discounts, not measured benefit utilization.' } 'Get-BudgetStatus' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Budgets API'; Reason = 'Requires Microsoft.Consumption/budgets/read. Returns empty if no budgets are configured for scanned subscriptions.' } 'Get-BudgetHistory' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to retrieve monthly actuals per budget. Runs only when Budget Status returns budgets.' } 'Get-AnomalyAlerts' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Alerts API'; Reason = 'Requires Microsoft.CostManagement/alerts/read. Returns empty if no cost anomalies were detected.' } @@ -289,6 +289,15 @@ function Invoke-FinOpsMultitool { Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray Write-Host "" + if ($Preselected -in @('API', 'GraphOnly')) { + Write-Host " Data source set by parameter: $Preselected" -ForegroundColor DarkGray + return @{ Source = $Preselected; HubStorage = $null } + } + if (-not [string]::IsNullOrWhiteSpace($env:FINOPS_HUB_KUSTO_URI)) { + $provider = Resolve-FOHubProvider -Subscriptions @($Subscriptions.Id) + return @{ Source = 'Hub'; HubStorage = $null; HubProvider = $provider } + } + # Try to detect a FinOps Hub in the selected subscriptions $hubStorage = $null Write-Host " Checking for FinOps Hub deployment..." -ForegroundColor DarkGray @@ -308,8 +317,7 @@ function Invoke-FinOpsMultitool { if ($Preselected) { if ($Preselected -eq 'Hub' -and -not $hubStorage) { - Write-Host " No FinOps Hub found in scope. Using the Cost Management API instead." -ForegroundColor Yellow - return @{ Source = 'API'; HubStorage = $null } + throw 'No FinOps hub was found in the selected subscriptions. Configure FINOPS_HUB_KUSTO_URI or select API for a separate live scan.' } Write-Host " Data source set by parameter: $Preselected" -ForegroundColor DarkGray return @{ Source = $Preselected; HubStorage = $hubStorage } @@ -355,7 +363,7 @@ function Invoke-FinOpsMultitool { } if ($prov -and $prov.Found) { # A scalable Kusto path exists - no warning needed. - return @{ Source = 'Hub'; HubStorage = $hubStorage } + return @{ Source = 'Hub'; HubStorage = $hubStorage; HubProvider = $prov } } # Size the hub before judging the reader. An unmeasurable hub @@ -849,10 +857,11 @@ function Invoke-FinOpsMultitool { [array]$Modules, [array]$Subscriptions, [string]$TenantId, - [hashtable]$DataSource + [hashtable]$DataSource, + [hashtable]$PermissionInfo = @{} ) - $selected = $Modules | Where-Object { $_.Selected } + $selected = @($Modules | Where-Object { $_.Selected }) $results = @{} $total = $selected.Count $current = 0 @@ -873,9 +882,27 @@ function Invoke-FinOpsMultitool { # 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) { - $kp = Resolve-FOHubProvider -Subscriptions $subIdsForDisco - if ($kp -and $kp.Found) { $kustoProvider = $kp } + if ($DataSource.Source -eq 'Hub') { + $kp = if ($DataSource.HubProvider) { $DataSource.HubProvider } else { Resolve-FOHubProvider -Subscriptions $subIdsForDisco } + if ($kp -and $kp.Found) { + $kustoProvider = $kp + $DataSource.HubProvider = $kp + } + } + + if ($DataSource.Source -eq 'Hub') { + $hubPermission = if ($kustoProvider -and $kustoProvider.Mode -eq 'KustoLocal') { + @{ Role = 'None (local emulator)'; Scope = 'Local Kusto endpoint'; API = 'Kusto query API'; Reason = 'Check that the local emulator is running and the configured database is available.' } + } + elseif ($kustoProvider) { + @{ Role = 'Database Viewer'; Scope = 'Kusto database'; API = 'Kusto query API'; Reason = 'Confirm database Viewer access or an equivalent role, and that the Kusto endpoint permits your connection.' } + } + else { + @{ Role = 'Storage Blob Data Reader'; Scope = 'Hub storage account or export container'; API = 'Azure Storage data API'; Reason = 'Confirm Storage Blob Data Reader or equivalent data access, and check the storage firewall or private endpoint connection. Subscription Reader alone does not grant storage data access.' } + } + foreach ($hubScan in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag')) { + $permissionInfo[$hubScan] = $hubPermission + } } if ($kustoProvider) { @@ -910,7 +937,7 @@ function Invoke-FinOpsMultitool { Write-Host ' Hub cost results are unavailable. Select API as the data source to run a separate live scan.' -ForegroundColor Yellow } } - elseif ($DataSource.HubStorage) { + elseif ($DataSource.Source -eq 'Hub' -and $DataSource.HubStorage) { # Storage reader: small-dataset convenience path (rows loaded into # PowerShell). For large hubs, the Kusto path above is preferred. $hub = $DataSource.HubStorage @@ -1029,7 +1056,7 @@ function Invoke-FinOpsMultitool { } $srcLabel = switch ($DataSource.Source) { - 'Hub' { "FinOps Hub ($($DataSource.HubStorage.name))" } + 'Hub' { if ($kustoProvider) { "FinOps Hub ($($kustoProvider.ClusterUri), $($kustoProvider.Database))" } else { "FinOps Hub ($($DataSource.HubStorage.name))" } } 'API' { "Cost Management API (real-time)" } 'GraphOnly' { "Resource Graph only" } } @@ -1245,15 +1272,14 @@ function Invoke-FinOpsMultitool { if ($null -eq $Value) { return '' } # Numbers, booleans, and dates carry no formula risk, and prefixing one # would stop a negative cost being read as a number. - if ($Value -is [ValueType]) { return $Value } - if ($Value -is [string]) { return Protect-FinOpsExportText $Value } - if ($Value -is [System.Collections.IDictionary]) { - return Protect-FinOpsExportText ((($Value.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; ')) + if ($Value -is [datetime] -or $Value -is [datetimeoffset]) { + return $Value.ToString('o', [System.Globalization.CultureInfo]::InvariantCulture) } - if ($Value -is [System.Collections.IEnumerable]) { - return Protect-FinOpsExportText (((@($Value) | ForEach-Object { [string]$_ }) -join '; ')) + if ($Value -is [ValueType]) { + return [System.Convert]::ToString($Value, [System.Globalization.CultureInfo]::InvariantCulture) } - return Protect-FinOpsExportText ([string]$Value) + if ($Value -is [string]) { return Protect-FinOpsExportText $Value } + return Protect-FinOpsExportText (ConvertTo-Json -InputObject $Value -Depth 30 -Compress -ErrorAction Stop) } # Scan results are wrapper objects whose payload is a nested collection or a @@ -1269,62 +1295,132 @@ function Invoke-FinOpsMultitool { if ($null -eq $Data) { return @() } $rows = $null + $payloadsByScan = @{ + 'Get-AHBOpportunities' = @('WindowsVMs', 'SQLVMs', 'SQLDatabases') + 'Get-AIWorkloadMetrics' = @('ByModel', 'ByAccount') + 'Get-AnomalyAlerts' = @('TriggeredAlerts', 'ConfiguredRules') + 'Get-BillingAccount' = @('Accounts') + 'Get-BillingStructure' = @('BillingAccounts', 'BillingProfiles', 'InvoiceSections', 'EADepartments', 'CostAllocationRules') + 'Get-BudgetStatus' = @('Budgets') + 'Get-CarbonMetrics' = @('MonthlyTrend', 'BySubscription') + 'Get-CommitmentUtilization' = @('Reservations', 'SavingsPlans') + 'Get-CostByTag' = @('CostByTag') + 'Get-CostTrend' = @('Months', 'BySubscription') + 'Get-IdleVMs' = @('IdleVMs') + 'Get-LegacyResources' = @('LegacyResources') + 'Get-MaccCommitment' = @('Commitments') + 'Get-OptimizationAdvice' = @('Recommendations') + 'Get-OrphanedResources' = @('Orphans') + 'Get-PolicyInventory' = @('Assignments', 'ComplianceBySubMap') + 'Get-PolicyRecommendations' = @('Analysis') + 'Get-ReservationAdvice' = @('AdvisorRecommendations', 'ReservationRecommendations') + 'Get-SavingsRealized' = @('Details') + 'Get-SharedCostAllocation' = @('Allocations', 'RuleTargets') + 'Get-StorageTierAdvice' = @('Recommendations') + 'Get-TagInventory' = @('TagNames', 'CaseVariants', 'UntaggedResources') + 'Get-TagRecommendations' = @('Analysis') + 'Get-UsageProportionalAllocation' = @('Allocations', 'RuleTargets') + 'Get-VmCostBreakdown' = @('Breakdown') + } + + $metadata = [ordered]@{} + $summaryCollections = [ordered]@{} + if ($payloadsByScan.ContainsKey($Fn)) { + $payloadNames = $payloadsByScan[$Fn] + if ($Data -is [System.Collections.IDictionary]) { + foreach ($field in $Data.GetEnumerator()) { + if ($field.Key -notin $payloadNames) { $metadata["Summary.$($field.Key)"] = $field.Value } + } + } + else { + foreach ($property in $Data.PSObject.Properties) { + if ($property.Name -notin $payloadNames) { $metadata["Summary.$($property.Name)"] = $property.Value } + } + } + foreach ($name in @($metadata.Keys)) { + $value = $metadata[$name] + if ($null -ne $value -and $value -isnot [string] -and $value -isnot [ValueType]) { + $summaryCollections[$name] = $value + $metadata.Remove($name) + } + } + } # 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 - } + $record = [ordered]@{ SubscriptionId = $_.Key } + foreach ($field in $_.Value.GetEnumerator()) { $record[$field.Key] = $field.Value } + [PSCustomObject]$record }) } - 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 ($payloadsByScan.ContainsKey($Fn)) { + $rows = @( + if ($Fn -eq 'Get-CostByTag' -and $Data.CostByTag) { + foreach ($tag in $Data.CostByTag.GetEnumerator()) { + foreach ($entry in @($tag.Value)) { + $record = [ordered]@{ + RecordType = 'CostByTag' + TagKey = $tag.Key + TagValue = $entry.TagValue + Cost = $entry.Cost + Currency = $entry.Currency + } + foreach ($field in $metadata.GetEnumerator()) { $record[$field.Key] = $field.Value } + [PSCustomObject]$record + } + } + } + foreach ($collection in @($payloadNames | Where-Object { $_ -ne 'CostByTag' }) + @($summaryCollections.Keys)) { + $payload = if ($summaryCollections.Contains($collection)) { $summaryCollections[$collection] } else { $Data.$collection } + $entries = if ($payload -is [System.Collections.IDictionary]) { + foreach ($group in $payload.GetEnumerator()) { + foreach ($entry in @($group.Value | Where-Object { $null -ne $_ })) { + $record = [ordered]@{ Key = $group.Key } + if ($collection -eq 'BySubscription') { $record = [ordered]@{ SubscriptionId = $group.Key } } + elseif ($collection -eq 'TagNames') { $record = [ordered]@{ TagKey = $group.Key } } + if ($entry -is [System.Collections.IDictionary]) { + foreach ($field in $entry.GetEnumerator()) { $record[$field.Key] = $field.Value } + } + elseif ($entry -is [string] -or $entry -is [ValueType]) { $record['Value'] = $entry } + else { foreach ($property in $entry.PSObject.Properties) { $record[$property.Name] = $property.Value } } + [PSCustomObject]$record + } } } + else { @($payload | Where-Object { $null -ne $_ }) } + foreach ($entry in $entries) { + $record = [ordered]@{ RecordType = $collection } + if ($entry -is [System.Collections.IDictionary]) { + foreach ($field in $entry.GetEnumerator()) { $record[$field.Key] = $field.Value } + } + elseif ($entry -is [string] -or $entry -is [ValueType]) { $record['Value'] = $entry } + else { foreach ($property in $entry.PSObject.Properties) { $record[$property.Name] = $property.Value } } + foreach ($field in $metadata.GetEnumerator()) { $record[$field.Key] = $field.Value } + [PSCustomObject]$record + } }) } elseif ($Data -is [System.Collections.IDictionary]) { $rows = @($Data.GetEnumerator() | ForEach-Object { - [PSCustomObject]@{ Key = $_.Key; Value = (ConvertTo-FinOpsExportCell $_.Value) } + [PSCustomObject]@{ Key = $_.Key; Value = $_.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) - } + $rows = @($Data) + } + + if ($payloadsByScan.ContainsKey($Fn) -and $rows.Count -eq 0) { + $record = [ordered]@{ RecordType = 'Summary' } + foreach ($field in $metadata.GetEnumerator()) { $record[$field.Key] = $field.Value } + $rows = @([PSCustomObject]$record) } # Whatever projection was chosen, guarantee scalar cells. - return @($rows | Where-Object { $null -ne $_ } | ForEach-Object { + $flatRows = @($rows | Where-Object { $null -ne $_ } | ForEach-Object { $row = $_ if ($row -is [System.Collections.IDictionary]) { $ordered = [ordered]@{} @@ -1340,6 +1436,15 @@ function Invoke-FinOpsMultitool { [PSCustomObject]@{ Value = ConvertTo-FinOpsExportCell $row } } }) + $columnNames = [System.Collections.Generic.List[string]]::new() + $seenColumns = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($row in $flatRows) { + foreach ($property in $row.PSObject.Properties) { + if ($seenColumns.Add($property.Name)) { [void]$columnNames.Add($property.Name) } + } + } + if ($flatRows.Count -eq 0) { return @() } + return @($flatRows | Select-Object -Property $columnNames.ToArray()) } function Show-ResultsSummary { @@ -1349,8 +1454,6 @@ function Invoke-FinOpsMultitool { [string]$ExportPath, [array]$Subscriptions, - # The source that actually produced the numbers, which is not always the - # one requested: a Hub run that returns nothing falls back to the API. [string]$DataSourceLabel ) @@ -1554,9 +1657,12 @@ function Invoke-FinOpsMultitool { } } 'Get-SavingsRealized' { - 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' + Write-Host " Estimated savings (separate periods):" -ForegroundColor White + Write-Host " Commitment period: $($data.Period)" -ForegroundColor DarkGray + Write-ColorizedLine -Text " RI: $(Format-BudgetAmount -Value $data.RISavingsMonthToDate -Currency $data.Currency) SP: $(Format-BudgetAmount -Value $data.SPSavingsMonthToDate -Currency $data.Currency)" -DefaultColor 'Cyan' + Write-ColorizedLine -Text " Commitment estimate: $(Format-BudgetAmount -Value $data.CommitmentSavingsMonthToDate -Currency $data.Currency)" -DefaultColor 'White' + Write-ColorizedLine -Text " AHB: $(Format-BudgetAmount -Value $data.AHBSavingsMonthly -Currency $data.AHBCurrency) ($($data.AHBPeriod))" -DefaultColor 'Cyan' + if ($data.AHBIssue) { Write-Host " $($data.AHBIssue)" -ForegroundColor Yellow } if ($data.EstimateBasis) { Write-Host " $($data.EstimateBasis)" -ForegroundColor DarkGray } @@ -2150,17 +2256,24 @@ function Invoke-FinOpsMultitool { $maxUntaggedTag = '' $seenCost = $data.ResourceCostSeen $unallocCost = $data.UnallocatedCost - $haveCostData = (($seenCost -and [double]$seenCost -gt 0) -or ($data.AllocatedCost -and [double]$data.AllocatedCost -gt 0)) - if ($seenCost -and [double]$seenCost -gt 0 -and $null -ne $unallocCost) { + $tagCostRows = @($data.CostByTag.Values | ForEach-Object { $_ } | Where-Object { $null -ne $_.Cost }) + $allocationTags = @($data.CostByTag.Keys | Where-Object { $allocTags -contains $_ }) + $haveResourceTotals = $null -ne $seenCost -and $null -ne $unallocCost + $haveCostData = $haveResourceTotals -or $tagCostRows.Count -gt 0 + $havePositiveCost = [double]$seenCost -gt 0 + $hasCredits = @($tagCostRows | Where-Object { [double]$_.Cost -lt 0 }).Count -gt 0 + if ($haveResourceTotals) { $maxUntaggedCost = [double]$unallocCost $maxUntaggedTag = 'any allocation tag' + $hasCredits = $hasCredits -or [double]$unallocCost -lt 0 -or [double]$unallocCost -gt [double]$seenCost } else { foreach ($tag in $data.CostByTag.GetEnumerator()) { + if (($tag.Value | Measure-Object Cost -Sum).Sum -gt 0) { $havePositiveCost = $true } if ($allocTags -notcontains $tag.Key) { continue } - foreach ($v in $tag.Value) { - if ($v.TagValue -eq '(untagged)' -and [double]$v.Cost -gt $maxUntaggedCost) { - $maxUntaggedCost = [double]$v.Cost + foreach ($tagValue in $tag.Value) { + if ($tagValue.TagValue -eq '(untagged)' -and [double]$tagValue.Cost -gt $maxUntaggedCost) { + $maxUntaggedCost = [double]$tagValue.Cost $maxUntaggedTag = $tag.Key } } @@ -2173,11 +2286,21 @@ function Invoke-FinOpsMultitool { @{ Severity = 'Yellow'; Message = "No cost data was returned for this period, so spend cannot be split by tag. Tag coverage itself is unaffected - check the data source, permissions, and that the period has usage." } ) } - elseif (@($data.AllocationTags | Where-Object { $_ }).Count -eq 0) { + elseif ($allocationTags.Count -eq 0) { $guidanceItems = @( @{ Severity = 'Yellow'; Message = "No CAF allocation tag (CostCenter, Customer, Project, Environment, Owner, ...) is in use, so spend cannot be attributed. Add an allocation tag and deploy inheritance to make cost traceable." } ) } + elseif ($hasCredits) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Cost data includes credits or negative net costs. Review the amounts by tag; allocation percentages might not be comparable.' } + ) + } + elseif (-not $havePositiveCost) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Cost data is available, but there is no positive net cost for allocation percentages.' } + ) + } elseif ($maxUntaggedCost -gt 1000) { $guidanceItems = @( @{ Severity = 'Red'; Message = "Untagged spend: $("{0:C0}" -f $maxUntaggedCost) not allocated by '$maxUntaggedTag'. This cost cannot be attributed to any team, project, or budget." } @@ -2192,7 +2315,7 @@ function Invoke-FinOpsMultitool { } else { $guidanceItems = @( - @{ Severity = 'Green'; Message = "All scanned cost is tagged with allocation tags. Cost allocation is fully traceable — enables chargeback and showback." } + @{ Severity = 'Green'; Message = 'No positive untagged cost was found for the allocation tags in this result.' } ) } } @@ -2302,15 +2425,15 @@ function Invoke-FinOpsMultitool { } } 'Get-SavingsRealized' { - if ($data.TotalMonthly -and $data.TotalMonthly -gt 0) { + if ($data.CommitmentSavingsMonthToDate -gt 0 -or $data.AHBSavingsMonthly -gt 0) { $guidanceItems = @( - @{ Severity = 'Green'; Message = "Realizing $($data.TotalMonthly.ToString('C0'))/month ($($data.TotalAnnual.ToString('C0'))/year) in commitment discounts." } - @{ Severity = 'Green'; Message = "FinOps Maturity: Active savings tracking shows Run-level FinOps maturity. Keep reviewing quarterly." } + @{ Severity = 'Yellow'; Message = 'Estimated savings use assumed discounts. Commitment amounts cover the reported month-to-date period; AHB uses a separate 730-hour estimate. They are not combined or annualized.' } + @{ Severity = 'Yellow'; Message = 'Validate the estimate against matching pay-as-you-go rates and benefit usage before reporting savings.' } ) } else { $guidanceItems = @( - @{ Severity = 'Yellow'; Message = "No savings from commitments detected. Evaluate RIs and Savings Plans for steady-state workloads." } + @{ Severity = 'Yellow'; Message = 'No positive savings estimate is available from this scan. Review commitment usage and data access before drawing a conclusion.' } @{ Severity = 'Yellow'; Message = "FinOps Practice: Commitment discounts are the #1 cost optimization lever (30-60% savings)."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' } ) } @@ -2663,10 +2786,10 @@ tr:hover td { background: var(--surface); } "@) # Summary cards - $errorCount = ($Modules | Where-Object { $_.Selected } | Where-Object { $Results.ContainsKey("_error_$($_.Fn)") }).Count + $errorCount = @($Modules | Where-Object { $_.Selected } | Where-Object { $Results.ContainsKey("_error_$($_.Fn)") }).Count [void]$htmlSb.Append('
') [void]$htmlSb.Append("
Total Findings
$totalFindings
") - [void]$htmlSb.Append("
Scans Run
$(($Modules | Where-Object { $_.Selected }).Count)
") + [void]$htmlSb.Append("
Scans Run
$(@($Modules | Where-Object { $_.Selected }).Count)
") if ($errorCount -gt 0) { [void]$htmlSb.Append("
Errors
$errorCount
") } @@ -2944,7 +3067,11 @@ tr:hover td { background: var(--surface); } } } 'Get-SavingsRealized' { - [void]$htmlSb.Append("

RI: $($data.RISavingsMonthly.ToString('C0'))  |  SP: $($data.SPSavingsMonthly.ToString('C0'))  |  AHB: $($data.AHBSavingsMonthly.ToString('C0'))  |  Total: $($data.TotalMonthly.ToString('C0'))/mo

") + [void]$htmlSb.Append("

Estimated commitment savings ($([System.Net.WebUtility]::HtmlEncode([string]$data.Period))): $([System.Net.WebUtility]::HtmlEncode((Format-BudgetAmount -Value $data.CommitmentSavingsMonthToDate -Currency $data.Currency)))

") + [void]$htmlSb.Append("

RI: $([System.Net.WebUtility]::HtmlEncode((Format-BudgetAmount -Value $data.RISavingsMonthToDate -Currency $data.Currency)))  |  SP: $([System.Net.WebUtility]::HtmlEncode((Format-BudgetAmount -Value $data.SPSavingsMonthToDate -Currency $data.Currency)))

") + [void]$htmlSb.Append("

AHB: $([System.Net.WebUtility]::HtmlEncode((Format-BudgetAmount -Value $data.AHBSavingsMonthly -Currency $data.AHBCurrency))) ($([System.Net.WebUtility]::HtmlEncode([string]$data.AHBPeriod)))

") + if ($data.AHBIssue) { [void]$htmlSb.Append("

$([System.Net.WebUtility]::HtmlEncode([string]$data.AHBIssue))

") } + if ($data.EstimateBasis) { $tableNote = [string]$data.EstimateBasis } } 'Get-BudgetStatus' { $htmlCoverage = if ($data.CoverageIncomplete) { @@ -3242,7 +3369,7 @@ tr:hover td { background: var(--surface); } # Show active data source $sourceLabel = switch ($sourceChoice.Source) { - 'Hub' { "FinOps Hub ($($sourceChoice.HubStorage.name))" } + 'Hub' { if ($sourceChoice.HubProvider) { "FinOps Hub ($($sourceChoice.HubProvider.ClusterUri), $($sourceChoice.HubProvider.Database))" } else { "FinOps Hub ($($sourceChoice.HubStorage.name))" } } 'API' { 'Cost Management API (real-time)' } 'GraphOnly' { 'Resource Graph only (no cost data)' } } @@ -3283,14 +3410,11 @@ tr:hover td { background: var(--surface); } } # Step 4: Run - $results = Invoke-SelectedScans -Modules $finalModules -Subscriptions $subs -TenantId $tenantId -DataSource $sourceChoice + $results = Invoke-SelectedScans -Modules $finalModules -Subscriptions $subs -TenantId $tenantId -DataSource $sourceChoice -PermissionInfo $permissionInfo # Step 5: Summary + export - # Re-read the source after the run: Invoke-SelectedScans downgrades Hub to API - # in place when the hub returns nothing, so this is the source that actually - # produced the numbers rather than the one requested. $effectiveSource = switch ($sourceChoice.Source) { - 'Hub' { "FinOps Hub ($($sourceChoice.HubStorage.name))" } + 'Hub' { if ($sourceChoice.HubProvider) { "FinOps Hub ($($sourceChoice.HubProvider.ClusterUri), $($sourceChoice.HubProvider.Database))" } else { "FinOps Hub ($($sourceChoice.HubStorage.name))" } } 'API' { 'Cost Management API (real-time)' } 'GraphOnly' { 'Resource Graph only (no cost data)' } default { [string]$sourceChoice.Source } diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index bb8af3164..e436027ee 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -1,3 +1,5 @@ + + # 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. @@ -18,12 +20,13 @@ Invoke-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' ## Requirements -| Requirement | Details | -| --------------------- | ----------------------------------------------- | -| PowerShell | 7.0 or later (Windows, macOS, Linux) | -| Az modules | `Az.Accounts`, `Az.ResourceGraph`, `Az.Storage` | -| Azure RBAC | Reader + Cost Management Reader on target scope | -| FinOps Hub (optional) | Storage Blob Data Reader on Hub storage account | +| Requirement | Details | +| -------------------------------------------- | ------------------------------------------------------------------------------------ | +| PowerShell | 7.0 or later (Windows, macOS, Linux) | +| Az modules | `Az.Accounts`, `Az.ResourceGraph`, `Az.Storage` | +| Azure role-based access control (Azure RBAC) | Reader and Cost Management Reader on the target scope | +| FinOps hub storage (optional) | Storage Blob Data Reader on the hub storage account | +| FinOps hub Kusto (optional) | Query access to the hub database. A local ftklocal instance uses its local endpoint. | Install Az modules if needed: @@ -35,23 +38,23 @@ Install-Module Az.Accounts, Az.ResourceGraph, Az.Storage -Scope CurrentUser ### 1. Authentication -On launch, the TUI checks for an existing `Az.Accounts` session. If you're not logged in, it prompts you to run `Connect-AzAccount`. If your account has access to multiple Azure AD tenants, a tenant picker appears so you can select which tenant to scan. It then discovers all accessible subscriptions and lets you select which ones to scan. +On launch, the TUI checks for an existing `Az.Accounts` session and starts `Connect-AzAccount` when needed. If you supply `-SubscriptionId`, the tool resolves the subscription and sets the subscription and tenant context before displaying menus. Otherwise, the tool offers a tenant menu when supported and discovers subscriptions in the selected tenant. ### 2. Data source selection If a FinOps Hub is detected in any of your subscriptions, you'll be asked to choose a data source: -| Source | Description | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| **FinOps Hub** | Reads cost data from the FinOps Hub. Faster, no API throttling. Tag and cost-by-tag scans are instant. | -| **Cost Management API** | Queries the Cost Management REST API in real-time. Slower but always current. Hub tag data is still used when available. | -| **Resource Graph only** | Skips all cost APIs. Only runs scans that use Azure Resource Graph (orphaned resources, idle VMs, etc). | +| Source | Description | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| **FinOps Hub** | Reads available cost data from the hub. Kusto summarizes data in the engine; the storage reader is for small datasets. | +| **Cost Management API** | Queries currently available cost data through the Cost Management REST API. Doesn't preload hub data. | +| **Resource Graph only** | Skips all cost APIs. Only runs scans that use Azure Resource Graph (orphaned resources, idle VMs, etc). | When the **FinOps Hub** source is chosen, the tool prefers the hub's **Kusto database** (Azure Data Explorer / Fabric, or a local ftklocal emulator) and pushes aggregation into the engine, returning only summarized results. This is the scalable path for large customer datasets — it never loads the raw cost rows into PowerShell. See [FinOps Hub data paths](#finops-hub-data-paths) below. The storage-export reader remains as a small-dataset fallback. ### 3. Scan selection -Arrow-key driven menu to toggle individual scans on/off. All scans are selected by default except Billing Structure. +Use arrow-key menus to select scans when your host supports them. Other hosts use numbered prompts. For automation, use `-NonInteractive` with `-Scans`, `-DataSource`, and `-SubscriptionId`. Add `-OutputPath` to export results. All menu scans are selected by default except **Billing Structure**. | Key | Action | | --------- | ------------------ | @@ -64,7 +67,7 @@ Arrow-key driven menu to toggle individual scans on/off. All scans are selected ### 4. Scan execution -Selected scans run sequentially with a progress bar. When a FinOps Hub is available, tag-related scans (Tag Inventory, Cost by Tag) use pre-loaded Hub data instead of API calls — completing in under a second. +Selected scans run sequentially with a progress bar. Supported scans reuse available hub summaries or preloaded rows. The tool reports hub query failures as scan errors and doesn't silently switch data sources. It reports AI metrics from a Kusto-only hub as unavailable. Select **Cost Management API** to run a separate live AI scan. ### 5. Results @@ -89,32 +92,38 @@ Guidance includes FinOps Foundation best practices, actionable next steps, and l Optional exports write to the output path: one CSV file per scan module, a `FinOpsReport.html` summary, and a `ScanSummary.txt` text summary. +CSV files use `RecordType` to distinguish datasets when a scan returns several collections, such as reservations and savings plans. Scalar `Summary.*` columns retain scan diagnostics and estimate assumptions. Nested summary collections appear once as separate record types, such as `Summary.UnderutilizedRIs`, instead of repeating in every row. Nested values within a record are JSON. CSV headers include fields from every exported record type, amounts use a decimal point regardless of your system locale, and dates use ISO 8601. Aggregate and detailed records are separate views, not amounts to add together. + ## Required permissions -Each scan module requires specific Azure RBAC roles. The TUI will tell you which role is needed if a scan fails due to missing permissions. +Each scan requires specific permissions. The TUI identifies the required role when a scan fails because of missing permissions. Billing permissions depend on your agreement, such as a Microsoft Customer Agreement (MCA) or Enterprise Agreement (EA). + +| Category | Scans | Required role | Scope | +| ---------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| Optimization | Orphaned Resources, Idle VMs, Storage Tier Advice, AHB | Reader | Subscription | +| Governance | Tag Inventory, Tag Recommendations, Policy Inventory/Recs | Reader | Subscription | +| Cost | Cost Data, Resource Costs, Cost by Tag, Cost Trend | Cost Management Reader | Subscription or management group | +| Commitments | Reservation Advice, Savings Realized estimates | Cost Management Reader, and Reader for Azure Hybrid Benefit inventory | Subscription or management group | +| Commitment utilization | Reservation and savings plan usage | Billing access for the agreement, such as EA Enterprise Administrator (read only) or MCA Billing account reader or Billing profile reader | Billing account or profile | +| Monitoring | Budget Status, Anomaly Alerts | Cost Management Reader | Subscription | +| Advisor | Optimization Advice | Reader | Subscription | +| Account | Billing Structure, Contract Info, MACC | Billing access for the agreement | Billing account or profile | +| Hub storage (optional) | Storage-backed cost and tag scans | Storage Blob Data Reader | Hub storage account | +| Hub Kusto (optional) | Kusto-backed cost summaries | Database query access | Hub database | -| Category | Scans | Required Role | Scope | -| ------------ | ------------------------------------------------------------ | ------------------------ | ------------------- | -| Optimization | Orphaned Resources, Idle VMs, Storage Tier Advice, AHB | Reader | Subscription | -| Governance | Tag Inventory, Tag Recommendations, Policy Inventory/Recs | Reader | Subscription | -| Cost | Cost Data, Resource Costs, Cost by Tag, Cost Trend | Cost Management Reader | Subscription or MG | -| Commitments | Reservation Advice, Commitment Utilization, Savings Realized | Cost Management Reader | Subscription | -| Monitoring | Budget Status, Anomaly Alerts | Cost Management Reader | Subscription | -| Advisor | Optimization Advice | Reader | Subscription | -| Account | Billing Structure, Contract Info | Billing Reader | Billing Account | -| Hub (opt.) | All scans via Hub data | Storage Blob Data Reader | Hub Storage Account | +Subscription Reader access alone doesn't grant billing access. See [MCA billing roles](https://learn.microsoft.com/azure/cost-management-billing/manage/understand-mca-roles), [EA roles](https://learn.microsoft.com/azure/cost-management-billing/manage/understand-ea-roles), and [Kusto database roles](https://learn.microsoft.com/kusto/management/manage-database-security-roles). Reading hub data also requires network access to the storage or Kusto endpoint. If you receive a 403 response, check the firewall or private endpoint as well as role assignments. ## Available scans ### Optimization (Resource Graph) -| Scan | What it finds | -| ------------------- | --------------------------------------------------------------------- | -| Orphaned Resources | Unattached disks, NICs, public IPs, NSGs | -| Idle VMs | VMs with <5% CPU over 30 days | -| Storage Tier Advice | Blob storage that could move to cooler tiers | -| AHB Opportunities | Windows/SQL VMs not using Azure Hybrid Benefit | -| Legacy Resources | Legacy/retiring SKUs (v1 VM families, unmanaged disks, Basic IPs/LBs) | +| Scan | What it finds | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Orphaned Resources | Unattached disks, NICs, public IPs, NSGs | +| Idle VMs | Running VMs with average CPU below 5% and network traffic below 1 MB per day over 14 days. A second threshold flags underutilized VMs. | +| Storage Tier Advice | Blob storage that could move to cooler tiers | +| AHB Opportunities | Windows/SQL VMs not using Azure Hybrid Benefit | +| Legacy Resources | Legacy/retiring SKUs (v1 VM families, unmanaged disks, Basic IPs/LBs) | ### Governance @@ -143,11 +152,17 @@ Each scan module requires specific Azure RBAC roles. The TUI will tell you which ### Commitments -| Scan | What it finds | -| ---------------------- | ---------------------------------------- | -| Reservation Advice | RI purchase recommendations from Advisor | -| Commitment Utilization | RI and Savings Plan usage rates | -| Savings Realized | Actual savings from existing commitments | +| Scan | What it finds | +| ---------------------- | --------------------------------------------------------------------------------------- | +| Reservation Advice | RI purchase recommendations from Advisor | +| Commitment Utilization | RI and Savings Plan usage rates | +| Savings Realized | Estimates of commitment and Azure Hybrid Benefit savings, not measured realized savings | + +The scan keeps the **Savings Realized** name for compatibility. Reservation and savings plan estimates use assumed effective discounts of 40% and 25%. Azure Hybrid Benefit estimates use a Windows license premium when available, with a fallback estimate otherwise. Results include `IsEstimate` and `EstimateBasis`. Compare the estimates with matching pay-as-you-go rates and benefit usage before reporting realized savings. + +Commitment estimates cover usage charges in the captured UTC month-to-date period and retain the billing currency. Purchases, refunds, and unused commitment charges are excluded before aggregation. Unknown, nonmonetary, or mixed billing currencies stop the scan instead of producing a combined amount. Negative usage adjustments also stop the estimate because the assumed discount can't produce a comparable savings amount. Use `RISavingsMonthToDate`, `SPSavingsMonthToDate`, and `CommitmentSavingsMonthToDate`, together with `Currency` and `Period`. + +The AHB estimate is separate: `AHBSavingsMonthly` represents 730 hours for the current VM inventory in USD, using a retail Windows license premium or a USD 50 per-VM fallback. `AHBCurrency` and `AHBPeriod` identify those units. The scan doesn't combine these amounts or annualize them. The legacy `RISavingsMonthly`, `SPSavingsMonthly`, `TotalMonthly`, and `TotalAnnual` fields remain present but are empty. ### Monitoring @@ -207,13 +222,13 @@ Storage $ 41,200 (24.3%) 126,400 GB (84,600 GB disk + 41,800 GB blob/file) Cost per GB stored $0.326 / month ``` -vCPU and RAM are exact (read from Compute SKU capabilities). Storage GB combines provisioned managed disks with Storage-account used capacity (Azure Monitor `UsedCapacity`). Cost is scoped to the selected subscriptions and falls back to per-subscription queries when the management-group scope is not accessible, so the section is never silently $0. Directly produces `Cost per GB Stored`; feeds `Hourly Cost per CPU Core` (÷ 730) and `Effective Avg Compute Cost per Core`. +vCPU and RAM come from Compute SKU capabilities. Storage capacity combines provisioned managed disk capacity with storage account usage from the Azure Monitor `UsedCapacity` metric. The tool reports the combined capacity in GB. Cost queries cover the selected subscriptions. If the tool can't access the management group scope, it queries each subscription separately and reports failed queries as errors. **Hourly Cost per CPU Core** divides cost per vCPU by the elapsed hours in the recorded UTC cost period, with a one-hour minimum. It doesn't use a fixed 730-hour month. ### Token Consumption / Cost per 1K Tokens / Cost per API Call → ai workloads > "What are my AI/LLM workloads costing per token and per request this month?" -This scan is self-gating: a single Resource Graph query detects whether any AI workloads (Azure OpenAI, AI Services, Machine Learning, AI Search, GPU VMs) exist. Non-AI tenants skip the deep scan entirely, so the scan stays fast. When AI is present, it joins Azure Monitor token metrics to Cost Management spend over the same month-to-date window. +This scan first queries Resource Graph for AI workloads (Azure OpenAI, Foundry Tools, Azure Machine Learning, Azure AI Search, and GPU VMs) in the selected subscriptions. When AI workloads are present, the API path combines Azure Monitor token metrics with Cost Management spend over the same month-to-date window. ```text AI footprint — OpenAI/AIServices: 3 ML workspaces: 1 AI Search: 2 GPU VMs: 0 @@ -227,7 +242,7 @@ gpt-4o-mini 77,700,000 26,500,000 104,200,000 25.2 Produces `Token Consumption`, `Cost per 1K Tokens` (effective blended rate), and `Cost per API Call`; the per-model breakdown highlights where to shift traffic to cheaper SKUs or evaluate Provisioned Throughput Units (PTUs). -Like the cost scans, this honors `dataSource` (`auto` / `hub` / `api`). When a readable FinOps Hub export covers the scope, AI spend and billed token volume are read straight from the export — no Azure Monitor or Cost Management calls. Cost per request is only available on the live API path, since request counts are not billed line items. +The TUI uses the selected `-DataSource`. When readable hub rows cover the selected subscriptions, AI spend and billed token volume come from those rows and use their observed period. A Kusto-only hub doesn't currently provide this AI scan, so the result is unavailable. Select **Cost Management API** to run a separate live scan. Cost per request is available only on the API path because request counts aren't billed line items. ### Carbon per Unit of Spend / Carbon Efficiency → carbon @@ -272,29 +287,29 @@ Tagged spend $612,300 (87.4%) Untagged spend $ 88,200 (12.6%) ← KPI ``` -`% Costs from Untagged Resources` = 12.6%. +**% Costs from Untagged Resources** = 12.6%. The resource-based path measures cost with no allocation tag. Server-aggregated results use the allocation tag with the lowest cost coverage and name that tag in the result. The tool reports the percentage as unavailable when net totals are zero or negative, or when credits make the percentage unsuitable for comparison. It doesn't score those results. ## FinOps hub integration -When a Hub is detected, the tool reads FinOps Hub cost data. This enables: +When you select **FinOps Hub**, supported scans reuse its available cost data: -- **Instant tag scans** — Tag Inventory and Cost by Tag are answered from Hub data instead of querying the Cost Management API -- **No API throttling** — avoids Cost Management API rate limits -- **Richer tag data** — Hub data contains the full Tags per cost record, enabling accurate per-resource tag parsing -- **Forecast enrichment** — Hub data contains actuals only, so the TUI calls the Cost Management Forecast API to project full-month costs and adds them to Hub actuals (storage path) -- **Accurate tag coverage** — Hub only sees resources with cost data. The TUI queries Azure Resource Graph for the true total/untagged resource count and overrides the Hub-derived coverage percentage +- **Tag data reuse**: stored cost records can supply tag inventory and cost by tag. Kusto returns aggregated tag costs. Azure Resource Graph supplies resource inventory where needed. +- **Fewer cost queries**: hub summaries reduce Cost Management API calls. Other scans and forecast enrichment can still call Azure APIs and encounter throttling. +- **Observed cost periods**: actual costs use the dates present in the selected subscriptions' hub data, not an assumed current-month window. +- **Forecast enrichment**: for current-month storage data, the TUI can show a separate full-month API forecast with matching currency. It never adds that forecast to hub actuals. The forecast is unavailable for older data and Kusto summaries, or when the API can't supply it. +- **Resource coverage**: a hub contains only resources represented in its cost data. The storage path queries Azure Resource Graph for total and untagged resource counts when available. ### FinOps hub data paths -The cost-family scans (Cost Data, Resource Costs, Cost by Tag) read from a FinOps Hub three ways, in priority order. The first two push aggregation **into the engine** and bring back only summarized results — they never load the raw cost rows into PowerShell, so they scale to large customer datasets (tens of GB / hundreds of millions of rows): +The **Cost Data**, **Resource Costs**, and **Cost by Tag** scans support three hub paths. The Kusto paths aggregate data in the engine and return summarized results without loading raw cost rows into PowerShell: -| Path | When | How | -| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Kusto — online** | A deployed hub with an Azure Data Explorer / Fabric cluster | The cluster is discovered via Azure Resource Graph (`microsoft.kusto/clusters` tagged `ftk-tool == 'FinOps hubs'`), queried with a bearer token. Aggregation runs in KQL against the `Costs` function. | -| **Kusto — offline (ftklocal)** | Your own hardware / air-gapped: an [ftklocal](https://github.com/microsoft/finops-toolkit) Kusto emulator with the exports loaded into the local **Hub** database | Set `FINOPS_HUB_KUSTO_URI` (and optionally `FINOPS_HUB_KUSTO_DB`, default `Hub`). The local emulator is queried anonymously — same KQL, no auth. | -| **Storage export reader** | Small datasets, or when no Kusto cluster is available | Reads the hub's `ingestion` parquet / `msexports` CSV and aggregates in PowerShell. A convenience fallback, **not** the scalable path. | +| Path | When | How | +| -------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Kusto — online** | A deployed hub with an Azure Data Explorer / Fabric cluster | The cluster is discovered via Azure Resource Graph (`microsoft.kusto/clusters` tagged `ftk-tool == 'FinOps hubs'`), queried with a bearer token. Aggregation runs in KQL against the `Costs` function. | +| **Local Kusto (ftklocal)** | A local Kusto emulator with cost data in its `Hub` database | Set `FINOPS_HUB_KUSTO_URI`. Optionally, set `FINOPS_HUB_KUSTO_DB`, which defaults to `Hub`. Loopback queries are anonymous. The public launcher still uses Azure context and resource metadata, so this isn't a fully offline workflow. | +| **Storage export reader** | Small datasets, or when no Kusto cluster is available | Reads the hub's `ingestion` parquet / `msexports` CSV and aggregates in PowerShell. A convenience fallback, **not** the scalable path. | -Selection is automatic: `FINOPS_HUB_KUSTO_URI` (if set) wins, else a discovered cluster, else the storage reader. To force the live Cost Management API instead, choose the **Cost Management API** source in the TUI. +An explicit `-DataSource API` or `-DataSource GraphOnly` takes precedence over `FINOPS_HUB_KUSTO_URI` and doesn't preload hub data. Otherwise, a configured Kusto URI selects the hub without requiring storage-account discovery. For a discovered hub, the tool prefers Kusto and uses the storage reader when no Kusto provider is available. An explicit `-DataSource Hub` fails if neither a configured endpoint nor hub storage is available; it doesn't silently switch to API. #### Environment variables @@ -303,7 +318,7 @@ Selection is automatic: `FINOPS_HUB_KUSTO_URI` (if set) wins, else a discovered | `FINOPS_HUB_KUSTO_URI` | Kusto cluster query URI. An `https://...kusto.windows.net` cluster (token auth) or `http://localhost:` ftklocal emulator (anonymous). | unset (auto-discover) | | `FINOPS_HUB_KUSTO_DB` | Hub database name. | `Hub` | -Hub data is loaded once at startup and reused across all scans that need it. +The tool loads hub summaries or storage rows once per run and reuses them for supported scans. It reports a failed query against the selected hub as an error and doesn't silently replace the result with API data. ## Scripting (non-interactive) diff --git a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json index db5627a84..8cc0572db 100644 --- a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json +++ b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json @@ -215,14 +215,14 @@ }, { "id": "effective-savings-rate", - "name": "Effective Savings Rate Percentage", + "name": "Estimated commitment savings", "domain": "Quantify", - "definition": "Return-on-investment metric across all commitment discounts and optimizations.", + "definition": "A month-to-date commitment savings estimate for usage charges in the reported billing currency, using assumed discounts rather than measured savings.", "sourceTool": "scan_savings_realized", "compute": true, - "unit": "%", - "plainLanguage": "How much you are actually saving versus paying full on-demand rates. Shown as realized monthly savings from RIs, Savings Plans and AHB (a true rate also needs total on-demand-equivalent spend).", - "exploreHint": "Realized savings from RIs, Savings Plans and AHB appear in this scan." + "unit": "currency/period", + "plainLanguage": "This scan estimates commitment savings for the reported UTC period, not a savings percentage or an annual projection. Azure Hybrid Benefit has a separate USD estimate for 730 hours and isn't added to this amount. Actual discounts vary by SKU, term, region, and agreement.", + "exploreHint": "Validate the estimate against matching pay-as-you-go rates and benefit usage before reporting realized savings." }, { "id": "pct-legacy-resource", diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 index 6a861272b..487818ace 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -28,11 +28,26 @@ function Get-SavingsRealized { [object]$CommitmentData ) - Write-Host " Calculating savings already realized..." -ForegroundColor Cyan + Write-Host " Estimating savings from commitments..." -ForegroundColor Cyan $riSavings = 0 $spSavings = 0 $ahbSavings = 0 + $periodEndUtc = (Get-Date).ToUniversalTime() + $periodStartUtc = $periodEndUtc.Date.AddDays(1 - $periodEndUtc.Day) + $periodStart = $periodStartUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + $periodEnd = $periodEndUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + $period = "$periodStart to $periodEnd" + $currencies = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $monetaryCurrencies = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($culture in [System.Globalization.CultureInfo]::GetCultures([System.Globalization.CultureTypes]::SpecificCultures)) { + try { + $region = [System.Globalization.RegionInfo]::new($culture.Name) + if ($region.ISOCurrencySymbol -notin @('XXX', 'XTS')) { [void]$monetaryCurrencies.Add($region.ISOCurrencySymbol) } + } + catch [System.ArgumentException] { Write-Verbose "No currency metadata for culture $($culture.Name)." } + } + $ahbIssue = $null # 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 @@ -74,32 +89,51 @@ function Get-SavingsRealized { # Build a Cost Management query body with the requested grouping dimensions function New-SavingsQueryBody { param([string]$Type, [string[]]$Dimensions) - @{ + $query = @{ type = $Type - timeframe = 'MonthToDate' + timeframe = 'Custom' + timePeriod = @{ from = $periodStart; to = $periodEnd } dataset = @{ granularity = 'None' aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } grouping = @($Dimensions | ForEach-Object { @{ type = 'Dimension'; name = $_ } }) } - } | ConvertTo-Json -Depth 10 + } + if ($Type -eq 'AmortizedCost') { + $query.dataset.filter = @{ dimensions = @{ name = 'ChargeType'; operator = 'In'; values = @('Usage') } } + } + $query | ConvertTo-Json -Depth 10 } # Resolve named column indices from a Cost Management query result function Get-SavingsColMap { param($Columns) - $map = @{ Cost = 0; ChargeType = -1; PricingModel = -1; SubscriptionId = -1 } + $map = @{ Cost = 0; ChargeType = -1; PricingModel = -1; SubscriptionId = -1; Currency = -1 } for ($c = 0; $c -lt $Columns.Count; $c++) { switch ($Columns[$c].name) { 'Cost' { $map.Cost = $c } 'ChargeType' { $map.ChargeType = $c } 'PricingModel' { $map.PricingModel = $c } 'SubscriptionId' { $map.SubscriptionId = $c } + 'Currency' { $map.Currency = $c } } } $map } + function Assert-SavingsCurrency { + param($Row, $Columns) + if ($Columns.Currency -lt 0) { throw 'Savings currency is missing; results are incomplete.' } + $currency = [string]$Row[$Columns.Currency] + if ($currency -notmatch '^[A-Za-z]{3}$' -or -not $monetaryCurrencies.Contains($currency)) { + throw 'Savings currency is missing or is not a recognized monetary currency; results are incomplete.' + } + $currency = $currency.ToUpperInvariant() + [void]$currencies.Add($currency) + if ($currencies.Count -gt 1) { throw 'Savings include multiple billing currencies. Scan each currency separately; no currency conversion is applied.' } + return $currency + } + # Parse an ActualCost result for UnusedReservation waste; returns detail rows function Read-SavingsActual { param($Result) @@ -107,6 +141,7 @@ function Get-SavingsRealized { if (-not $Result -or -not $Result.properties.rows) { return $rows } $m = Get-SavingsColMap -Columns $Result.properties.columns foreach ($row in $Result.properties.rows) { + $currency = Assert-SavingsCurrency -Row $row -Columns $m $charge = if ($m.ChargeType -ge 0) { [string]$row[$m.ChargeType] } else { '' } if ($charge -match 'UnusedReservation') { $sub = 'All (MG scope)' @@ -119,6 +154,8 @@ function Get-SavingsRealized { Category = 'Unused Reservation' Amount = [math]::Round([double]$row[$m.Cost], 2) Type = 'Waste' + Currency = $currency + Period = $period }) } } @@ -134,7 +171,11 @@ function Get-SavingsRealized { if ($Result -and $Result.properties.rows) { $m = Get-SavingsColMap -Columns $Result.properties.columns foreach ($row in $Result.properties.rows) { + $currency = Assert-SavingsCurrency -Row $row -Columns $m $pm = if ($m.PricingModel -ge 0) { [string]$row[$m.PricingModel] } else { '' } + if ([double]$row[$m.Cost] -lt 0) { + throw 'Savings usage costs include negative adjustments; a comparable estimate is unavailable.' + } $cost = [math]::Round([double]$row[$m.Cost], 2) $sub = 'All (MG scope)' if ($m.SubscriptionId -ge 0) { @@ -144,12 +185,12 @@ function Get-SavingsRealized { if ($pm -match 'Reservation') { $ri += $cost * $script:FinOpsRiSavingsFactor $committed += $cost - $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Reservation Benefit'; Amount = $cost; Type = 'Commitment' }) + $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Reservation Benefit'; Amount = $cost; Type = 'Commitment'; Currency = $currency; Period = $period }) } elseif ($pm -match 'SavingsPlan') { $sp += $cost * $script:FinOpsSpSavingsFactor $committed += $cost - $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Savings Plan Benefit'; Amount = $cost; Type = 'Commitment' }) + $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Savings Plan Benefit'; Amount = $cost; Type = 'Commitment'; Currency = $currency; Period = $period }) } elseif ($pm -match 'Spot') { $spot += $cost } elseif ($pm) { $onDemand += $cost } @@ -250,13 +291,12 @@ function Get-SavingsRealized { # -- Strategy 2: Per-subscription fallback (only if MG/direct scope unavailable) -- if ($hasCommitments -and -not $gotMgData) { $details.Clear() + $currencies.Clear() $riSavings = 0.0 $spSavings = 0.0 $committedAmort = 0.0 $onDemandAmort = 0.0 $spotAmort = 0.0 - # -- 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) { @@ -267,20 +307,7 @@ function Get-SavingsRealized { } } try { - # Get ActualCost MonthToDate - $actualBody = @{ - type = 'ActualCost' - timeframe = 'MonthToDate' - dataset = @{ - granularity = 'None' - aggregation = @{ - totalCost = @{ name = 'Cost'; function = 'Sum' } - } - grouping = @( - @{ type = 'Dimension'; name = 'ChargeType' } - ) - } - } | ConvertTo-Json -Depth 10 + $actualBody = New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('ChargeType') $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $actualBody @@ -290,39 +317,13 @@ function Get-SavingsRealized { if ($actualResp.StatusCode -eq 200) { $actualResult = Get-CostQueryResult -FirstResponse $actualResp -Payload $actualBody -Context "savings charges for $($sub.Name)" - 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' - }) - } - } + foreach ($detail in (Read-SavingsActual -Result $actualResult)) { + $detail.Subscription = $sub.Name + [void]$details.Add($detail) } } - # Get benefit usage via the reservation transactions or amortized view - $amortBody = @{ - type = 'AmortizedCost' - timeframe = 'MonthToDate' - dataset = @{ - granularity = 'None' - aggregation = @{ - totalCost = @{ name = 'Cost'; function = 'Sum' } - } - grouping = @( - @{ type = 'Dimension'; name = 'PricingModel' } - ) - } - } | ConvertTo-Json -Depth 10 + $amortBody = New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('PricingModel') $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $amortBody if (-not $amortResp -or $amortResp.StatusCode -ne 200) { @@ -330,38 +331,16 @@ function Get-SavingsRealized { } if ($amortResp.StatusCode -eq 200) { $amortResult = Get-CostQueryResult -FirstResponse $amortResp -Payload $amortBody -Context "savings benefits for $($sub.Name)" - 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 - # Same factor as the main path: savings is the gap up - # to PAYG, not a share of what was paid. - $riSavings += $cost * $script:FinOpsRiSavingsFactor - $committedAmort += $cost - [void]$details.Add([PSCustomObject]@{ - Subscription = $sub.Name - Category = 'Reservation Benefit' - Amount = $cost - Type = 'Commitment' - }) - } - elseif ($pricingModel -match 'SavingsPlan') { - $spSavings += $cost * $script:FinOpsSpSavingsFactor - $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 } - } + $parsed = Read-SavingsAmort -Result $amortResult + foreach ($detail in $parsed.Rows) { + $detail.Subscription = $sub.Name + [void]$details.Add($detail) } + $riSavings += $parsed.RI + $spSavings += $parsed.SP + $committedAmort += $parsed.Committed + $onDemandAmort += $parsed.OnDemand + $spotAmort += $parsed.Spot } } catch { @@ -370,7 +349,7 @@ function Get-SavingsRealized { } } # end per-sub fallback - # -- Step 2: AHB realized savings (per-SKU Windows license premium) --- + # -- Step 2: Separate 730-hour AHB estimate for the current VM inventory --- try { $ahbQuery = @" resources @@ -397,15 +376,19 @@ resources Category = 'Azure Hybrid Benefit (VMs)' Amount = [math]::Round($ahbSavings, 2) Type = 'AHB' + Currency = 'USD' + Period = '730-hour estimate for current VM inventory' }) } } catch { + $ahbSavings = $null + $ahbIssue = "AHB estimate is unavailable: $($_.Exception.Message)" Write-Warning " AHB savings query failed: $($_.Exception.Message)" } - $totalMonthly = [math]::Round($riSavings + $spSavings + $ahbSavings, 2) - $totalAnnual = [math]::Round($totalMonthly * 12, 2) + $currency = if ($currencies.Count -eq 1) { @($currencies)[0] } else { $null } + $commitmentSavings = if ($currency) { [math]::Round($riSavings + $spSavings, 2) } else { $null } # Commitment coverage = committed eligible spend / total eligible spend. # Eligible = everything except Spot (Spot cannot be covered by a commitment). @@ -416,18 +399,28 @@ resources else { $null } return [PSCustomObject]@{ - RISavingsMonthly = [math]::Round($riSavings, 2) - SPSavingsMonthly = [math]::Round($spSavings, 2) - AHBSavingsMonthly = [math]::Round($ahbSavings, 2) - TotalMonthly = $totalMonthly - TotalAnnual = $totalAnnual + RISavingsMonthToDate = if ($currency) { [math]::Round($riSavings, 2) } else { $null } + SPSavingsMonthToDate = if ($currency) { [math]::Round($spSavings, 2) } else { $null } + CommitmentSavingsMonthToDate = $commitmentSavings + Currency = $currency + Period = $period + CostPeriodStartUtc = $periodStartUtc + CostPeriodEndUtc = $periodEndUtc + RISavingsMonthly = $null + SPSavingsMonthly = $null + AHBSavingsMonthly = if ($null -ne $ahbSavings) { [math]::Round($ahbSavings, 2) } else { $null } + AHBCurrency = 'USD' + AHBPeriod = '730-hour estimate for current VM inventory' + AHBIssue = $ahbIssue + TotalMonthly = $null + TotalAnnual = $null 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) + EstimateBasis = "Commitment estimates cover usage charges for $period in the reported billing currency, using assumed $([int]($riDiscountRate * 100))% reservation and $([int]($spDiscountRate * 100))% savings plan discounts. Purchases, refunds, and unused commitment charges are excluded from that estimate. AHB is a separate USD estimate for 730 hours on the current VM inventory, using retail license premiums or a USD 50 per-VM fallback. These amounts are not combined or annualized. Monthly commitment and combined total fields are unavailable; use the month-to-date fields. Validate against matching pay-as-you-go rates and benefit usage before reporting realized savings." + HasData = ($null -ne $commitmentSavings -or $ahbSavings -gt 0 -or $details.Count -gt 0) } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index 3aaf00143..204dfa054 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -244,14 +244,14 @@ function Get-KpiComputedValue { return (New-KpiValue "Actual is $pctOfPlan% of planned ($cur $spend of $cur $plan, comparable budgets)" $variance) } 'effective-savings-rate' { - # Realized monthly savings from commitments + AHB (proxy: a true rate - # also needs total on-demand-equivalent spend, not in this scan). - $monthly = Get-ScanField $Data 'TotalMonthly' + $savings = Get-ScanField $Data 'CommitmentSavingsMonthToDate' $cur = Get-ScanField $Data 'Currency' - if (-not $cur) { $cur = 'USD' } - if ($null -ne $monthly -and [double]$monthly -gt 0) { - return (New-KpiValue "$cur $([math]::Round([double]$monthly, 2)) / month realized (proxy)" ([math]::Round([double]$monthly, 2))) + if (-not $cur) { return (New-KpiValue 'Unavailable: savings currency is unknown.') } + $period = Get-ScanField $Data 'Period' + if ($null -eq $savings -or -not $period -or [double]$savings -lt 0 -or [double]::IsNaN($savings) -or [double]::IsInfinity($savings)) { + return (New-KpiValue 'Unavailable: a valid commitment estimate and cost period are required.') } + return (New-KpiValue "$cur $([math]::Round([double]$savings, 2)) estimated savings ($period; assumed discounts, not a measured rate)" ([math]::Round([double]$savings, 2))) } 'pct-compute-covered-by-commitment' { # Commitment coverage = committed eligible spend / total eligible @@ -261,10 +261,9 @@ function Get-KpiComputedValue { $committed = Get-ScanField $Data 'CommittedAmortized' $onDemand = Get-ScanField $Data 'OnDemandAmortized' $cur = Get-ScanField $Data 'Currency' - if (-not $cur) { $cur = 'USD' } if ($null -ne $cov) { $detail = '' - if ($null -ne $committed -and $null -ne $onDemand) { + if ($cur -and $null -ne $committed -and $null -ne $onDemand) { $base = [double]$committed + [double]$onDemand $detail = " ($cur $([math]::Round([double]$committed, 0)) committed of $cur $([math]::Round($base, 0)) eligible)" } @@ -307,7 +306,14 @@ function Get-KpiComputedValue { # under every tag it lacks, so summing them double-counts. $seen = Get-ScanField $Data 'ResourceCostSeen' $unalloc = Get-ScanField $Data 'UnallocatedCost' - if ($seen -and [double]$seen -gt 0 -and $null -ne $unalloc) { + if ($null -ne $seen -and $null -ne $unalloc) { + if ([double]$seen -le 0 -or [double]::IsNaN($seen) -or [double]::IsInfinity($seen) -or + [double]::IsNaN($unalloc) -or [double]::IsInfinity($unalloc)) { + return (New-KpiValue 'Unavailable: allocation percentages require finite amounts and a positive net cost total.') + } + if ([double]$unalloc -lt 0 -or [double]$unalloc -gt [double]$seen) { + return (New-KpiValue 'Unavailable: credits or negative net costs prevent a comparable allocation percentage.') + } $pct = [math]::Round(100 * [double]$unalloc / [double]$seen, 1) switch ($KpiId) { 'pct-costs-untagged' { return (New-KpiValue "$pct% of resource spend carries no allocation tag" $pct) } @@ -333,8 +339,14 @@ function Get-KpiComputedValue { foreach ($tp in $tagPairs) { if ($allocTags -notcontains $tp.Name) { continue } # allocation tags only $rows = @($tp.Value) + if ($rows.Count -eq 0) { continue } $total = ($rows | Measure-Object -Property Cost -Sum).Sum - if (-not $total -or $total -le 0) { continue } + if ($null -eq $total -or $total -le 0 -or [double]::IsNaN($total) -or [double]::IsInfinity($total)) { + return (New-KpiValue 'Unavailable: allocation percentages require finite amounts and a positive net cost total.') + } + if (@($rows | Where-Object { [double]$_.Cost -lt 0 }).Count -gt 0) { + return (New-KpiValue 'Unavailable: credits or negative net costs prevent a comparable allocation percentage.') + } $untag = ($rows | Where-Object { $_.TagValue -eq '(untagged)' } | Measure-Object -Property Cost -Sum).Sum if ($null -eq $untag) { $untag = 0 } $pctUntag = [math]::Round(100 * $untag / $total, 1) diff --git a/src/powershell/Public/Start-FinOpsMultitool.ps1 b/src/powershell/Public/Start-FinOpsMultitool.ps1 index 2bdab0051..574e14a30 100644 --- a/src/powershell/Public/Start-FinOpsMultitool.ps1 +++ b/src/powershell/Public/Start-FinOpsMultitool.ps1 @@ -38,14 +38,16 @@ 'Orphaned Resources'. Use 'All' to select every scan. An unrecognized name is an error. .PARAMETER DataSource - Optional data source, which skips the data source prompt. Hub reads a deployed FinOps - hub, API queries Cost Management directly, and GraphOnly skips the cost scans. Hub - falls back to API when no hub is found in scope. + Optional data source, which skips the data source prompt. Hub reads a configured + Kusto endpoint or a discovered FinOps hub. API queries Cost Management directly, and + GraphOnly skips the cost scans. API and GraphOnly ignore FINOPS_HUB_KUSTO_URI and + don't preload hub data. An explicit Hub selection fails if no hub source is available. + Select API separately to run a live scan. .PARAMETER NonInteractive Runs without prompting, for automation and scheduled jobs. Every choice comes from the parameters or their defaults: all accessible subscriptions in the current tenant unless - SubscriptionId is set, a detected hub or the Cost Management API unless DataSource is + SubscriptionId is set, a configured or detected hub or the Cost Management API unless DataSource is set, and results are exported only when OutputPath is supplied. .EXAMPLE diff --git a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 index 5d0529a90..d52036bd0 100644 --- a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 +++ b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 @@ -704,8 +704,10 @@ param($Path, $Method, $Payload) if ($Scan -eq 'Savings') { $result = Get-SavingsRealized -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -WarningAction SilentlyContinue $result.CommittedAmortized | Should -Be (125 * $factor) - $result.RISavingsMonthly | Should -Be ([math]::Round(100 * $factor * 0.4 / 0.6, 2)) - $result.SPSavingsMonthly | Should -Be ([math]::Round(25 * $factor * 0.25 / 0.75, 2)) + $result.RISavingsMonthToDate | Should -Be ([math]::Round(100 * $factor * 0.4 / 0.6, 2)) + $result.SPSavingsMonthToDate | Should -Be ([math]::Round(25 * $factor * 0.25 / 0.75, 2)) + $result.Currency | Should -Be 'USD' + $result.TotalAnnual | Should -BeNullOrEmpty $waste = @($result.Details | Where-Object Type -EQ 'Waste') ($waste | Measure-Object -Property Amount -Sum).Sum | Should -Be (125 * $factor) $continuationCalls = if ($UseFallback) { 6 } else { 2 } diff --git a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 index fa7266f7c..86ee806f2 100644 --- a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 +++ b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 @@ -101,6 +101,585 @@ Describe 'FinOps Multitool safety' { } } + Context 'CSV export projections' { + BeforeAll { + $launcher = Join-Path $script:ModuleRoot 'Invoke-FinOpsMultitool.ps1' + $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile($launcher, [ref]$null, [ref]$null) + foreach ($definition in $launcherAst.FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -in @('Protect-FinOpsExportText', 'ConvertTo-FinOpsExportCell', 'ConvertTo-FinOpsExportRows') + }, $true)) { + . ([scriptblock]::Create($definition.Extent.Text)) + } + } + + It 'Uses invariant amounts and ISO dates under ' -ForEach @( + @{ Culture = 'en-US' } + @{ Culture = 'de-DE' } + ) { + $originalCulture = [System.Threading.Thread]::CurrentThread.CurrentCulture + try { + [System.Threading.Thread]::CurrentThread.CurrentCulture = [cultureinfo]::GetCultureInfo($Culture) + $data = @{ 'sub-a' = @{ + Actual = [decimal]100.25; Credit = -20.5; Currency = 'EUR'; Name = '-formula' + ActualPeriodStart = [datetime]::new(2026, 9, 1, 0, 0, 0, [DateTimeKind]::Utc) + CapturedAt = [datetimeoffset]::new(2026, 9, 2, 3, 4, 5, [timespan]::FromHours(2)) + } } + + $row = @(ConvertTo-FinOpsExportRows -Fn 'Get-CostData' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv)[0] + + $row.Actual | Should -Be '100.25' + $row.Credit | Should -Be '-20.5' + $row.ActualPeriodStart | Should -Be '2026-09-01T00:00:00.0000000Z' + $row.CapturedAt | Should -Be '2026-09-02T03:04:05.0000000+02:00' + $row.Name | Should -Be "'-formula" + $generic = @(ConvertTo-FinOpsExportRows -Fn 'Unknown' -Data @{ Credit = -20.5 } | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv)[0] + $generic.Value | Should -Be '-20.5' + } + finally { [System.Threading.Thread]::CurrentThread.CurrentCulture = $originalCulture } + } + + It 'Exports tag values, amounts, and currencies from scanner row objects' { + $data = [pscustomobject]@{ + CostByTag = @{ + CostCenter = @( + [pscustomobject]@{ TagValue = 'team-a'; Cost = 100.25; Currency = 'EUR' } + [pscustomobject]@{ TagValue = 'team-b'; Cost = -20; Currency = 'EUR' } + ) + } + TagsQueried = @('CostCenter') + NoTagsFound = $false + Source = 'Kusto' + ResourceCostSeen = 80.25 + } + + $rows = @(ConvertTo-FinOpsExportRows -Fn 'Get-CostByTag' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv) + + $rows.Count | Should -Be 3 + $tagRows = @($rows | Where-Object RecordType -EQ 'CostByTag') + $tagRows.TagValue | Should -Be @('team-a', 'team-b') + $tagRows.Cost | Should -Be @('100.25', '-20') + $tagRows.Currency | Should -Be @('EUR', 'EUR') + foreach ($row in $tagRows) { + $row.RecordType | Should -Be 'CostByTag' + $row.'Summary.Source' | Should -Be 'Kusto' + $row.'Summary.ResourceCostSeen' | Should -Be '80.25' + } + ($rows | Where-Object RecordType -EQ 'Summary.TagsQueried').Value | Should -Be 'CostCenter' + } + + It 'Retains metadata for dictionary-backed scan wrappers' { + $data = @{ + Reservations = @([pscustomobject]@{ ReservationId = 'ri-1'; AvgUtilization = 90 }) + SavingsPlans = @() + HasData = $true + Note = 'Validated billing scope' + } + + $row = @(ConvertTo-FinOpsExportRows -Fn 'Get-CommitmentUtilization' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv)[0] + + $row.ReservationId | Should -Be 'ri-1' + $row.'Summary.Note' | Should -Be 'Validated billing scope' + $row.'Summary.HasData' | Should -Be 'True' + $row.PSObject.Properties.Name | Should -Not -Contain 'Summary.Keys' + } + + It 'Retains tag diagnostics when no tag rows exist' { + $data = [pscustomobject]@{ CostByTag = @{}; NoTagsFound = $true; Source = 'Kusto'; Note = 'No tag keys in the selected cost data' } + + $row = @(ConvertTo-FinOpsExportRows -Fn 'Get-CostByTag' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv)[0] + + $row.RecordType | Should -Be 'Summary' + $row.'Summary.NoTagsFound' | Should -Be 'True' + $row.'Summary.Source' | Should -Be 'Kusto' + $row.'Summary.Note' | Should -Be $data.Note + } + + It 'Exports both commitment families and the underutilized view once' { + $reservation = [pscustomobject]@{ ReservationId = 'ri-1'; SkuName = 'Standard_D2s_v5'; AvgUtilization = 50 } + $data = [pscustomobject]@{ + Reservations = @($reservation) + SavingsPlans = @([pscustomobject]@{ BenefitId = 'sp-1'; BenefitOrderId = 'order-1'; AvgUtilization = 75 }) + UnderutilizedRIs = @($reservation) + RICount = 1 + SPCount = 1 + HasData = $true + } + + $rows = @(ConvertTo-FinOpsExportRows -Fn 'Get-CommitmentUtilization' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv) + + $rows.Count | Should -Be 3 + ($rows | Where-Object RecordType -EQ 'Reservations').ReservationId | Should -Be 'ri-1' + ($rows | Where-Object RecordType -EQ 'SavingsPlans').BenefitId | Should -Be 'sp-1' + ($rows | Where-Object RecordType -EQ 'SavingsPlans').BenefitOrderId | Should -Be 'order-1' + @($rows | Where-Object RecordType -EQ 'Summary.UnderutilizedRIs').Count | Should -Be 1 + $rows[0].PSObject.Properties.Name | Should -Not -Contain 'Summary.UnderutilizedRIs' + } + + It 'Keeps nested summary exports linear in collection size' { + $sizes = @() + foreach ($count in @(100, 200)) { + $reservations = @(foreach ($index in 1..$count) { + [pscustomobject]@{ ReservationId = "reservation-$index"; AvgUtilization = 50; SkuName = 'Standard_D2s_v5' } + }) + $data = [pscustomobject]@{ Reservations = $reservations; SavingsPlans = @(); UnderutilizedRIs = $reservations; RICount = $count; HasData = $true } + + $rows = @(ConvertTo-FinOpsExportRows -Fn 'Get-CommitmentUtilization' -Data $data) + $csv = ($rows | ConvertTo-Csv -NoTypeInformation) -join "`n" + + @($rows | Where-Object RecordType -EQ 'Reservations').Count | Should -Be $count + @($rows | Where-Object RecordType -EQ 'Summary.UnderutilizedRIs').Count | Should -Be $count + $rows[0].PSObject.Properties.Name | Should -Not -Contain 'Summary.UnderutilizedRIs' + $sizes += $csv.Length + } + $sizes[1] | Should -BeLessThan ($sizes[0] * 2.2) + } + + It 'Exports raw tag records and tag locations once as distinct views' { + $data = [pscustomobject]@{ + TagNames = @{ CostCenter = @{ TotalResources = 2; Values = @('team') } } + CaseVariants = @(); UntaggedResources = @(); TagCount = 1 + RawResults = @([pscustomobject]@{ tagName = 'CostCenter'; tagValue = 'team'; ResourceCount = 2 }) + TagLocations = @{ CostCenter = @('sub-a / rg-a', 'sub-b / rg-b') } + } + + $rows = @(ConvertTo-FinOpsExportRows -Fn 'Get-TagInventory' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv) + + @($rows | Where-Object RecordType -EQ 'TagNames').Count | Should -Be 1 + @($rows | Where-Object RecordType -EQ 'Summary.RawResults').Count | Should -Be 1 + ($rows | Where-Object RecordType -EQ 'Summary.TagLocations').Value | Should -Be @('sub-a / rg-a', 'sub-b / rg-b') + $rows[0].PSObject.Properties.Name | Should -Not -Contain 'Summary.RawResults' + } + + It 'Preserves all primary collections for ' -ForEach @( + @{ Scan = 'Get-AHBOpportunities'; Collections = @('WindowsVMs', 'SQLVMs', 'SQLDatabases') } + @{ Scan = 'Get-AIWorkloadMetrics'; Collections = @('ByModel', 'ByAccount') } + @{ Scan = 'Get-AnomalyAlerts'; Collections = @('TriggeredAlerts', 'ConfiguredRules') } + @{ Scan = 'Get-BillingStructure'; Collections = @('BillingAccounts', 'BillingProfiles', 'InvoiceSections', 'EADepartments', 'CostAllocationRules') } + @{ Scan = 'Get-CarbonMetrics'; Collections = @('MonthlyTrend', 'BySubscription') } + @{ Scan = 'Get-ReservationAdvice'; Collections = @('AdvisorRecommendations', 'ReservationRecommendations') } + ) { + $payload = [ordered]@{ HasData = $true; Note = 'Known scope only' } + foreach ($collection in $Collections) { $payload[$collection] = @([pscustomobject]@{ Id = $collection; Amount = 12.5 }) } + + $rows = @(ConvertTo-FinOpsExportRows -Fn $Scan -Data ([pscustomobject]$payload) | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv) + + $rows.Count | Should -Be $Collections.Count + $rows.RecordType | Should -Be $Collections + $rows.Id | Should -Be $Collections + foreach ($row in $rows) { $row.'Summary.Note' | Should -Be 'Known scope only' } + } + + It 'Keeps per-subscription monthly trends alongside aggregate months' { + $data = [pscustomobject]@{ + HasData = $true + Months = @([pscustomobject]@{ Month = 'Aug 2026'; Cost = 30; Currency = 'USD' }) + BySubscription = @{ + 'sub-a' = @([pscustomobject]@{ Month = 'Aug 2026'; Cost = 10; Currency = 'USD' }) + 'sub-b' = @([pscustomobject]@{ Month = 'Aug 2026'; Cost = 20; Currency = 'USD' }) + } + } + + $rows = @(ConvertTo-FinOpsExportRows -Fn 'Get-CostTrend' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv) + + $rows.Count | Should -Be 3 + ($rows | Where-Object SubscriptionId -EQ 'sub-a').Cost | Should -Be '10' + ($rows | Where-Object SubscriptionId -EQ 'sub-b').Cost | Should -Be '20' + ($rows | Where-Object RecordType -EQ 'Months').Cost | Should -Be '30' + } + + It 'Preserves nested values as JSON and retains zero-result diagnostics' { + $nested = @{ Owner = @{ Name = 'team'; Contacts = @('one@example.test', 'two@example.test') } } + $cell = ConvertTo-FinOpsExportCell $nested + ($cell | ConvertFrom-Json).Owner.Contacts.Count | Should -Be 2 + $cell | Should -Not -Match 'System\.Collections|System\.Object' + $data = [pscustomobject]@{ Reservations = @(); SavingsPlans = @(); HasData = $false; AccessDenied = $true; Note = 'Missing billing access' } + + $rows = @(ConvertTo-FinOpsExportRows -Fn 'Get-CommitmentUtilization' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv) + + $rows.Count | Should -Be 1 + $rows[0].RecordType | Should -Be 'Summary' + $rows[0].'Summary.AccessDenied' | Should -Be 'True' + $rows[0].'Summary.Note' | Should -Be 'Missing billing access' + } + + It 'Preserves cost source and period while protecting formula text' { + $data = @{ 'sub-a' = @{ Actual = -25; Forecast = $null; Currency = 'EUR'; ActualPeriod = '2026-08'; ForecastSource = 'Unavailable'; Name = '=1+1' } } + + $row = @(ConvertTo-FinOpsExportRows -Fn 'Get-CostData' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv)[0] + + $row.Actual | Should -Be '-25' + $row.ActualPeriod | Should -Be '2026-08' + $row.ForecastSource | Should -Be 'Unavailable' + $row.Forecast | Should -Be '' + $row.Name | Should -Be "'=1+1" + } + } + + Context 'Tag cost presentation' { + It 'Uses aggregate tag rows for guidance when ' -ForEach @( + @{ Case = 'cost is untagged'; Tagged = 100.0; Untagged = 20.0; Expected = 'Some untagged spend'; HasRows = $true } + @{ Case = 'an untagged credit exists'; Tagged = 125.0; Untagged = -5.0; Expected = 'credits|negative'; HasRows = $true } + @{ Case = 'tagged costs include a credit'; Tagged = -5.0; Untagged = 125.0; Expected = 'credits|negative'; HasRows = $true } + @{ Case = 'net cost is zero'; Tagged = 0.0; Untagged = 0.0; Expected = 'no positive net cost'; HasRows = $true } + @{ Case = 'no rows are available'; Tagged = 0.0; Untagged = 0.0; Expected = 'No cost data was returned'; HasRows = $false } + ) { + InModuleScope FinOpsMultitool -Parameters @{ ModuleRoot = $script:ModuleRoot; Tagged = $Tagged; Untagged = $Untagged; Expected = $Expected; HasRows = $HasRows } { + param($ModuleRoot, $Tagged, $Untagged, $Expected, $HasRows) + $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $switches = $launcherAst.FindAll({ $args[0] -is [System.Management.Automation.Language.SwitchStatementAst] }, $true) + $branch = @($switches.Clauses | Where-Object { + $_.Item1.Value -eq 'Get-CostByTag' -and $_.Item2.Extent.Text.Contains('No cost data was returned') + }) + $branch.Count | Should -Be 1 + $data = [pscustomobject]@{ + CostByTag = @{ CostCenter = @(if ($HasRows) { + [pscustomobject]@{ TagValue = 'team'; Cost = $Tagged; Currency = 'USD' } + [pscustomobject]@{ TagValue = '(untagged)'; Cost = $Untagged; Currency = 'USD' } + }) } + } + $guidanceItems = @() + $body = ($branch[0].Item2.Statements | ForEach-Object { $_.Extent.Text }) -join "`n" + . ([scriptblock]::Create("param(`$data)`n$body")) $data + + ($guidanceItems.Message -join ' ') | Should -Match $Expected + $guidanceItems.Severity | Should -Not -Contain 'Green' + if ($HasRows) { ($guidanceItems.Message -join ' ') | Should -Not -Match 'No cost data was returned|No CAF allocation tag' } + } + } + + It 'Does not score invalid allocation percentages for ' -ForEach @( + @{ Case = 'negative aggregate untagged cost'; ResourceTotals = $false; Tagged = 125.0; Untagged = -5.0 } + @{ Case = 'aggregate untagged cost above the total'; ResourceTotals = $false; Tagged = -5.0; Untagged = 125.0 } + @{ Case = 'zero aggregate cost'; ResourceTotals = $false; Tagged = 0.0; Untagged = 0.0 } + @{ Case = 'negative per-resource unallocated cost'; ResourceTotals = $true; Tagged = 125.0; Untagged = -5.0 } + @{ Case = 'per-resource unallocated cost above the total'; ResourceTotals = $true; Tagged = -5.0; Untagged = 125.0 } + @{ Case = 'zero per-resource cost'; ResourceTotals = $true; Tagged = 0.0; Untagged = 0.0 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ ResourceTotals = $ResourceTotals; Tagged = $Tagged; Untagged = $Untagged } { + param($ResourceTotals, $Tagged, $Untagged) + $data = if ($ResourceTotals) { + [pscustomobject]@{ ResourceCostSeen = $Tagged + $Untagged; UnallocatedCost = $Untagged } + } + else { + [pscustomobject]@{ CostByTag = @{ CostCenter = @( + [pscustomobject]@{ TagValue = 'team'; Cost = $Tagged } + [pscustomobject]@{ TagValue = '(untagged)'; Cost = $Untagged } + ) } } + } + $result = Add-KpiInsights -Result @{ tool = 'scan_cost_by_tag'; data = $data } + foreach ($insight in $result.kpiInsights | Where-Object kpiId -In @('pct-costs-untagged', 'pct-costs-unallocated', 'tagging-policy-compliant')) { + $insight.status | Should -Be 'unavailable' + $insight.numericValue | Should -BeNullOrEmpty + $insight.yourValue | Should -Match 'Unavailable' + } + } + } + + It 'Keeps valid allocation percentages for ' -ForEach @( + @{ Case = 'aggregate rows'; ResourceTotals = $false } + @{ Case = 'resource totals'; ResourceTotals = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ ResourceTotals = $ResourceTotals } { + param($ResourceTotals) + $data = if ($ResourceTotals) { + [pscustomobject]@{ ResourceCostSeen = 100.0; UnallocatedCost = 20.0 } + } + else { + [pscustomobject]@{ CostByTag = @{ CostCenter = @( + [pscustomobject]@{ TagValue = 'team'; Cost = 80.0 } + [pscustomobject]@{ TagValue = '(untagged)'; Cost = 20.0 } + ) } } + } + (Get-KpiComputedValue -KpiId 'pct-costs-untagged' -Data $data).Value | Should -Be 20 + (Get-KpiComputedValue -KpiId 'tagging-policy-compliant' -Data $data).Value | Should -Be 80 + } + } + } + + Context 'Savings estimate contract' { + BeforeEach { + Mock Write-Host -ModuleName FinOpsMultitool { } + Mock Get-Date -ModuleName FinOpsMultitool { [datetime]::new(2026, 9, 16, 12, 0, 0, [DateTimeKind]::Utc) } + Mock Resolve-CostMgId -ModuleName FinOpsMultitool { $null } + Mock Search-AzGraphSafe -ModuleName FinOpsMultitool { + @{ Data = @([pscustomobject]@{ vmSize = 'Standard_D2s_v5'; location = 'eastus' }) } + } + Mock Get-AhbVmRates -ModuleName FinOpsMultitool { [pscustomobject]@{ HourlyPremium = 0.1 } } + Mock Invoke-RestMethod -ModuleName FinOpsMultitool { throw 'Savings tests must not access the network.' } + } + + It 'Separates month-to-date commitments from the USD AHB run rate' -ForEach @( + @{ BillingCurrency = 'EUR' } + @{ BillingCurrency = 'USD' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ BillingCurrency = $BillingCurrency } { + param($BillingCurrency) + $fixtureCurrency = $BillingCurrency + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + $dimension = if ($request.type -eq 'ActualCost') { 'ChargeType' } else { 'PricingModel' } + $category = if ($request.type -eq 'ActualCost') { 'UnusedReservation' } else { 'Reservation' } + $properties = @{ + columns = @(@{ name = 'Currency' }, @{ name = $dimension }, @{ name = 'Cost' }) + rows = @(, @($fixtureCurrency, $category, 100.0)) + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result = Get-SavingsRealized -Subscriptions $subscriptions + + $result.Currency | Should -Be $fixtureCurrency + $result.RISavingsMonthToDate | Should -Be 66.67 + $result.CommitmentSavingsMonthToDate | Should -Be 66.67 + $result.AHBSavingsMonthly | Should -Be 73 + $result.AHBCurrency | Should -Be 'USD' + $result.AHBPeriod | Should -Match '730' + $result.TotalMonthly | Should -BeNullOrEmpty + $result.TotalAnnual | Should -BeNullOrEmpty + $result.RISavingsMonthly | Should -BeNullOrEmpty + $result.Period | Should -Be '2026-09-01T00:00:00Z to 2026-09-16T12:00:00Z' + @($result.Details | Where-Object Type -NE 'AHB').Currency | Select-Object -Unique | Should -Be $fixtureCurrency + ($result.Details | Where-Object Type -EQ 'AHB').Currency | Should -Be 'USD' + Should -Invoke Invoke-AzRestMethodWithRetry -Times 2 -Exactly -ParameterFilter { + $request = $Payload | ConvertFrom-Json + $request.timeframe -eq 'Custom' -and + ([datetime]$request.timePeriod.from).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -eq '2026-09-01T00:00:00Z' -and + ([datetime]$request.timePeriod.to).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -eq '2026-09-16T12:00:00Z' + } + $kpi = Get-KpiComputedValue -KpiId 'effective-savings-rate' -Data $result + $kpi.Display | Should -Match "$fixtureCurrency 66.67" + $kpi.Display | Should -Not -Match '/ month|annual' + } + } + + It 'Rejects rather than guessing or combining currencies' -ForEach @( + @{ Case = 'missing currency'; First = ''; Second = ''; IncludeColumn = $true } + @{ Case = 'missing currency column'; First = 'EUR'; Second = 'EUR'; IncludeColumn = $false } + @{ Case = 'mixed billing currencies'; First = 'EUR'; Second = 'USD'; IncludeColumn = $true } + @{ Case = 'no-currency code'; First = 'XXX'; Second = 'XXX'; IncludeColumn = $true } + @{ Case = 'test currency code'; First = 'XTS'; Second = 'XTS'; IncludeColumn = $true } + @{ Case = 'unsupported currency code'; First = 'ABC'; Second = 'ABC'; IncludeColumn = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ First = $First; Second = $Second; IncludeColumn = $IncludeColumn } { + param($First, $Second, $IncludeColumn) + $firstCurrency = $First + $secondCurrency = $Second + $hasCurrencyColumn = $IncludeColumn + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + $dimension = if ($request.type -eq 'ActualCost') { 'ChargeType' } else { 'PricingModel' } + $category = if ($request.type -eq 'ActualCost') { 'UnusedReservation' } else { 'Reservation' } + $currency = if ($Path -like '/subscriptions/11111111-*') { $firstCurrency } else { $secondCurrency } + $properties = @{ columns = @(@{ name = $dimension }, @{ name = 'Cost' }); rows = @(, @($category, 100.0)) } + if ($hasCurrencyColumn) { + $properties.columns += @{ name = 'Currency' } + $properties.rows = @(, @($category, 100.0, $currency)) + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'First' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Second' } + ) + + { Get-SavingsRealized -Subscriptions $subscriptions } | Should -Throw '*currenc*' + } + } + + It 'Excludes purchases, refunds, and unused commitments before aggregation' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + if ($request.type -eq 'ActualCost') { + $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'ChargeType' }, @{ name = 'Currency' }); rows = @() } + } + else { + $charges = @( + @{ ChargeType = 'Usage'; Cost = 100.0 } + @{ ChargeType = 'Refund'; Cost = -90.0 } + @{ ChargeType = 'Purchase'; Cost = 1000.0 } + @{ ChargeType = 'UnusedReservation'; Cost = 30.0 } + ) + $filter = $request.dataset.filter.dimensions + if ($filter.name -eq 'ChargeType' -and $filter.operator -eq 'In') { + $charges = @($charges | Where-Object { $_.ChargeType -in $filter.values }) + } + $amount = ($charges | Measure-Object Cost -Sum).Sum + $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'PricingModel' }, @{ name = 'Currency' }); rows = @(, @($amount, 'Reservation', 'EUR')) } + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + + $result = Get-SavingsRealized -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result.CommitmentSavingsMonthToDate | Should -Be 66.67 + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { + $request = $Payload | ConvertFrom-Json + $request.type -eq 'AmortizedCost' -and $request.dataset.filter.dimensions.name -eq 'ChargeType' -and + $request.dataset.filter.dimensions.operator -eq 'In' -and (@($request.dataset.filter.dimensions.values) -join ',') -eq 'Usage' + } + } + } + + It 'Rejects negative usage adjustments of without partial savings' -ForEach @( + @{ Amount = -100.0 } + @{ Amount = -0.001 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Adjustment = $Amount } { + param($Adjustment) + $fixtureAdjustment = $Adjustment + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + $dimension = if ($request.type -eq 'ActualCost') { 'ChargeType' } else { 'PricingModel' } + $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = $dimension }, @{ name = 'Currency' }); rows = @() } + if ($request.type -eq 'AmortizedCost') { $properties.rows = @(, @($fixtureAdjustment, 'Reservation', 'USD')) } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $received = [System.Collections.Generic.List[object]]::new() + + { Get-SavingsRealized -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) | + ForEach-Object { $received.Add($_) } } | Should -Throw '*negative adjustments*' + + $received.Count | Should -Be 0 + } + } + + It 'Rejects a currency change on a later page without emitting partial savings' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { + $isNext = $Path -like '*page=2' + $currency = if ($isNext) { 'USD' } else { 'EUR' } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'ChargeType' }, @{ name = 'Currency' }) + rows = @(, @(100.0, 'UnusedReservation', $currency)) + } + if (-not $isNext) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $received = [System.Collections.Generic.List[object]]::new() + + { Get-SavingsRealized -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) | + ForEach-Object { $received.Add($_) } } | Should -Throw '*multiple billing currencies*' + + $received.Count | Should -Be 0 + } + } + + It 'Discards a failed management-group attempt including its currency' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { 'test-management-group' } + Mock Search-AzGraphSafe { @{ Data = @() } } + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + $isManagementGroup = $Path -like '/providers/Microsoft.Management/*' + if ($isManagementGroup -and $request.type -eq 'AmortizedCost') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $currency = if ($isManagementGroup) { 'GBP' } else { 'EUR' } + $dimension = if ($request.type -eq 'ActualCost') { 'ChargeType' } else { 'PricingModel' } + $category = if ($request.type -eq 'ActualCost') { 'UnusedReservation' } else { 'Reservation' } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ + columns = @(@{ name = $dimension }, @{ name = 'Currency' }, @{ name = 'Cost' }) + rows = @(, @($category, $currency, 100.0)) + } } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'First' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Second' } + ) + + $result = Get-SavingsRealized -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -WarningAction SilentlyContinue + + $result.Currency | Should -Be 'EUR' + $result.CommitmentSavingsMonthToDate | Should -Be 133.33 + $result.Details.Currency | Select-Object -Unique | Should -Be 'EUR' + @($result.Details | Where-Object Type -EQ 'Waste').Count | Should -Be 2 + } + } + + It 'Retains an AHB read failure without inventing zero or a commitment currency' { + InModuleScope FinOpsMultitool { + Mock Search-AzGraphSafe { throw '403: inventory unavailable' } + Mock Invoke-AzRestMethodWithRetry { throw 'A confirmed empty commitment inventory should skip cost queries.' } + + $result = Get-SavingsRealized -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) -CommitmentData ([pscustomobject]@{ HasData = $false }) -WarningAction SilentlyContinue + + $result.AHBSavingsMonthly | Should -BeNullOrEmpty + $result.AHBIssue | Should -Match '403' + $result.Currency | Should -BeNullOrEmpty + $result.CommitmentSavingsMonthToDate | Should -BeNullOrEmpty + $result.HasData | Should -BeFalse + } + } + + It 'Does not substitute USD for an unknown savings currency in the KPI' { + InModuleScope FinOpsMultitool { + $data = [pscustomobject]@{ CommitmentSavingsMonthToDate = 66.67; Period = 'Month to date'; TotalMonthly = 66.67 } + + $result = Get-KpiComputedValue -KpiId 'effective-savings-rate' -Data $data + + $result.Value | Should -BeNullOrEmpty + $result.Display | Should -Match 'Unavailable.*currency' + } + } + } + + Context 'Savings estimate presentation' { + It 'Labels terminal, guidance, HTML, and KPI output as estimates' { + InModuleScope FinOpsMultitool -Parameters @{ ModuleRoot = $script:ModuleRoot } { + param($ModuleRoot) + $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $formatter = $launcherAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Write-ColorizedLine' }, $true) + . ([scriptblock]::Create($formatter.Extent.Text)) + $captured = [System.Collections.Generic.List[string]]::new() + Mock Write-Host { [void]$captured.Add([string]$Object) } + Mock Write-ColorizedLine { [void]$captured.Add($Text) } + Mock Get-Date { [datetime]::new(2026, 9, 16, 12, 0, 0, [DateTimeKind]::Utc) } + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + $dimension = if ($request.type -eq 'ActualCost') { 'ChargeType' } else { 'PricingModel' } + $category = if ($request.type -eq 'ActualCost') { 'Usage' } else { 'Reservation' } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = $dimension }, @{ name = 'Currency' }) + rows = @(, @(100.0, $category, 'EUR')) + } } | ConvertTo-Json -Depth 8) } + } + Mock Search-AzGraphSafe { @{ Data = @([pscustomobject]@{ vmSize = 'Standard_D2s_v5'; location = 'eastus' }) } } + Mock Get-AhbVmRates { [pscustomobject]@{ HourlyPremium = 0.1 } } + $data = Get-SavingsRealized -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + $htmlSb = [System.Text.StringBuilder]::new() + $guidanceItems = @() + $tableNote = $null + $switches = $launcherAst.FindAll({ $args[0] -is [System.Management.Automation.Language.SwitchStatementAst] }, $true) + $branches = @($switches.Clauses | Where-Object { $_.Item1.Value -eq 'Get-SavingsRealized' }) + $branches.Count | Should -Be 3 + foreach ($branch in $branches) { + $body = ($branch.Item2.Statements | ForEach-Object { $_.Extent.Text }) -join "`n" + . ([scriptblock]::Create("param(`$data, `$htmlSb)`n$body")) $data $htmlSb + } + + ($captured -join ' ') | Should -Match 'Estimated savings' + ($captured -join ' ') | Should -Match 'EUR 66.67' + ($captured -join ' ') | Should -Match 'USD 73.00' + ($captured -join ' ') | Should -Not -Match 'Total monthly:|Annual:' + ($guidanceItems.Message -join ' ') | Should -Match 'Estimated savings' + ($guidanceItems.Message -join ' ') | Should -Not -Match 'Realizing|Run-level' + $htmlSb.ToString() | Should -Match 'Estimated commitment savings' + $htmlSb.ToString() | Should -Match 'EUR 66.67' + $htmlSb.ToString() | Should -Match 'USD 73.00' + $htmlSb.ToString() | Should -Match '730-hour' + $tableNote | Should -Be $data.EstimateBasis + $kpi = Get-KpiComputedValue -KpiId 'effective-savings-rate' -Data $data + $kpi.Display | Should -Match 'estimated savings' + $kpi.Display | Should -Not -Match 'realized' + $catalog = Get-Content -LiteralPath (Join-Path $ModuleRoot 'kpi/kpi-catalog.json') -Raw | ConvertFrom-Json + $definition = $catalog.kpis | Where-Object id -EQ 'effective-savings-rate' + $definition.unit | Should -Be 'currency/period' + $definition.plainLanguage | Should -Match 'estimates' + } + } + } + Context 'Export amounts parse invariantly' { It 'Reads a decimal point as a decimal point regardless of culture' { diff --git a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 index 974e75e54..0f4c66ccd 100644 --- a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 +++ b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 @@ -62,6 +62,269 @@ InModuleScope 'FinOpsToolkit' { } } + Context 'Successful public launch' { + BeforeEach { + $fixtureRoot = Join-Path $TestDrive 'launcher' + [void](New-Item -ItemType Directory -Path $fixtureRoot -Force) + $fixtureLauncher = Join-Path $fixtureRoot 'Invoke-FinOpsMultitool.ps1' + @' +function Invoke-FinOpsMultitool { + [CmdletBinding()] + param( + [string]$SubscriptionId, + [string]$OutputPath, + [string[]]$Scans, + [string]$DataSource, + [switch]$NonInteractive + ) + [pscustomobject]@{ + SubscriptionId = $SubscriptionId + OutputPath = $OutputPath + Scans = $Scans + DataSource = $DataSource + NonInteractive = $NonInteractive.IsPresent + BoundParameters = @($PSBoundParameters.Keys) + } +} +'@ | Set-Content -LiteralPath $fixtureLauncher -Encoding utf8 + Mock Join-Path { [System.IO.Path]::Combine([string]$Path, [string]$ChildPath) } + Mock Join-Path { $fixtureRoot } -ParameterFilter { $ChildPath -eq '../Private/FinOpsMultitool' } + } + + It 'Forwards the complete public call to the launcher for ' -ForEach @( + @{ Source = 'API' } + @{ Source = 'Hub' } + @{ Source = 'GraphOnly' } + ) { + $outputDirectory = Join-Path $TestDrive 'reports with spaces' + $scans = @('Get-CostData', 'Get-ResourceCosts') + + $result = Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -OutputPath $outputDirectory -Scans $scans -DataSource $Source -NonInteractive + + $result.SubscriptionId | Should -Be '11111111-1111-1111-1111-111111111111' + $result.OutputPath | Should -Be $outputDirectory + $result.Scans | Should -Be $scans + $result.DataSource | Should -Be $Source + $result.NonInteractive | Should -BeTrue + $result.BoundParameters.Count | Should -Be 5 + } + + It 'Leaves omitted choices to the launcher defaults' { + $result = Start-FinOpsMultitool + + $result.BoundParameters.Count | Should -Be 0 + $result.SubscriptionId | Should -BeNullOrEmpty + $result.Scans | Should -BeNullOrEmpty + $result.NonInteractive | Should -BeFalse + } + + It 'Preserves an explicitly disabled NonInteractive switch' { + $result = Start-FinOpsMultitool -NonInteractive:$false + + $result.NonInteractive | Should -BeFalse + $result.BoundParameters | Should -Contain 'NonInteractive' + } + } + + Context 'Public source smoke tests' { + BeforeAll { + $script:RealMultitoolRoot = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool' + Import-Module (Join-Path $script:RealMultitoolRoot 'FinOpsMultitool.psm1') -Force -Global + } + + BeforeEach { + $script:PreviousHubUri = $env:FINOPS_HUB_KUSTO_URI + $script:PreviousHubDatabase = $env:FINOPS_HUB_KUSTO_DB + $script:PreviousFinOpsResults = Get-Variable -Name FinOpsResults -Scope Global -ErrorAction SilentlyContinue + Mock Import-Module { } + Mock Get-Module { [pscustomobject]@{ Name = $Name } } + Mock Clear-Host { } + Mock Write-Host { } + Mock Read-Host { throw 'Noninteractive launch must not prompt.' } + Mock Connect-AzAccount { throw 'An existing test context must not trigger sign-in.' } + Mock Get-AzTenant { throw 'Explicit subscription scope must not enumerate tenants.' } + Mock Get-AzContext { + [pscustomobject]@{ Account = @{ Id = 'test@example.test' }; Tenant = @{ Id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } } + } + Mock Get-AzSubscription { + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Test subscription'; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; State = 'Enabled' } + } + Mock Set-AzContext { } + Mock Search-AzGraph { @() } + Mock Resolve-CostMgId -ModuleName FinOpsMultitool { $null } + Mock Get-PlainAccessToken -ModuleName FinOpsMultitool { 'test-token' } + Mock Invoke-RestMethod -ModuleName FinOpsMultitool { throw 'Unexpected external HTTP request.' } + Mock Invoke-WebRequest -ModuleName FinOpsMultitool { throw 'Unexpected external HTTP request.' } + Mock Read-FinOpsHubData { throw 'Kusto smoke tests must not fall back to storage.' } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -notlike '/subscriptions/11111111-1111-1111-1111-111111111111/*') { throw 'Unexpected query scope.' } + $amount = if ($Path -like '*forecast*') { 150.0 } else { 100.0 } + $content = @{ properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'Currency' }); rows = @(, @($amount, 'USD')) } } | ConvertTo-Json -Depth 8 + [pscustomobject]@{ StatusCode = 200; Content = $content } + } + Mock Invoke-FOHubKustoQuery -ModuleName FinOpsMultitool { + $validation = [pscustomobject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 1; _SourceRows = 1; _MissingSubscriptions = 0 } + $rows = if ($Query.Contains('ResourcePath = ResourceId')) { + @([pscustomobject]@{ Actual = 100.0; Currency = 'USD'; Subscription = 'Test subscription'; ResourcePath = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/test'; ResourceType = 'microsoft.compute/disks'; ResourceGroup = 'test' }) + } + elseif ($Query.Contains("TagKey = '*TOTAL*'")) { + @([pscustomobject]@{ Cost = 100.0; Currency = 'USD'; TagKey = '*TOTAL*'; TagValue = '*TOTAL*' }) + } + else { + @([pscustomobject]@{ _sub = '11111111-1111-1111-1111-111111111111'; Name = 'Test subscription'; Actual = 100.0; Currency = 'USD'; ActualPeriodStart = '2026-09-01'; ActualPeriodEnd = '2026-09-16' }) + } + @{ Ok = $true; Rows = @($validation) + $rows; Error = $null } + } + } + + AfterEach { + $env:FINOPS_HUB_KUSTO_URI = $script:PreviousHubUri + $env:FINOPS_HUB_KUSTO_DB = $script:PreviousHubDatabase + if ($script:PreviousFinOpsResults) { + Set-Variable -Name FinOpsResults -Scope Global -Value $script:PreviousFinOpsResults.Value + } + else { Remove-Variable -Name FinOpsResults -Scope Global -ErrorAction SilentlyContinue } + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + It 'Runs the real public launcher and exports reports for ' -ForEach @( + @{ Mode = 'API'; Source = 'API'; HubUri = $null; NeedsToken = $false } + @{ Mode = 'ApiWithKustoOverride'; Source = 'API'; HubUri = 'http://localhost:8082'; NeedsToken = $false } + @{ Mode = 'OnlineHub'; Source = 'Hub'; HubUri = 'https://test.eastus.kusto.windows.net'; NeedsToken = $true } + @{ Mode = 'LocalHub'; Source = 'Hub'; HubUri = 'http://localhost:8082'; NeedsToken = $false } + ) { + $env:FINOPS_HUB_KUSTO_URI = $HubUri + $env:FINOPS_HUB_KUSTO_DB = 'Hub' + $reportPath = Join-Path $TestDrive $Mode + + Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-CostData -DataSource $Source -OutputPath $reportPath -NonInteractive -ErrorAction Stop + + $result = Get-Variable -Name FinOpsResults -Scope Global -ValueOnly + $result.ContainsKey('_error_Get-CostData') | Should -BeFalse + $result['Get-CostData']['11111111-1111-1111-1111-111111111111'].Actual | Should -Be 100 + Test-Path (Join-Path $reportPath 'FinOpsReport.html') | Should -BeTrue + Get-Content (Join-Path $reportPath 'FinOpsReport.html') -Raw | Should -Match 'Scans Run
1
' + Get-Content (Join-Path $reportPath 'ScanSummary.txt') -Raw | Should -Not -Match 'ERROR:' + $csvFiles = @(Get-ChildItem -LiteralPath $reportPath -Filter '*.csv') + $csvFiles.Count | Should -Be 1 + $rows = @(Import-Csv -LiteralPath $csvFiles[0].FullName) + $rows.Count | Should -Be 1 + $rows[0].SubscriptionId | Should -Be '11111111-1111-1111-1111-111111111111' + $rows[0].Actual | Should -Be '100' + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -match 'RUNNING 1 SCANS' } + Should -Invoke Read-Host -Times 0 -Exactly + Should -Invoke Connect-AzAccount -Times 0 -Exactly + Should -Invoke Get-AzTenant -Times 0 -Exactly + Should -Invoke Get-AzSubscription -Times 1 -Exactly -ParameterFilter { + $SubscriptionId -eq '11111111-1111-1111-1111-111111111111' -and $TenantId -eq 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + } + if ($Source -eq 'API') { + $rows[0].Forecast | Should -Be '150' + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 2 -Exactly + Should -Invoke Invoke-FOHubKustoQuery -ModuleName FinOpsMultitool -Times 0 -Exactly + } + else { + $rows[0].ForecastSource | Should -Be 'Unavailable' + Get-Content (Join-Path $reportPath 'FinOpsReport.html') -Raw | Should -Match ([regex]::Escape("Cost data: FinOps Hub ($HubUri, Hub)")) + Should -Invoke Search-AzGraph -Times 0 -Exactly + Should -Invoke Invoke-FOHubKustoQuery -ModuleName FinOpsMultitool -Times 3 -Exactly -ParameterFilter { + $ClusterUri -eq $HubUri -and $Database -eq 'Hub' -and $Query.Contains('11111111-1111-1111-1111-111111111111') + } + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + } + $tokenCalls = if ($NeedsToken) { 3 } else { 0 } + Should -Invoke Get-PlainAccessToken -ModuleName FinOpsMultitool -Times $tokenCalls -Exactly + Should -Invoke Read-FinOpsHubData -Times 0 -Exactly + } + + It 'Preserves the interactive Kusto choice without rediscovering the provider' { + $env:FINOPS_HUB_KUSTO_URI = $null + Set-Variable -Name NonInteractive -Value $false -Scope Local + $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:RealMultitoolRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + foreach ($definition in $launcherAst.FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -in @('Select-DataSource', 'Read-FinOpsAnswer', 'Invoke-SelectedScans', 'Write-SectionHeader') + }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } + Mock Read-FinOpsAnswer { '1' } + Mock Search-AzGraph { [pscustomobject]@{ name = 'test-hub-storage'; resourceGroup = 'test-hub' } } + Mock Resolve-FOHubProvider { + @{ Found = $true; Mode = 'Kusto'; ClusterUri = 'https://test.eastus.kusto.windows.net'; Database = 'Hub'; UseAuth = $true } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Test subscription' }) + + $choice = Select-DataSource -Subscriptions $subscriptions + $choice.HubProvider.ClusterUri | Should -Be 'https://test.eastus.kusto.windows.net' + $result = Invoke-SelectedScans -Modules @(@{ Name = 'Cost Data'; Fn = 'Get-CostData'; Selected = $true }) -Subscriptions $subscriptions -DataSource $choice + + $result['Get-CostData'][$subscriptions[0].Id].Actual | Should -Be 100 + $choice.HubProvider.Database | Should -Be 'Hub' + Should -Invoke Resolve-FOHubProvider -Times 1 -Exactly + Should -Invoke Read-FinOpsHubData -Times 0 -Exactly + } + + It 'Does not query Hub costs for a Graph-only run with a Kusto override' { + $env:FINOPS_HUB_KUSTO_URI = 'http://localhost:8082' + Mock Get-TagInventory { [pscustomobject]@{ TagNames = @{}; TotalResources = 0; TaggedCount = 0; UntaggedCount = 0; TagCoverage = 0 } } + + Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-TagInventory -DataSource GraphOnly -NonInteractive -ErrorAction Stop + + Should -Invoke Get-TagInventory -Times 1 -Exactly + Should -Invoke Invoke-FOHubKustoQuery -ModuleName FinOpsMultitool -Times 0 -Exactly + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + Should -Invoke Get-PlainAccessToken -ModuleName FinOpsMultitool -Times 0 -Exactly + Should -Invoke Read-FinOpsHubData -Times 0 -Exactly + } + + It 'Rejects an unavailable explicit Hub rather than silently selecting API' { + $env:FINOPS_HUB_KUSTO_URI = $null + + { Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-CostData -DataSource Hub -NonInteractive -ErrorAction Stop } | + Should -Throw '*No FinOps hub*' + + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + } + + It 'Keeps selected-source failure details for ' -ForEach @( + @{ Mode = 'ApiDenied'; Source = 'API'; HubUri = $null; ExpectedRole = 'Cost Management Reader' } + @{ Mode = 'StorageHubDenied'; Source = 'Hub'; HubUri = $null; ExpectedRole = 'Storage Blob Data Reader' } + @{ Mode = 'KustoHubDenied'; Source = 'Hub'; HubUri = 'https://test.eastus.kusto.windows.net'; ExpectedRole = 'Database Viewer' } + ) { + $env:FINOPS_HUB_KUSTO_URI = $HubUri + $env:FINOPS_HUB_KUSTO_DB = 'Hub' + $reportPath = Join-Path $TestDrive $Mode + Mock Search-AzGraph { + [pscustomobject]@{ name = 'test-hub-storage'; resourceGroup = 'test-hub'; subscriptionId = '11111111-1111-1111-1111-111111111111' } + } + if (-not $HubUri) { + Mock Resolve-FOHubProvider { @{ Found = $false } } + } + Mock Read-FinOpsHubData { throw '403 Forbidden: storage fixture.' } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw '403 Forbidden: API fixture.' } + Mock Invoke-FOHubKustoQuery -ModuleName FinOpsMultitool { @{ Ok = $false; Rows = @(); Error = '403 Forbidden: Kusto fixture.' } } + + Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-CostData -DataSource $Source -OutputPath $reportPath -NonInteractive -ErrorAction Stop + + $result = Get-Variable -Name FinOpsResults -Scope Global -ValueOnly + $result['_error_Get-CostData'] | Should -Match '403' + $result['Get-CostData'] | Should -BeNullOrEmpty + $html = Get-Content (Join-Path $reportPath 'FinOpsReport.html') -Raw + $html | Should -Match ([regex]::Escape($ExpectedRole)) + $html | Should -Match 'Scans Run
1
' + $html | Should -Match 'Errors
1
' + Get-Content (Join-Path $reportPath 'ScanSummary.txt') -Raw | Should -Match 'ERROR:.*403' + @(Get-ChildItem -LiteralPath $reportPath -Filter '*.csv').Count | Should -Be 0 + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { "$Object" -match "Required role:\s+$([regex]::Escape($ExpectedRole))" } + Should -Invoke Read-Host -Times 0 -Exactly + if ($Source -eq 'Hub') { + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + $html | Should -Not -Match 'Cost Management Reader' + } + } + } + Context 'Parameters' { It 'Should expose an optional SubscriptionId parameter' { $cmd = Get-Command -Name 'Start-FinOpsMultitool' -Module 'FinOpsToolkit' From 839276dd5b524f3f763fcbe2d8d2775be62b86fd Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Sat, 19 Sep 2026 00:18:51 -0600 Subject: [PATCH 135/142] fix(multitool): auto-save reports and correct Hub output --- .../multitool/finops-multitool-commands.md | 2 + .../multitool/start-finopsmultitool.md | 27 +- .../Invoke-FinOpsMultitool.ps1 | 392 +++++++++----- .../Private/FinOpsMultitool/README.md | 10 +- .../modules/Get-UnitEconomics.ps1 | 66 +-- .../modules/helpers/Get-CostExport.ps1 | 11 +- .../modules/helpers/Get-KpiInsights.ps1 | 20 +- .../modules/helpers/Read-FinOpsHubData.ps1 | 19 +- .../Public/Start-FinOpsMultitool.ps1 | 21 +- .../Tests/Unit/MultitoolSafety.Tests.ps1 | 490 +++++++++++++++--- .../Unit/Start-FinOpsMultitool.Tests.ps1 | 88 +++- 11 files changed, 859 insertions(+), 287 deletions(-) diff --git a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md index 5b57dfbfe..4755d8981 100644 --- a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md +++ b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md @@ -22,6 +22,8 @@ The multitool provides one scan engine with two interfaces: The terminal UI prompts for each choice by default. Consoles that can't render the arrow-key menus, such as PowerShell remoting sessions, fall back to numbered prompts. To run the tool from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters. +CSV, HTML, and text reports are saved automatically on the machine running the multitool, in a new private folder under the current user's local application data. Use `-OutputPath` to select a different local parent folder outside Git repositories. For location details and privacy limits, see [Report storage](start-finopsmultitool.md#report-storage). +
## Commands diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md index 0df418d6e..8e386af3e 100644 --- a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -17,7 +17,7 @@ ms.reviewer: micflan 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. Scans cover 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. When you choose to export, the tool writes a CSV file per scan module, a `FinOpsReport.html` summary, and a `ScanSummary.txt` file. The scan modules are read-only. +Results appear in the terminal and are saved automatically on the machine running the command. Each run creates a private folder with one CSV file per selected scan, a `FinOpsReport.html` summary, and a `ScanSummary.txt` file. Failed or empty scans have a CSV status record. The scans don't change Azure resources. The command requires PowerShell 7 or later on Windows, macOS, and Linux. 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. Commitment utilization reads at billing account or billing profile scope, so it needs that same billing access. The carbon scan needs Reader or Carbon Optimization Reader assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. @@ -44,10 +44,10 @@ Start-FinOpsMultitool ` | Name | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `‑SubscriptionId` | Optional. Scopes the scan to a single subscription. When omitted, all accessible subscriptions are discovered. If the subscription can't be resolved and nothing can answer a prompt, the command returns an error rather than scanning every subscription. | -| `‑OutputPath` | Optional. Directory for exported result files. Defaults to a `FinOpsResults` folder in your home directory. | +| `‑OutputPath` | Optional. Local parent folder for reports. Defaults to `FinOpsToolkit/Multitool/Reports` under the current user's local application data. Each run creates a new timestamped subfolder. Git repositories, UNC paths, mapped Windows network drives, symbolic links, and junctions aren't accepted. Unix network mounts aren't detected. | | `‑Scans` | Optional. Runs the specified scans instead of the default selection. Accepts a scan command name, such as `Get-OrphanedResources`, or its menu label, such as `Orphaned Resources`. Use `All` to select every scan. An unrecognized name returns an error. | | `‑DataSource` | Optional. Sets the data source and skips the data source prompt. Valid values are `Hub`, `API`, and `GraphOnly`. `API` and `GraphOnly` take precedence over `FINOPS_HUB_KUSTO_URI` and don't preload hub data. An explicit `Hub` selection fails if no configured Kusto endpoint or hub storage is available. Select `API` separately for a live scan. | -| `‑NonInteractive` | Optional. Runs without prompting. Every choice comes from the parameters or their defaults, and results are exported only when you set `-OutputPath`. | +| `‑NonInteractive` | Optional. Runs without prompting. Every choice comes from the parameters or their defaults. Reports are saved automatically, even when `-OutputPath` is omitted. |
@@ -71,13 +71,13 @@ Start-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' Launches the terminal UI scoped to a single subscription. -### Set an output path for exports +### Choose a local report folder ```powershell -Start-FinOpsMultitool -OutputPath './finops-results' +Start-FinOpsMultitool -OutputPath (Join-Path $HOME 'FinOpsReports') ``` -Launches the terminal UI and writes exported result files to the specified directory. +Launches the terminal UI and saves reports in a new run subfolder under the specified local folder. Choose a location outside Git repositories and synced folders. ### Run specific scans without prompting @@ -86,11 +86,20 @@ Start-FinOpsMultitool ` -NonInteractive ` -SubscriptionId '00000000-0000-0000-0000-000000000000' ` -Scans Get-OrphanedResources, Get-IdleVMs ` - -DataSource API ` - -OutputPath './finops-results' + -DataSource API ``` -Runs two scans against one subscription without prompting and writes the results to the specified directory. Use this form from a pipeline or a scheduled job. +Runs two scans against one subscription without prompting and saves all report formats in the default local folder. Use this form from a pipeline or a scheduled job. + +
+ +## Report storage + +The default parent folder is `FinOpsToolkit/Multitool/Reports` under `[Environment]::GetFolderPath('LocalApplicationData')`. On Windows, that's usually `%LOCALAPPDATA%\FinOpsToolkit\Multitool\Reports`. The command prints the full path for each run. Reports never overwrite an earlier run. + +The tool creates the run folder with permissions restricted to the current user. It rejects Git repositories, UNC paths, mapped Windows network drives, symbolic links, and junctions, and includes an ignore-all `.gitignore` to reduce accidental staging. Unix network mounts aren't detected, so choose a path on a local filesystem. If saving fails, the command reports the error and retains results in `$FinOpsResults`. It doesn't silently use the current directory instead. + +Reports are plaintext, not encrypted, and can contain sensitive cost and resource details. The tool doesn't upload them. Keep custom folders outside cloud-sync locations, protect access to your account, and follow your organization's retention policy. Administrators and processes running as your account can still access the files. Moving or force-adding reports to Git bypasses these safeguards.
diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index 63a7ea181..a4bf577aa 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -1019,10 +1019,10 @@ function Invoke-FinOpsMultitool { $currentMonth = (Get-Date).ToUniversalTime() $currentMonth = $currentMonth.Date.AddDays(1 - $currentMonth.Day) $forecastSubscriptions = @($Subscriptions | Where-Object { - $entry = if ($hubCostData) { $hubCostData[$_.Id] } else { $null } - $entry -and $null -ne $entry.ActualPeriodStart -and $null -ne $entry.ActualPeriodEnd -and - $entry.ActualPeriodStart -ge $currentMonth -and $entry.ActualPeriodEnd -lt $currentMonth.AddMonths(1) - }) + $entry = if ($hubCostData) { $hubCostData[$_.Id] } else { $null } + $entry -and $null -ne $entry.ActualPeriodStart -and $null -ne $entry.ActualPeriodEnd -and + $entry.ActualPeriodStart -ge $currentMonth -and $entry.ActualPeriodEnd -lt $currentMonth.AddMonths(1) + }) if ($hubCostData -and $forecastSubscriptions.Count -gt 0) { try { $liveCost = Get-CostData -TenantId $TenantId -Subscriptions $forecastSubscriptions -RestrictToSelected @@ -1070,7 +1070,7 @@ function Invoke-FinOpsMultitool { $pct = [math]::Round(($current / $total) * 100) $bar = ('█' * [math]::Floor($pct / 5)).PadRight(20, '░') - Write-Host " [$bar] $pct% ($current/$total) $($mod.Name)" -ForegroundColor White -NoNewline + Write-Host " [$bar] $pct% ($current/$total) $($mod.Name)" -ForegroundColor White $sw = [System.Diagnostics.Stopwatch]::StartNew() try { @@ -1191,13 +1191,12 @@ function Invoke-FinOpsMultitool { $count = if ($output) { @($output).Count } else { 0 } $results[$fn] = $output - Write-Host "`r [$bar] $pct% ($current/$total) $($mod.Name) " -ForegroundColor Green -NoNewline - Write-Host " $count results ($([math]::Round($sw.Elapsed.TotalSeconds, 1))s)" -ForegroundColor DarkGray + Write-Host " Completed: $($mod.Name) - $count results ($([math]::Round($sw.Elapsed.TotalSeconds, 1))s)" -ForegroundColor Green } catch { $sw.Stop() - Write-Host "`r [$bar] $pct% ($current/$total) $($mod.Name) " -ForegroundColor Red -NoNewline - Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red + Write-Host " FAILED: $($mod.Name)" -ForegroundColor Red + Write-Host " $($_.Exception.Message)" -ForegroundColor Red $results[$mod.Fn] = @() $results["_error_$($mod.Fn)"] = $_.Exception.Message } @@ -1296,31 +1295,31 @@ function Invoke-FinOpsMultitool { $rows = $null $payloadsByScan = @{ - 'Get-AHBOpportunities' = @('WindowsVMs', 'SQLVMs', 'SQLDatabases') - 'Get-AIWorkloadMetrics' = @('ByModel', 'ByAccount') - 'Get-AnomalyAlerts' = @('TriggeredAlerts', 'ConfiguredRules') - 'Get-BillingAccount' = @('Accounts') - 'Get-BillingStructure' = @('BillingAccounts', 'BillingProfiles', 'InvoiceSections', 'EADepartments', 'CostAllocationRules') - 'Get-BudgetStatus' = @('Budgets') - 'Get-CarbonMetrics' = @('MonthlyTrend', 'BySubscription') - 'Get-CommitmentUtilization' = @('Reservations', 'SavingsPlans') - 'Get-CostByTag' = @('CostByTag') - 'Get-CostTrend' = @('Months', 'BySubscription') - 'Get-IdleVMs' = @('IdleVMs') - 'Get-LegacyResources' = @('LegacyResources') - 'Get-MaccCommitment' = @('Commitments') - 'Get-OptimizationAdvice' = @('Recommendations') - 'Get-OrphanedResources' = @('Orphans') - 'Get-PolicyInventory' = @('Assignments', 'ComplianceBySubMap') - 'Get-PolicyRecommendations' = @('Analysis') - 'Get-ReservationAdvice' = @('AdvisorRecommendations', 'ReservationRecommendations') - 'Get-SavingsRealized' = @('Details') - 'Get-SharedCostAllocation' = @('Allocations', 'RuleTargets') - 'Get-StorageTierAdvice' = @('Recommendations') - 'Get-TagInventory' = @('TagNames', 'CaseVariants', 'UntaggedResources') - 'Get-TagRecommendations' = @('Analysis') + 'Get-AHBOpportunities' = @('WindowsVMs', 'SQLVMs', 'SQLDatabases') + 'Get-AIWorkloadMetrics' = @('ByModel', 'ByAccount') + 'Get-AnomalyAlerts' = @('TriggeredAlerts', 'ConfiguredRules') + 'Get-BillingAccount' = @('Accounts') + 'Get-BillingStructure' = @('BillingAccounts', 'BillingProfiles', 'InvoiceSections', 'EADepartments', 'CostAllocationRules') + 'Get-BudgetStatus' = @('Budgets') + 'Get-CarbonMetrics' = @('MonthlyTrend', 'BySubscription') + 'Get-CommitmentUtilization' = @('Reservations', 'SavingsPlans') + 'Get-CostByTag' = @('CostByTag') + 'Get-CostTrend' = @('Months', 'BySubscription') + 'Get-IdleVMs' = @('IdleVMs') + 'Get-LegacyResources' = @('LegacyResources') + 'Get-MaccCommitment' = @('Commitments') + 'Get-OptimizationAdvice' = @('Recommendations') + 'Get-OrphanedResources' = @('Orphans') + 'Get-PolicyInventory' = @('Assignments', 'ComplianceBySubMap') + 'Get-PolicyRecommendations' = @('Analysis') + 'Get-ReservationAdvice' = @('AdvisorRecommendations', 'ReservationRecommendations') + 'Get-SavingsRealized' = @('Details') + 'Get-SharedCostAllocation' = @('Allocations', 'RuleTargets') + 'Get-StorageTierAdvice' = @('Recommendations') + 'Get-TagInventory' = @('TagNames', 'CaseVariants', 'UntaggedResources') + 'Get-TagRecommendations' = @('Analysis') 'Get-UsageProportionalAllocation' = @('Allocations', 'RuleTargets') - 'Get-VmCostBreakdown' = @('Breakdown') + 'Get-VmCostBreakdown' = @('Breakdown') } $metadata = [ordered]@{} @@ -1413,10 +1412,18 @@ function Invoke-FinOpsMultitool { $rows = @($Data) } - if ($payloadsByScan.ContainsKey($Fn) -and $rows.Count -eq 0) { - $record = [ordered]@{ RecordType = 'Summary' } - foreach ($field in $metadata.GetEnumerator()) { $record[$field.Key] = $field.Value } - $rows = @([PSCustomObject]$record) + if ($payloadsByScan.ContainsKey($Fn)) { + $primaryRows = @($rows | Where-Object { $_.RecordType -in $payloadNames }) + if ($primaryRows.Count -eq 0) { + $record = [ordered]@{ + RecordType = 'Status' + Scan = $Fn + Status = if ($Data.AccessDenied -or $Data.Error) { 'Error' } else { 'No data' } + Error = if ($Data.Error) { $Data.Error } elseif ($Data.AccessDenied) { $Data.Note } else { $null } + } + foreach ($field in $metadata.GetEnumerator()) { $record[$field.Key] = $field.Value } + $rows = @([PSCustomObject]$record) + @($rows) + } } # Whatever projection was chosen, guarantee scalar cells. @@ -1447,6 +1454,133 @@ function Invoke-FinOpsMultitool { return @($flatRows | Select-Object -Property $columnNames.ToArray()) } + function Get-FinOpsReportRoot { + $localData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData, [Environment+SpecialFolderOption]::DoNotVerify) + if ([string]::IsNullOrWhiteSpace($localData)) { + throw 'Local application data is unavailable. Specify a local OutputPath outside any Git repository.' + } + return (Join-Path $localData 'FinOpsToolkit/Multitool/Reports') + } + + function Assert-FinOpsReportPath { + param([Parameter(Mandatory)][string]$Path) + + if ($Path -match '^[\\/]{2}|::|[\x00-\x1f]' -or ($Path.Contains(':') -and $Path -notmatch '^[A-Za-z]:[\\/][^:]*$')) { + throw 'Reports require a local filesystem path, not a network, device, or provider path.' + } + $provider = $null + $drive = $null + $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path, [ref]$provider, [ref]$drive) + if ($provider.Name -ne 'FileSystem' -or $fullPath -match '^[\\/]{2}') { + throw 'Reports require a local filesystem path.' + } + $fullPath = [System.IO.Path]::GetFullPath($fullPath) + if ($IsWindows -and ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($fullPath))).DriveType -eq [System.IO.DriveType]::Network) { + throw 'Reports require a local drive, not a mapped network drive.' + } + $ancestor = $fullPath + while ($ancestor) { + if ([System.IO.Path]::GetFileName($ancestor) -ieq '.git') { + throw 'Reports cannot be saved in a Git metadata directory.' + } + try { + $attributes = [System.IO.File]::GetAttributes($ancestor) + if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Report paths cannot contain symbolic links or junctions.' + } + if (($attributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + throw 'The report destination must be a local directory.' + } + $gitMarker = Join-Path $ancestor '.git' + $hasGitMarker = $false + try { + $null = [System.IO.File]::GetAttributes($gitMarker) + $hasGitMarker = $true + } + catch [System.IO.FileNotFoundException] { $hasGitMarker = $false } + catch [System.IO.DirectoryNotFoundException] { $hasGitMarker = $false } + if ($hasGitMarker -or ([System.IO.File]::Exists((Join-Path $ancestor 'HEAD')) -and [System.IO.Directory]::Exists((Join-Path $ancestor 'objects')))) { + throw 'Reports cannot be saved inside a Git repository or worktree. Choose a different local OutputPath.' + } + } + catch [System.IO.FileNotFoundException] { Write-Verbose "The report path '$ancestor' does not exist yet." } + catch [System.IO.DirectoryNotFoundException] { Write-Verbose "The report path '$ancestor' does not exist yet." } + $ancestor = [System.IO.Path]::GetDirectoryName($ancestor) + } + return $fullPath + } + + function New-FinOpsReportDirectory { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates only a new private report directory for the requested scan.')] + [CmdletBinding()] + param([string]$OutputPath) + + $basePath = if ([string]::IsNullOrWhiteSpace($OutputPath)) { Get-FinOpsReportRoot } else { $OutputPath } + $basePath = Assert-FinOpsReportPath -Path $basePath + [void][System.IO.Directory]::CreateDirectory($basePath) + $runName = '{0}-{1}' -f [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ', [cultureinfo]::InvariantCulture), [guid]::NewGuid().ToString('N') + $runPath = Assert-FinOpsReportPath -Path (Join-Path $basePath $runName) + if (Test-Path -LiteralPath $runPath) { throw 'The report run directory already exists. No files were written.' } + + if ($IsWindows) { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + try { + $security = [System.Security.AccessControl.DirectorySecurity]::new() + $security.SetAccessRuleProtection($true, $false) + $security.SetOwner($identity.User) + $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $security.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + $identity.User, [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow)) + [System.IO.FileSystemAclExtensions]::Create([System.IO.DirectoryInfo]::new($runPath), $security) + } + finally { $identity.Dispose() } + } + else { + $unixModeType = 'System.IO.UnixFileMode' -as [type] + if ($unixModeType) { + [void][System.IO.Directory]::CreateDirectory($runPath, [Enum]::ToObject($unixModeType, 448)) + } + else { + $mkdir = Get-Command -Name mkdir -CommandType Application -ErrorAction Stop + & $mkdir.Source -m 700 $runPath + if ($LASTEXITCODE -ne 0) { throw 'Could not create a private report directory.' } + } + } + $runPath = Assert-FinOpsReportPath -Path $runPath + Write-FinOpsReportFile -Directory $runPath -Name '.gitignore' -Lines @('*') + return $runPath + } + + function Write-FinOpsReportFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Directory, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines + ) + + $Directory = Assert-FinOpsReportPath -Path $Directory + if ($Name -notmatch '^(?:[A-Za-z0-9][A-Za-z0-9._-]*|\.gitignore)$') { throw 'Invalid report file name.' } + $path = Join-Path $Directory $Name + $stream = [System.IO.FileStream]::new($path, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) + try { + if (-not $IsWindows) { + $unixModeType = 'System.IO.UnixFileMode' -as [type] + if ($unixModeType) { [System.IO.File]::SetUnixFileMode($path, [Enum]::ToObject($unixModeType, 384)) } + else { + $chmod = Get-Command -Name chmod -CommandType Application -ErrorAction Stop + & $chmod.Source 600 $path + if ($LASTEXITCODE -ne 0) { throw 'Could not restrict report file permissions.' } + } + } + $writer = [System.IO.StreamWriter]::new($stream, [System.Text.UTF8Encoding]::new($false)) + try { foreach ($line in $Lines) { $writer.WriteLine($line) } } + finally { $writer.Dispose() } + } + finally { $stream.Dispose() } + } + function Show-ResultsSummary { param( [hashtable]$Results, @@ -1679,12 +1813,12 @@ function Invoke-FinOpsMultitool { elseif ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] } else { $_.Key.Substring(0, [Math]::Min(36, $_.Key.Length)) } [PSCustomObject]@{ - Subscription = $subLabel - Actual = Format-BudgetAmount -Value $_.Value.Actual -Currency $_.Value.Currency - ActualPeriod = if ($_.Value.ActualPeriod) { $_.Value.ActualPeriod } else { 'Current month' } - Forecast = if ($_.Value.ForecastSource -eq 'Actual') { 'Unavailable' } else { Format-BudgetAmount -Value $_.Value.Forecast -Currency $_.Value.Currency } + Subscription = $subLabel + Actual = Format-BudgetAmount -Value $_.Value.Actual -Currency $_.Value.Currency + ActualPeriod = if ($_.Value.ActualPeriod) { $_.Value.ActualPeriod } else { 'Current month' } + Forecast = if ($_.Value.ForecastSource -eq 'Actual') { 'Unavailable' } else { Format-BudgetAmount -Value $_.Value.Forecast -Currency $_.Value.Currency } ForecastSource = if ($_.Value.ForecastSource) { $_.Value.ForecastSource } else { 'Unavailable' } - Currency = $_.Value.Currency + Currency = $_.Value.Currency } } $cols = @('Subscription', 'Actual', 'ActualPeriod', 'Forecast', 'ForecastSource', 'Currency') @@ -1853,13 +1987,13 @@ function Invoke-FinOpsMultitool { } $rows = $data.Budgets | ForEach-Object { [PSCustomObject]@{ - Budget = $_.BudgetName - Amount = Format-BudgetAmount -Value $_.Amount -Currency $_.Currency - Spent = Format-BudgetAmount -Value $_.ActualSpend -Currency $_.Currency + Budget = $_.BudgetName + Amount = Format-BudgetAmount -Value $_.Amount -Currency $_.Currency + Spent = Format-BudgetAmount -Value $_.ActualSpend -Currency $_.Currency Forecast = Format-BudgetAmount -Value $_.Forecast -Currency $_.Currency - PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } - Risk = $_.Risk - Note = $_.Note + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } + Risk = $_.Risk + Note = $_.Note } } $cols = @('Budget', 'Amount', 'Spent', 'Forecast', 'PctUsed', 'Risk', 'Note') @@ -1973,10 +2107,10 @@ function Invoke-FinOpsMultitool { Write-ColorizedLine -Text " Storage: $($data.Currency) $($data.StorageCost) ($($data.StorageSharePct)%) over $($data.TotalStorageGb) GB ($($data.DiskGb) GB disk + $($data.BlobFileGb) GB blob/file)" -DefaultColor 'White' if ($data.Note) { Write-Host " $($data.Note)" -ForegroundColor DarkGray } $rows = @( - [PSCustomObject]@{ Metric = 'Cost per vCPU'; Value = "$($data.Currency) $($data.CostPerVCpu)" } - [PSCustomObject]@{ Metric = 'Cost per GB RAM'; Value = "$($data.Currency) $($data.CostPerGbRam)" } - [PSCustomObject]@{ Metric = 'Cost per VM'; Value = "$($data.Currency) $($data.CostPerVm)" } - [PSCustomObject]@{ Metric = 'Cost per GB stored'; Value = "$($data.Currency) $($data.CostPerGb)" } + [PSCustomObject]@{ Metric = 'Cost per vCPU'; Value = (Format-FinOpsUnitRate -Value $data.CostPerVCpu -Currency $data.Currency) } + [PSCustomObject]@{ Metric = 'Cost per GB RAM'; Value = (Format-FinOpsUnitRate -Value $data.CostPerGbRam -Currency $data.Currency) } + [PSCustomObject]@{ Metric = 'Cost per VM'; Value = (Format-FinOpsUnitRate -Value $data.CostPerVm -Currency $data.Currency) } + [PSCustomObject]@{ Metric = 'Cost per GB stored'; Value = (Format-FinOpsUnitRate -Value $data.CostPerGb -Currency $data.Currency) } ) $cols = @('Metric', 'Value') } @@ -2328,35 +2462,37 @@ function Invoke-FinOpsMultitool { } } 'Get-CostTrend' { - if ($data.Months -and @($data.Months).Count -ge 2) { - $sorted = @($data.Months) | Sort-Object Month -Descending | Select-Object -First 2 - $current = [double]$sorted[0].Cost - $previous = [double]$sorted[1].Cost - if ($previous -gt 0) { - $change = [math]::Round((($current - $previous) / $previous) * 100, 1) - if ($change -gt 20) { - $guidanceItems = @( - @{ Severity = 'Red'; Message = "Cost spiked $change% month-over-month. Investigate immediately — this is abnormal growth." } - @{ Severity = 'Red'; Message = "FinOps Action: Check for new deployments, usage spikes, or runaway auto-scale." } - @{ Severity = 'Yellow'; Message = "Set up Cost Management budget alerts at 80%, 90%, 100% to catch spikes early."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/cost-mgt-alerts-monitor-usage-spending' } - ) - } - elseif ($change -gt 5) { - $guidanceItems = @( - @{ Severity = 'Yellow'; Message = "Cost increased $change% MoM. Moderate growth — review new resources deployed this period." } - @{ Severity = 'Yellow'; Message = "FinOps Practice: Establish a monthly cost review cadence to catch trends before they become problems." } - ) - } - elseif ($change -lt -5) { - $guidanceItems = @( - @{ Severity = 'Green'; Message = "Cost decreased $([math]::Abs($change))% MoM. Optimization efforts are working." } - ) - } - else { - $guidanceItems = @( - @{ Severity = 'Green'; Message = "Cost trend is stable ($change% change). Good cost discipline and predictable spend." } - ) - } + $nowUtc = (Get-Date).ToUniversalTime() + $currentMonthStart = $nowUtc.Date.AddDays(1 - $nowUtc.Day) + $completedMonths = @($data.Months | Where-Object { $_.MonthDate -and [datetime]$_.MonthDate -lt $currentMonthStart } | + Sort-Object { [datetime]$_.MonthDate } -Descending | Select-Object -First 2) + if ($completedMonths.Count -lt 2) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = 'A month-over-month comparison needs two completed months. The current month is partial and is excluded.' }) + } + elseif (-not $completedMonths[0].Currency -or -not $completedMonths[1].Currency -or + $completedMonths[0].Currency -eq 'Mixed' -or $completedMonths[0].Currency -ne $completedMonths[1].Currency) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = 'The completed months have unknown or different currencies. A month-over-month percentage is unavailable.' }) + } + else { + $latestDate = [datetime]$completedMonths[0].MonthDate + $previousDate = [datetime]$completedMonths[1].MonthDate + $latestMonth = $latestDate.Date.AddDays(1 - $latestDate.Day) + $previousMonth = $previousDate.Date.AddDays(1 - $previousDate.Day) + if ($previousMonth.AddMonths(1) -ne $latestMonth) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = 'The result does not contain two consecutive completed months. A month-over-month percentage is unavailable.' }) + } + elseif ([double]$completedMonths[1].Cost -le 0) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = 'The previous completed month has no positive net cost. A month-over-month percentage is unavailable.' }) + } + else { + $change = [math]::Round((([double]$completedMonths[0].Cost - [double]$completedMonths[1].Cost) / [double]$completedMonths[1].Cost) * 100, 1) + $direction = if ($change -lt 0) { 'decreased' } elseif ($change -gt 0) { 'increased' } else { 'changed' } + $previousLabel = $previousMonth.ToString('MMM yyyy', [cultureinfo]::InvariantCulture) + $latestLabel = $latestMonth.ToString('MMM yyyy', [cultureinfo]::InvariantCulture) + $guidanceItems = @( + @{ Severity = $(if ($change -gt 20) { 'Red' } else { 'Yellow' }); Message = "Observed spend $direction $([math]::Abs($change))% from $previousLabel to $latestLabel ($($completedMonths[0].Currency)). The partial current month is excluded." } + @{ Severity = 'Yellow'; Message = 'A change in spend can reflect usage, prices, credits, or optimization. Review the cost drivers before attributing savings.' } + ) } } } @@ -2647,56 +2783,27 @@ function Invoke-FinOpsMultitool { Write-Host "" } - # -- Export option ------------------------------------------------- - $defaultPath = Join-Path $HOME 'FinOpsResults' - if ($ExportPath) { - $exportDir = $ExportPath - } - elseif ($NonInteractive) { - # Nothing can answer a prompt here, so -OutputPath is the way to export. - $exportDir = $null - } - elseif (-not (Test-FinOpsRichConsole)) { - $wantExport = Read-FinOpsAnswer ' Export results? [y/N]: ' - if ($wantExport -match '^(?i)y') { - $entered = Read-FinOpsAnswer " Path [$defaultPath]: " - $exportDir = if ($entered -eq '') { $defaultPath } else { $entered } - } - else { - $exportDir = $null - } - } - else { - Write-Host " Export results? [E] Export [Enter] Skip" -ForegroundColor DarkGray - $eKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') - if ($eKey.Character -eq 'e' -or $eKey.Character -eq 'E') { - Write-Host "" - Write-Host " Path [$defaultPath]: " -ForegroundColor White -NoNewline - $exportDir = Read-Host - if (-not $exportDir -or $exportDir.Trim() -eq '') { - $exportDir = $defaultPath - } - } - else { - $exportDir = $null - } - } - - if ($exportDir -and $exportDir.Trim() -ne '') { - if (-not (Test-Path $exportDir)) { - New-Item -ItemType Directory -Path $exportDir -Force | Out-Null - } + $exportDir = $null + try { + $exportDir = New-FinOpsReportDirectory -OutputPath $ExportPath -ErrorAction Stop # -- CSV exports per module -- foreach ($mod in ($Modules | Where-Object { $_.Selected })) { $data = $Results[$mod.Fn] - 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" - $exportRows | Export-Csv -Path $csvPath -NoTypeInformation + $hasScanError = $Results.ContainsKey("_error_$($mod.Fn)") + $exportRows = @(if (-not $hasScanError) { ConvertTo-FinOpsExportRows -Fn $mod.Fn -Data $data }) + if ($exportRows.Count -eq 0) { + $errorMessage = $Results["_error_$($mod.Fn)"] + $statusRow = [pscustomobject]@{ + RecordType = 'Status' + Scan = $mod.Fn + Status = if ($hasScanError) { 'Error' } else { 'No data' } + Error = $errorMessage + } + $exportRows = @(ConvertTo-FinOpsExportRows -Fn 'ReportStatus' -Data $statusRow) } + $safeName = $mod.Fn -replace '[^a-zA-Z0-9\-]', '' + Write-FinOpsReportFile -Directory $exportDir -Name "$safeName.csv" -Lines @($exportRows | ConvertTo-Csv -NoTypeInformation -ErrorAction Stop) -ErrorAction Stop } # -- HTML report -- @@ -2970,12 +3077,12 @@ tr:hover td { background: var(--surface); } $htmlRows = $data.GetEnumerator() | ForEach-Object { $sl = if ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] } else { $_.Key } [PSCustomObject]@{ - Subscription = $sl - Actual = Format-BudgetAmount -Value $_.Value.Actual -Currency $_.Value.Currency - ActualPeriod = if ($_.Value.ActualPeriod) { $_.Value.ActualPeriod } else { 'Current month' } - Forecast = if ($_.Value.ForecastSource -eq 'Actual') { 'Unavailable' } else { Format-BudgetAmount -Value $_.Value.Forecast -Currency $_.Value.Currency } + Subscription = $sl + Actual = Format-BudgetAmount -Value $_.Value.Actual -Currency $_.Value.Currency + ActualPeriod = if ($_.Value.ActualPeriod) { $_.Value.ActualPeriod } else { 'Current month' } + Forecast = if ($_.Value.ForecastSource -eq 'Actual') { 'Unavailable' } else { Format-BudgetAmount -Value $_.Value.Forecast -Currency $_.Value.Currency } ForecastSource = if ($_.Value.ForecastSource) { $_.Value.ForecastSource } else { 'Unavailable' } - Currency = $_.Value.Currency + Currency = $_.Value.Currency } } $htmlCols = @('Subscription', 'Actual', 'ActualPeriod', 'Forecast', 'ForecastSource', 'Currency') @@ -3156,10 +3263,10 @@ tr:hover td { background: var(--surface); } [void]$htmlSb.Append("

Compute: $uCur $($data.ComputeCost) ($($data.ComputeSharePct)%) over $($data.VmCount) VMs, $($data.TotalVCpu) vCPU, $($data.TotalMemoryGb) GB RAM

") [void]$htmlSb.Append("

Storage: $uCur $($data.StorageCost) ($($data.StorageSharePct)%) over $($data.TotalStorageGb) GB

") $htmlRows = @( - [PSCustomObject]@{ Metric = 'Cost per vCPU'; Value = "$($data.Currency) $($data.CostPerVCpu)" } - [PSCustomObject]@{ Metric = 'Cost per GB RAM'; Value = "$($data.Currency) $($data.CostPerGbRam)" } - [PSCustomObject]@{ Metric = 'Cost per VM'; Value = "$($data.Currency) $($data.CostPerVm)" } - [PSCustomObject]@{ Metric = 'Cost per GB stored'; Value = "$($data.Currency) $($data.CostPerGb)" } + [PSCustomObject]@{ Metric = 'Cost per vCPU'; Value = (Format-FinOpsUnitRate -Value $data.CostPerVCpu -Currency $data.Currency) } + [PSCustomObject]@{ Metric = 'Cost per GB RAM'; Value = (Format-FinOpsUnitRate -Value $data.CostPerGbRam -Currency $data.Currency) } + [PSCustomObject]@{ Metric = 'Cost per VM'; Value = (Format-FinOpsUnitRate -Value $data.CostPerVm -Currency $data.Currency) } + [PSCustomObject]@{ Metric = 'Cost per GB stored'; Value = (Format-FinOpsUnitRate -Value $data.CostPerGb -Currency $data.Currency) } ) $htmlCols = @('Metric', 'Value') if ($data.Note) { $tableNote = [string]$data.Note } @@ -3296,11 +3403,9 @@ tr:hover td { background: var(--surface); } '@) - $htmlPath = Join-Path $exportDir 'FinOpsReport.html' - $htmlSb.ToString() | Out-File -FilePath $htmlPath -Encoding utf8 + Write-FinOpsReportFile -Directory $exportDir -Name 'FinOpsReport.html' -Lines @($htmlSb.ToString()) -ErrorAction Stop # Summary text file - $summaryPath = Join-Path $exportDir 'ScanSummary.txt' $summaryLines = @( "FinOps Multitool Scan Summary" "Generated: $timestamp" @@ -3314,13 +3419,17 @@ tr:hover td { background: var(--surface); } $status = if ($Results.ContainsKey($errorKey)) { "ERROR: $($Results[$errorKey])" } elseif ($count -eq 0) { "No data" } else { "$count findings" } $summaryLines += "$($mod.Name): $status" } - $summaryLines | Out-File -FilePath $summaryPath -Encoding utf8 + Write-FinOpsReportFile -Directory $exportDir -Name 'ScanSummary.txt' -Lines $summaryLines -ErrorAction Stop Write-Host "" Write-Host " Exported to: $exportDir" -ForegroundColor Green - $csvCount = (Get-ChildItem $exportDir -Filter '*.csv').Count + $csvCount = @(Get-ChildItem -LiteralPath $exportDir -Filter '*.csv').Count Write-Host " Files: $csvCount CSVs + FinOpsReport.html + ScanSummary.txt" -ForegroundColor DarkGray } + catch { + $partialLocation = if ($exportDir) { " Incomplete reports may remain in '$exportDir'." } else { '' } + Write-Error -Message "Automatic report saving failed: $($_.Exception.Message). Results remain in `$FinOpsResults.$partialLocation" -ErrorId 'FinOpsReportExportFailed' -Category WriteError + } # Interactive drill-down Write-Host "" @@ -3411,6 +3520,7 @@ tr:hover td { background: var(--surface); } # Step 4: Run $results = Invoke-SelectedScans -Modules $finalModules -Subscriptions $subs -TenantId $tenantId -DataSource $sourceChoice -PermissionInfo $permissionInfo + $global:FinOpsResults = $results # Step 5: Summary + export $effectiveSource = switch ($sourceChoice.Source) { @@ -3419,7 +3529,7 @@ tr:hover td { background: var(--surface); } 'GraphOnly' { 'Resource Graph only (no cost data)' } default { [string]$sourceChoice.Source } } - $global:FinOpsResults = Show-ResultsSummary -Results $results -Modules $finalModules -ExportPath $OutputPath -Subscriptions $subs -DataSourceLabel $effectiveSource + $null = Show-ResultsSummary -Results $results -Modules $finalModules -ExportPath $OutputPath -Subscriptions $subs -DataSourceLabel $effectiveSource Write-Host " Done. Results available in `$FinOpsResults" -ForegroundColor Green Write-Host "" diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index e436027ee..4064b512a 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -54,7 +54,7 @@ When the **FinOps Hub** source is chosen, the tool prefers the hub's **Kusto dat ### 3. Scan selection -Use arrow-key menus to select scans when your host supports them. Other hosts use numbered prompts. For automation, use `-NonInteractive` with `-Scans`, `-DataSource`, and `-SubscriptionId`. Add `-OutputPath` to export results. All menu scans are selected by default except **Billing Structure**. +Use arrow-key menus to select scans when your host supports them. Other hosts use numbered prompts. For automation, use `-NonInteractive` with `-Scans`, `-DataSource`, and `-SubscriptionId`. Reports are saved automatically; `-OutputPath` changes their parent folder. All menu scans are selected by default except **Billing Structure**. | Key | Action | | --------- | ------------------ | @@ -90,7 +90,13 @@ Guidance includes FinOps Foundation best practices, actionable next steps, and l - **Access denied** (403/401) — Shows the exact error, required RBAC role, scope, and API - **No data** — Explains whether the module requires specific resources (e.g., "Returns empty if no budgets are configured") -Optional exports write to the output path: one CSV file per scan module, a `FinOpsReport.html` summary, and a `ScanSummary.txt` text summary. +Each completed run automatically saves one CSV file per selected scan, a `FinOpsReport.html` summary, and a `ScanSummary.txt` text summary on the machine running the multitool. Failed or empty scans have a CSV status record. There's no export prompt or format picker. + +By default, reports go under the current user's local application data directory, in `FinOpsToolkit/Multitool/Reports`. On Windows, that's usually `%LOCALAPPDATA%\FinOpsToolkit\Multitool\Reports`. Each run creates a timestamped, uniquely named subfolder. The terminal prints its full path. `-OutputPath` selects a different local parent folder; it doesn't replace reports from an earlier run. + +The run folder allows access only to the current user through filesystem permissions. On Unix, directories use mode `700` and files use mode `600`. The tool rejects Git repositories and worktrees, UNC paths, mapped Windows network drives, symbolic links, and junctions, and adds an ignore-all `.gitignore` as a backup against accidental staging. Unix network mounts aren't detected; choose a path on a local filesystem. If it can't safely save, it reports an error and keeps the scan results in `$FinOpsResults`; it doesn't fall back to the working directory. + +Reports are plaintext and can contain subscription, resource, tag, and billing details. They aren't encrypted or uploaded by the tool. Administrators and processes running as your account can still access them. Keep custom locations outside synced folders, follow your organization's retention policy, and delete reports when they're no longer needed. These safeguards don't stop someone from moving or force-adding the files to a repository later. CSV files use `RecordType` to distinguish datasets when a scan returns several collections, such as reservations and savings plans. Scalar `Summary.*` columns retain scan diagnostics and estimate assumptions. Nested summary collections appear once as separate record types, such as `Summary.UnderutilizedRIs`, instead of repeating in every row. Nested values within a record are JSON. CSV headers include fields from every exported record type, amounts use a decimal point regardless of your system locale, and dates use ISO 8601. Aggregate and detailed records are separate views, not amounts to add together. diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 index fbdb5d305..07fe79ccd 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -280,10 +280,10 @@ resources $subFilter = Get-CostSubscriptionFilter -Subscriptions $Subscriptions if ($subFilter) { $dataset['filter'] = $subFilter } $body = @{ - type = 'AmortizedCost' - timeframe = 'Custom' + type = 'AmortizedCost' + timeframe = 'Custom' timePeriod = $costTimePeriod - dataset = $dataset + dataset = $dataset } | ConvertTo-Json -Depth 10 $path = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" @@ -323,10 +323,10 @@ resources foreach ($sid in $subIds) { try { $body = @{ - type = 'AmortizedCost' - timeframe = 'Custom' + type = 'AmortizedCost' + timeframe = 'Custom' timePeriod = $costTimePeriod - dataset = @{ + dataset = @{ granularity = 'None' aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } grouping = @(@{ type = 'Dimension'; name = 'MeterCategory' }) @@ -349,10 +349,10 @@ resources } # -- 4: Derived KPIs -------------------------------------------------- - $costPerVCpu = if ($totalVCpu -gt 0) { [math]::Round($computeCost / $totalVCpu, 2) } else { 0 } - $costPerVm = if ($vmCount -gt 0) { [math]::Round($computeCost / $vmCount, 2) } else { 0 } - $costPerGb = if ($totalGb -gt 0) { [math]::Round($storageCost / $totalGb, 4) } else { 0 } - $costPerGbRam = if ($totalMemGb -gt 0) { [math]::Round($computeCost / $totalMemGb, 2) } else { 0 } + $costPerVCpu = if ($totalVCpu -gt 0) { $computeCost / $totalVCpu } else { 0 } + $costPerVm = if ($vmCount -gt 0) { $computeCost / $vmCount } else { 0 } + $costPerGb = if ($totalGb -gt 0) { $storageCost / $totalGb } else { 0 } + $costPerGbRam = if ($totalMemGb -gt 0) { $computeCost / $totalMemGb } else { 0 } $totalKnown = $computeCost + $storageCost $computeSharePct = if ($totalKnown -gt 0) { [math]::Round(100 * $computeCost / $totalKnown, 1) } else { 0 } @@ -382,29 +382,29 @@ resources } return [PSCustomObject]@{ - HasData = $hasData - Currency = Resolve-CurrencyLabel -Seen $currenciesSeen + HasData = $hasData + Currency = Resolve-CurrencyLabel -Seen $currenciesSeen CostPeriodStartUtc = $costPeriodStartUtc - CostPeriodEndUtc = $costPeriodEndUtc - ComputeCost = [math]::Round($computeCost, 2) - StorageCost = [math]::Round($storageCost, 2) - ComputeSharePct = $computeSharePct - StorageSharePct = $storageSharePct - VmCount = $vmCount - TotalVCpu = $totalVCpu - TotalMemoryGb = [math]::Round($totalMemGb, 0) - DiskGb = [math]::Round($diskGb, 1) - BlobFileGb = [math]::Round($blobFileGb, 1) - TotalStorageGb = [math]::Round($totalGb, 1) - CostPerVCpu = $costPerVCpu - CostPerGbRam = $costPerGbRam - CostPerVm = $costPerVm - CostPerGb = $costPerGb - VCpuExact = $vcpuExact - BlobFileOk = $blobFileOk - CostScope = $costScope - Period = 'MonthToDate' - ScannedSubs = $Subscriptions.Count - Note = ($notes -join ' ') + CostPeriodEndUtc = $costPeriodEndUtc + ComputeCost = [math]::Round($computeCost, 2) + StorageCost = [math]::Round($storageCost, 2) + ComputeSharePct = $computeSharePct + StorageSharePct = $storageSharePct + VmCount = $vmCount + TotalVCpu = $totalVCpu + TotalMemoryGb = [math]::Round($totalMemGb, 0) + DiskGb = [math]::Round($diskGb, 1) + BlobFileGb = [math]::Round($blobFileGb, 1) + TotalStorageGb = [math]::Round($totalGb, 1) + CostPerVCpu = $costPerVCpu + CostPerGbRam = $costPerGbRam + CostPerVm = $costPerVm + CostPerGb = $costPerGb + VCpuExact = $vcpuExact + BlobFileOk = $blobFileOk + CostScope = $costScope + Period = 'MonthToDate' + ScannedSubs = $Subscriptions.Count + Note = ($notes -join ' ') } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 index f8187f899..f853679d9 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 @@ -383,11 +383,14 @@ function ConvertFrom-ExportTagString { if ([string]::IsNullOrWhiteSpace($Raw)) { return $out } $text = $Raw.Trim() if (-not $text.StartsWith('{')) { $text = '{' + $text + '}' } - $parsed = $text | ConvertFrom-Json -ErrorAction Stop - if ($parsed -isnot [pscustomobject]) { throw 'Export tags must be a JSON object; tag cost coverage is incomplete.' } - foreach ($property in $parsed.PSObject.Properties) { + $parsed = $text | ConvertFrom-Json -AsHashtable -ErrorAction Stop + if ($parsed -isnot [System.Collections.IDictionary]) { throw 'Export tags must be a JSON object; tag cost coverage is incomplete.' } + foreach ($property in $parsed.GetEnumerator()) { if ($null -ne $property.Value -and $property.Value -isnot [string]) { throw 'Export tag values must be strings; tag cost coverage is incomplete.' } - $out[$property.Name] = [string]$property.Value + if ($out.ContainsKey($property.Key) -and $out[$property.Key] -cne [string]$property.Value) { + $out[$property.Key] = '(conflicting tag values)' + } + else { $out[$property.Key] = [string]$property.Value } } return $out } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 index 204dfa054..c8a048c51 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -143,6 +143,18 @@ function Get-BudgetKpiData { return @{ Error = $null; Currency = $currency; TotalBudget = $totalBudget; TotalActual = $totalActual; Percentages = $percentages.ToArray() } } +function Format-FinOpsUnitRate { + param($Value, [string]$Currency) + + if ($null -eq $Value -or [string]::IsNullOrWhiteSpace($Currency)) { return 'Unavailable' } + try { + $amount = Get-HubCostValue -Row ([pscustomobject]@{ Value = $Value }) -Column 'Value' + $format = if ($amount -ne 0 -and [math]::Abs($amount) -lt 0.00000001) { '0.########E+0' } else { '0.########' } + return "$Currency $($amount.ToString($format, [cultureinfo]::InvariantCulture))" + } + catch { return 'Unavailable' } +} + function Get-KpiComputedValue { param([string]$KpiId, $Data, $Catalog) @@ -150,7 +162,7 @@ function Get-KpiComputedValue { 'cost-per-gb-stored' { $v = Get-ScanField $Data 'CostPerGb' $cur = Get-ScanField $Data 'Currency' - if ($null -ne $v -and $v -gt 0) { return (New-KpiValue "$cur $v per GB / month" ([double]$v)) } + if ($null -ne $v -and $v -gt 0) { return (New-KpiValue "$(Format-FinOpsUnitRate -Value $v -Currency $cur) per GB (month-to-date)" ([double]$v)) } } 'hourly-cost-per-cpu-core' { $v = Get-ScanField $Data 'CostPerVCpu' @@ -167,15 +179,15 @@ function Get-KpiComputedValue { $periodStart = $periodEnd.Date.AddDays(1 - $periodEnd.Day) } $elapsedHours = [math]::Max(($periodEnd - $periodStart).TotalHours, 1) - $hourly = [math]::Round([double]$v / $elapsedHours, 4) - return (New-KpiValue "$cur $hourly per vCPU / hour" $hourly) + $hourly = [double]$v / $elapsedHours + return (New-KpiValue "$(Format-FinOpsUnitRate -Value $hourly -Currency $cur) per vCPU / hour" $hourly) } } 'effective-avg-compute-cost-per-core' { $v = Get-ScanField $Data 'CostPerVCpu' $cur = Get-ScanField $Data 'Currency' # Month-to-date, not a full month, so say so rather than implying a run rate. - if ($null -ne $v -and $v -gt 0) { return (New-KpiValue "$cur $v per vCPU (month-to-date)" ([double]$v)) } + if ($null -ne $v -and $v -gt 0) { return (New-KpiValue "$(Format-FinOpsUnitRate -Value $v -Currency $cur) per vCPU (month-to-date)" ([double]$v)) } } 'commitment-utilization-score' { # Get-CommitmentUtilization seeds both averages to 0 and only fills the diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index 668ea8ce5..dfa4cc02a 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -37,13 +37,9 @@ param() # - Storage Blob Data Reader RBAC on the Hub storage account ########################################################################### -# Helper: Convert JSON to hashtable (PS 5.1 compatible — no -AsHashtable) function ConvertTo-HashtableFromJson { param([string]$Json) - $obj = $Json | ConvertFrom-Json -ErrorAction Stop - $ht = @{} - foreach ($p in $obj.PSObject.Properties) { $ht[$p.Name] = $p.Value } - return $ht + return ConvertFrom-ExportTagString -Raw $Json } function Get-FinOpsParquetCachePath { @@ -64,7 +60,7 @@ function Get-ParquetPayloadFile { # Native payloads are .dll on Windows but .so/.dylib elsewhere, and a # .dll-only filter would leave those unhashed on Linux and macOS. Get-ChildItem -LiteralPath $r -Recurse -File -ErrorAction SilentlyContinue | - Where-Object { $_.Name -match '\.(dll|dylib|so)(\.\d+)*$' } + Where-Object { $_.Name -match '\.(dll|dylib|so)(\.\d+)*$' } } } return @($files | Sort-Object FullName) @@ -640,6 +636,7 @@ function Read-FinOpsHubData { } $allData = [System.Collections.Generic.List[PSCustomObject]]::new() + $loadedFormat = $null $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "FinOpsHub-$([guid]::NewGuid().ToString('N').Substring(0,8))" New-Item -ItemType Directory -Path $tempDir -Force | Out-Null @@ -675,6 +672,7 @@ function Read-FinOpsHubData { Get-AzDataLakeGen2ItemContent -Context $ctx -FileSystem 'ingestion' -Path $blob.Path -Destination $localFile -Force -ErrorAction Stop | Out-Null $rows = @(Read-ParquetFile -Path $localFile -ErrorAction Stop) if ($rows -and @($rows).Count -gt 0) { + $loadedFormat = 'Parquet' foreach ($row in $rows) { $allData.Add($row) } Write-Host " Loaded $(@($rows).Count) rows from $(Split-Path $blob.Path -Leaf)" -ForegroundColor DarkGray } @@ -738,6 +736,7 @@ function Read-FinOpsHubData { Get-AzDataLakeGen2ItemContent -Context $ctx -FileSystem 'msexports' -Path $blob.Path -Destination $localFile -Force -ErrorAction Stop | Out-Null $rows = Import-Csv -Path $localFile -ErrorAction Stop if ($rows -and @($rows).Count -gt 0) { + $loadedFormat = 'CSV' foreach ($row in $rows) { $allData.Add($row) } $periodRows += @($rows).Count } @@ -769,8 +768,7 @@ function Read-FinOpsHubData { } if ($allData.Count -gt 0) { - $source = if ($allData[0].PSObject.Properties.Name -contains 'x_SkuTier') { 'CSV' } else { 'parquet' } - Write-Host " Total rows from Hub ($source): $($allData.Count)" -ForegroundColor Green + Write-Host " Total rows from Hub ($loadedFormat): $($allData.Count)" -ForegroundColor Green } if ($wanted.Count -gt 0) { @@ -932,6 +930,9 @@ function Resolve-HubCostColumn { foreach ($column in $candidates) { if ($Props -contains $column) { return $column } } + if ($CostBasis -eq 'AmortizedCost') { + throw 'AmortizedCost is unavailable in the selected Hub data. This scan requires EffectiveCost from a FOCUS export. Billed cost is not substituted. Use a FOCUS export with EffectiveCost or select API for a separate live scan.' + } throw "No $CostBasis column is available; cost results are incomplete." } @@ -1230,7 +1231,7 @@ function ConvertTo-TagInventoryFromHub { if (-not $tagNames.ContainsKey($tName)) { $tagNames[$tName] = @{ - Values = @{} + Values = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) TotalResources = 0 } } diff --git a/src/powershell/Public/Start-FinOpsMultitool.ps1 b/src/powershell/Public/Start-FinOpsMultitool.ps1 index 574e14a30..74a1c0cff 100644 --- a/src/powershell/Public/Start-FinOpsMultitool.ps1 +++ b/src/powershell/Public/Start-FinOpsMultitool.ps1 @@ -13,8 +13,9 @@ and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance. - Results are rendered in the terminal. Exports are one CSV file per scan module, an - HTML report, and a text summary. + Results are rendered in the terminal and saved automatically on the machine running + the command. Each run gets a private folder with one CSV file per selected scan, + an HTML report, and a text summary. Failed or empty scans have a CSV status record. The scan modules are read-only. The TUI requires PowerShell 7 or later on Windows, macOS, and Linux, the Az modules (Az.Accounts, @@ -29,8 +30,12 @@ the tool discovers all accessible subscriptions. .PARAMETER OutputPath - Optional directory for exported result files. Defaults to a FinOpsResults folder in your - home directory. + Optional local parent directory for reports. Each run creates a new timestamped + subfolder and never overwrites earlier reports. The default is FinOpsToolkit/Multitool/Reports + under the current user's LocalApplicationData directory, usually LOCALAPPDATA on Windows. + Git repositories, UNC paths, mapped Windows network drives, symbolic links, and + junctions are rejected. Unix network mounts aren't detected; choose a local filesystem. + Reports contain sensitive cost and resource data; keep custom destinations outside synced folders. .PARAMETER Scans Optional list of scans to run, replacing the default selection. Accepts either the @@ -48,7 +53,7 @@ Runs without prompting, for automation and scheduled jobs. Every choice comes from the parameters or their defaults: all accessible subscriptions in the current tenant unless SubscriptionId is set, a configured or detected hub or the Cost Management API unless DataSource is - set, and results are exported only when OutputPath is supplied. + set. Reports are saved automatically even when OutputPath is omitted. .EXAMPLE Start-FinOpsMultitool @@ -62,10 +67,10 @@ Launches the TUI scoped to a single subscription. .EXAMPLE - Start-FinOpsMultitool -NonInteractive -Scans Get-OrphanedResources, Get-IdleVMs -OutputPath './results' + Start-FinOpsMultitool -NonInteractive -Scans Get-OrphanedResources, Get-IdleVMs - Runs two scans without prompting and writes the CSV output to the results folder, - which is the shape to use from a pipeline or scheduled job. + Runs two scans without prompting and saves CSV, HTML, and text reports in a new + private run folder under the current user's local application data. .LINK https://aka.ms/ftk/Start-FinOpsMultitool diff --git a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 index 86ee806f2..1f5950064 100644 --- a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 +++ b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 @@ -101,14 +101,192 @@ Describe 'FinOps Multitool safety' { } } + Context 'Automatic report storage' { + BeforeAll { + $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + foreach ($definition in $launcherAst.FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -in @('Get-FinOpsReportRoot', 'Assert-FinOpsReportPath', 'New-FinOpsReportDirectory', 'Write-FinOpsReportFile', + 'Show-ResultsSummary', 'Write-SectionHeader', 'Write-ColorizedLine', 'Protect-FinOpsExportText', 'ConvertTo-FinOpsExportCell', 'ConvertTo-FinOpsExportRows') + }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } + } + + It 'Creates distinct run folders without changing existing reports' { + $root = Join-Path $TestDrive 'reports' + + $first = New-FinOpsReportDirectory -OutputPath $root + Write-FinOpsReportFile -Directory $first -Name 'ScanSummary.txt' -Lines @('First run') + $second = New-FinOpsReportDirectory -OutputPath $root + + $first | Should -Not -Be $second + Split-Path $first -Parent | Should -Be $root + Split-Path $second -Parent | Should -Be $root + Get-Content -LiteralPath (Join-Path $first 'ScanSummary.txt') | Should -Be 'First run' + { Write-FinOpsReportFile -Directory $first -Name 'ScanSummary.txt' -Lines @('Replacement') } | Should -Throw + Get-Content -LiteralPath (Join-Path $first 'ScanSummary.txt') | Should -Be 'First run' + } + + It 'Rejects a Git worktree before creating a report folder' -ForEach @( + @{ Marker = 'directory' } + @{ Marker = 'file' } + ) { + $repository = Join-Path $TestDrive "repository-$Marker" + [void](New-Item -ItemType Directory -Path $repository) + $gitMarker = Join-Path $repository '.git' + if ($Marker -eq 'directory') { [void](New-Item -ItemType Directory -Path $gitMarker) } + else { Set-Content -LiteralPath $gitMarker -Value 'gitdir: elsewhere' } + $target = Join-Path $repository 'nested/reports' + + { New-FinOpsReportDirectory -OutputPath $target } | Should -Throw '*Git*' + + Test-Path -LiteralPath $target | Should -BeFalse + } + + It 'Rejects network and provider paths before writing data ()' -ForEach @( + @{ Destination = '\\server\share\reports' } + @{ Destination = 'https://example.test/reports' } + @{ Destination = 'Env:reports' } + ) { + { New-FinOpsReportDirectory -OutputPath $Destination } | Should -Throw '*local*' + } + + It 'Uses per-user local application data rather than the working directory' { + $base = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData, [Environment+SpecialFolderOption]::DoNotVerify) + + Get-FinOpsReportRoot | Should -Be (Join-Path $base 'FinOpsToolkit/Multitool/Reports') + } + + It 'Creates the default reports folder for a fresh Linux application-data path' -Skip:(-not $IsLinux) { + $applicationData = Join-Path $TestDrive 'fresh-application-data' + $definitions = @('Get-FinOpsReportRoot', 'Assert-FinOpsReportPath', 'New-FinOpsReportDirectory', 'Write-FinOpsReportFile') | + ForEach-Object { "function $_ { $((Get-Command $_).Definition) }" } + $scriptText = "`$ErrorActionPreference = 'Stop'`n" + ($definitions -join "`n") + "`nNew-FinOpsReportDirectory" + $startInfo = [System.Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($scriptText)))) { + $startInfo.ArgumentList.Add($argument) + } + $startInfo.Environment['XDG_DATA_HOME'] = $applicationData + $process = [System.Diagnostics.Process]::new() + try { + $process.StartInfo = $startInfo + [void]$process.Start() + $standardOutput = $process.StandardOutput.ReadToEndAsync() + $standardError = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $process.ExitCode | Should -Be 0 -Because $standardError.GetAwaiter().GetResult() + $run = $standardOutput.GetAwaiter().GetResult().Trim() + Split-Path $run -Parent | Should -Be (Join-Path $applicationData 'FinOpsToolkit/Multitool/Reports') + Test-Path -LiteralPath (Join-Path $run '.gitignore') | Should -BeTrue + } + finally { $process.Dispose() } + } + + It 'Automatically saves all formats without OutputPath or a key press' { + $localRoot = Join-Path $TestDrive 'automatic-local' + Mock Get-FinOpsReportRoot { $localRoot } + Mock Read-Host { throw 'Saving reports must not prompt.' } + Mock Write-Host { } + $subscription = [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' } + $results = @{ 'Get-CostData' = @{ $subscription.Id = @{ Actual = 10; Currency = 'USD'; Name = 'Fixture'; ForecastSource = 'Unavailable' } } } + $modules = @(@{ Fn = 'Get-CostData'; Name = 'Cost Data'; Selected = $true; Category = 'Cost Analysis' }) + + $returned = Show-ResultsSummary -Results $results -Modules $modules -Subscriptions @($subscription) -ErrorAction Stop + + $runs = @(Get-ChildItem -LiteralPath $localRoot -Directory) + $runs.Count | Should -Be 1 + foreach ($name in @('Get-CostData.csv', 'FinOpsReport.html', 'ScanSummary.txt', '.gitignore')) { + Test-Path -LiteralPath (Join-Path $runs[0].FullName $name) | Should -BeTrue + } + (Import-Csv -LiteralPath (Join-Path $runs[0].FullName 'Get-CostData.csv')).Actual | Should -Be '10' + $returned['Get-CostData'][$subscription.Id].Actual | Should -Be 10 + Get-Content -LiteralPath (Join-Path $runs[0].FullName '.gitignore') | Should -Be '*' + Should -Invoke Read-Host -Times 0 -Exactly + } + + It 'Creates private directories and report files' { + $run = New-FinOpsReportDirectory -OutputPath (Join-Path $TestDrive 'private') + $path = Join-Path $run 'ScanSummary.txt' + Write-FinOpsReportFile -Directory $run -Name 'ScanSummary.txt' -Lines @('Fixture') + + if ($IsWindows) { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + try { + $directoryAcl = Get-Acl -LiteralPath $run + $directoryAcl.AreAccessRulesProtected | Should -BeTrue + foreach ($acl in @($directoryAcl, (Get-Acl -LiteralPath $path))) { + $rules = @($acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) + $rules.Count | Should -BeGreaterThan 0 + foreach ($rule in $rules) { $rule.IdentityReference.Value | Should -Be $identity.User.Value } + } + } + finally { $identity.Dispose() } + } + elseif ('System.IO.UnixFileMode' -as [type]) { + [int][System.IO.File]::GetUnixFileMode($run) | Should -Be 448 + [int][System.IO.File]::GetUnixFileMode($path) | Should -Be 384 + } + } + + It 'Rejects a bare Git repository' { + $repository = Join-Path $TestDrive 'bare' + [void](New-Item -ItemType Directory -Path (Join-Path $repository 'objects') -Force) + Set-Content -LiteralPath (Join-Path $repository 'HEAD') -Value 'ref: refs/heads/main' + + { New-FinOpsReportDirectory -OutputPath (Join-Path $repository 'reports') } | Should -Throw '*Git*' + + Test-Path -LiteralPath (Join-Path $repository 'reports') | Should -BeFalse + } + + It 'Does not create Git metadata from a report destination' { + $parent = Join-Path $TestDrive 'not-a-repository' + [void](New-Item -ItemType Directory -Path $parent) + + { New-FinOpsReportDirectory -OutputPath (Join-Path $parent '.git/reports') } | Should -Throw '*Git*' + + Test-Path -LiteralPath (Join-Path $parent '.git') | Should -BeFalse + } + + It 'Refuses links and junctions in the destination path' { + $target = Join-Path $TestDrive 'link-target' + $link = Join-Path $TestDrive 'report-link' + [void](New-Item -ItemType Directory -Path $target) + $linkType = if ($IsWindows) { 'Junction' } else { 'SymbolicLink' } + [void](New-Item -ItemType $linkType -Path $link -Target $target) + try { + { New-FinOpsReportDirectory -OutputPath (Join-Path $link 'reports') } | Should -Throw '*links or junctions*' + Test-Path -LiteralPath (Join-Path $target 'reports') | Should -BeFalse + } + finally { Remove-Item -LiteralPath $link -Force } + } + + It 'Rechecks Git ancestry before writing report content' { + $run = New-FinOpsReportDirectory -OutputPath (Join-Path $TestDrive 'became-repository') + [void](New-Item -ItemType Directory -Path (Join-Path $run '.git')) + + { Write-FinOpsReportFile -Directory $run -Name 'ScanSummary.txt' -Lines @('Sensitive fixture') } | Should -Throw '*Git*' + + Test-Path -LiteralPath (Join-Path $run 'ScanSummary.txt') | Should -BeFalse + } + + It 'Rejects path traversal in report file names' { + $run = New-FinOpsReportDirectory -OutputPath (Join-Path $TestDrive 'file-names') + + { Write-FinOpsReportFile -Directory $run -Name '../escaped.txt' -Lines @('Fixture') } | Should -Throw '*file name*' + { Write-FinOpsReportFile -Directory $run -Name 'ScanSummary.txt:stream' -Lines @('Fixture') } | Should -Throw '*file name*' + } + } + Context 'CSV export projections' { BeforeAll { $launcher = Join-Path $script:ModuleRoot 'Invoke-FinOpsMultitool.ps1' $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile($launcher, [ref]$null, [ref]$null) foreach ($definition in $launcherAst.FindAll({ - $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $args[0].Name -in @('Protect-FinOpsExportText', 'ConvertTo-FinOpsExportCell', 'ConvertTo-FinOpsExportRows') - }, $true)) { + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -in @('Protect-FinOpsExportText', 'ConvertTo-FinOpsExportCell', 'ConvertTo-FinOpsExportRows') + }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } } @@ -121,10 +299,11 @@ Describe 'FinOps Multitool safety' { try { [System.Threading.Thread]::CurrentThread.CurrentCulture = [cultureinfo]::GetCultureInfo($Culture) $data = @{ 'sub-a' = @{ - Actual = [decimal]100.25; Credit = -20.5; Currency = 'EUR'; Name = '-formula' - ActualPeriodStart = [datetime]::new(2026, 9, 1, 0, 0, 0, [DateTimeKind]::Utc) - CapturedAt = [datetimeoffset]::new(2026, 9, 2, 3, 4, 5, [timespan]::FromHours(2)) - } } + Actual = [decimal]100.25; Credit = -20.5; Currency = 'EUR'; Name = '-formula' + ActualPeriodStart = [datetime]::new(2026, 9, 1, 0, 0, 0, [DateTimeKind]::Utc) + CapturedAt = [datetimeoffset]::new(2026, 9, 2, 3, 4, 5, [timespan]::FromHours(2)) + } + } $row = @(ConvertTo-FinOpsExportRows -Fn 'Get-CostData' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv)[0] @@ -141,15 +320,15 @@ Describe 'FinOps Multitool safety' { It 'Exports tag values, amounts, and currencies from scanner row objects' { $data = [pscustomobject]@{ - CostByTag = @{ + CostByTag = @{ CostCenter = @( [pscustomobject]@{ TagValue = 'team-a'; Cost = 100.25; Currency = 'EUR' } [pscustomobject]@{ TagValue = 'team-b'; Cost = -20; Currency = 'EUR' } ) } - TagsQueried = @('CostCenter') - NoTagsFound = $false - Source = 'Kusto' + TagsQueried = @('CostCenter') + NoTagsFound = $false + Source = 'Kusto' ResourceCostSeen = 80.25 } @@ -172,8 +351,8 @@ Describe 'FinOps Multitool safety' { $data = @{ Reservations = @([pscustomobject]@{ ReservationId = 'ri-1'; AvgUtilization = 90 }) SavingsPlans = @() - HasData = $true - Note = 'Validated billing scope' + HasData = $true + Note = 'Validated billing scope' } $row = @(ConvertTo-FinOpsExportRows -Fn 'Get-CommitmentUtilization' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv)[0] @@ -189,7 +368,8 @@ Describe 'FinOps Multitool safety' { $row = @(ConvertTo-FinOpsExportRows -Fn 'Get-CostByTag' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv)[0] - $row.RecordType | Should -Be 'Summary' + $row.RecordType | Should -Be 'Status' + $row.Status | Should -Be 'No data' $row.'Summary.NoTagsFound' | Should -Be 'True' $row.'Summary.Source' | Should -Be 'Kusto' $row.'Summary.Note' | Should -Be $data.Note @@ -198,12 +378,12 @@ Describe 'FinOps Multitool safety' { It 'Exports both commitment families and the underutilized view once' { $reservation = [pscustomobject]@{ ReservationId = 'ri-1'; SkuName = 'Standard_D2s_v5'; AvgUtilization = 50 } $data = [pscustomobject]@{ - Reservations = @($reservation) - SavingsPlans = @([pscustomobject]@{ BenefitId = 'sp-1'; BenefitOrderId = 'order-1'; AvgUtilization = 75 }) + Reservations = @($reservation) + SavingsPlans = @([pscustomobject]@{ BenefitId = 'sp-1'; BenefitOrderId = 'order-1'; AvgUtilization = 75 }) UnderutilizedRIs = @($reservation) - RICount = 1 - SPCount = 1 - HasData = $true + RICount = 1 + SPCount = 1 + HasData = $true } $rows = @(ConvertTo-FinOpsExportRows -Fn 'Get-CommitmentUtilization' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv) @@ -220,8 +400,8 @@ Describe 'FinOps Multitool safety' { $sizes = @() foreach ($count in @(100, 200)) { $reservations = @(foreach ($index in 1..$count) { - [pscustomobject]@{ ReservationId = "reservation-$index"; AvgUtilization = 50; SkuName = 'Standard_D2s_v5' } - }) + [pscustomobject]@{ ReservationId = "reservation-$index"; AvgUtilization = 50; SkuName = 'Standard_D2s_v5' } + }) $data = [pscustomobject]@{ Reservations = $reservations; SavingsPlans = @(); UnderutilizedRIs = $reservations; RICount = $count; HasData = $true } $rows = @(ConvertTo-FinOpsExportRows -Fn 'Get-CommitmentUtilization' -Data $data) @@ -272,8 +452,8 @@ Describe 'FinOps Multitool safety' { It 'Keeps per-subscription monthly trends alongside aggregate months' { $data = [pscustomobject]@{ - HasData = $true - Months = @([pscustomobject]@{ Month = 'Aug 2026'; Cost = 30; Currency = 'USD' }) + HasData = $true + Months = @([pscustomobject]@{ Month = 'Aug 2026'; Cost = 30; Currency = 'USD' }) BySubscription = @{ 'sub-a' = @([pscustomobject]@{ Month = 'Aug 2026'; Cost = 10; Currency = 'USD' }) 'sub-b' = @([pscustomobject]@{ Month = 'Aug 2026'; Cost = 20; Currency = 'USD' }) @@ -298,11 +478,30 @@ Describe 'FinOps Multitool safety' { $rows = @(ConvertTo-FinOpsExportRows -Fn 'Get-CommitmentUtilization' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv) $rows.Count | Should -Be 1 - $rows[0].RecordType | Should -Be 'Summary' + $rows[0].RecordType | Should -Be 'Status' + $rows[0].Status | Should -Be 'Error' + $rows[0].Error | Should -Be 'Missing billing access' $rows[0].'Summary.AccessDenied' | Should -Be 'True' $rows[0].'Summary.Note' | Should -Be 'Missing billing access' } + It 'Labels empty wrapper results while preserving summary collections' { + $data = [pscustomobject]@{ + Orphans = @(); HasData = $false; TotalCount = 0; Note = 'No orphaned resources found' + CheckedScopes = @('sub-a', 'sub-b') + } + + $rows = @(ConvertTo-FinOpsExportRows -Fn 'Get-OrphanedResources' -Data $data | ConvertTo-Csv -NoTypeInformation | ConvertFrom-Csv) + + $statusRows = @($rows | Where-Object RecordType -EQ 'Status') + $statusRows.Count | Should -Be 1 + $statusRows[0].Status | Should -Be 'No data' + $statusRows[0].Scan | Should -Be 'Get-OrphanedResources' + $statusRows[0].'Summary.HasData' | Should -Be 'False' + $statusRows[0].'Summary.Note' | Should -Be $data.Note + ($rows | Where-Object RecordType -EQ 'Summary.CheckedScopes').Value | Should -Be @('sub-a', 'sub-b') + } + It 'Preserves cost source and period while protecting formula text' { $data = @{ 'sub-a' = @{ Actual = -25; Forecast = $null; Currency = 'EUR'; ActualPeriod = '2026-08'; ForecastSource = 'Unavailable'; Name = '=1+1' } } @@ -329,14 +528,15 @@ Describe 'FinOps Multitool safety' { $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) $switches = $launcherAst.FindAll({ $args[0] -is [System.Management.Automation.Language.SwitchStatementAst] }, $true) $branch = @($switches.Clauses | Where-Object { - $_.Item1.Value -eq 'Get-CostByTag' -and $_.Item2.Extent.Text.Contains('No cost data was returned') - }) + $_.Item1.Value -eq 'Get-CostByTag' -and $_.Item2.Extent.Text.Contains('No cost data was returned') + }) $branch.Count | Should -Be 1 $data = [pscustomobject]@{ CostByTag = @{ CostCenter = @(if ($HasRows) { - [pscustomobject]@{ TagValue = 'team'; Cost = $Tagged; Currency = 'USD' } - [pscustomobject]@{ TagValue = '(untagged)'; Cost = $Untagged; Currency = 'USD' } - }) } + [pscustomobject]@{ TagValue = 'team'; Cost = $Tagged; Currency = 'USD' } + [pscustomobject]@{ TagValue = '(untagged)'; Cost = $Untagged; Currency = 'USD' } + }) + } } $guidanceItems = @() $body = ($branch[0].Item2.Statements | ForEach-Object { $_.Extent.Text }) -join "`n" @@ -363,9 +563,11 @@ Describe 'FinOps Multitool safety' { } else { [pscustomobject]@{ CostByTag = @{ CostCenter = @( - [pscustomobject]@{ TagValue = 'team'; Cost = $Tagged } - [pscustomobject]@{ TagValue = '(untagged)'; Cost = $Untagged } - ) } } + [pscustomobject]@{ TagValue = 'team'; Cost = $Tagged } + [pscustomobject]@{ TagValue = '(untagged)'; Cost = $Untagged } + ) + } + } } $result = Add-KpiInsights -Result @{ tool = 'scan_cost_by_tag'; data = $data } foreach ($insight in $result.kpiInsights | Where-Object kpiId -In @('pct-costs-untagged', 'pct-costs-unallocated', 'tagging-policy-compliant')) { @@ -387,9 +589,11 @@ Describe 'FinOps Multitool safety' { } else { [pscustomobject]@{ CostByTag = @{ CostCenter = @( - [pscustomobject]@{ TagValue = 'team'; Cost = 80.0 } - [pscustomobject]@{ TagValue = '(untagged)'; Cost = 20.0 } - ) } } + [pscustomobject]@{ TagValue = 'team'; Cost = 80.0 } + [pscustomobject]@{ TagValue = '(untagged)'; Cost = 20.0 } + ) + } + } } (Get-KpiComputedValue -KpiId 'pct-costs-untagged' -Data $data).Value | Should -Be 20 (Get-KpiComputedValue -KpiId 'tagging-policy-compliant' -Data $data).Value | Should -Be 80 @@ -422,7 +626,7 @@ Describe 'FinOps Multitool safety' { $category = if ($request.type -eq 'ActualCost') { 'UnusedReservation' } else { 'Reservation' } $properties = @{ columns = @(@{ name = 'Currency' }, @{ name = $dimension }, @{ name = 'Cost' }) - rows = @(, @($fixtureCurrency, $category, 100.0)) + rows = @(, @($fixtureCurrency, $category, 100.0)) } [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } } @@ -553,7 +757,7 @@ Describe 'FinOps Multitool safety' { $currency = if ($isNext) { 'USD' } else { 'EUR' } $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'ChargeType' }, @{ name = 'Currency' }) - rows = @(, @(100.0, 'UnusedReservation', $currency)) + rows = @(, @(100.0, 'UnusedReservation', $currency)) } if (-not $isNext) { $properties.nextLink = "$Path&page=2" } [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } @@ -579,9 +783,11 @@ Describe 'FinOps Multitool safety' { $dimension = if ($request.type -eq 'ActualCost') { 'ChargeType' } else { 'PricingModel' } $category = if ($request.type -eq 'ActualCost') { 'UnusedReservation' } else { 'Reservation' } [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ - columns = @(@{ name = $dimension }, @{ name = 'Currency' }, @{ name = 'Cost' }) - rows = @(, @($category, $currency, 100.0)) - } } | ConvertTo-Json -Depth 8) } + columns = @(@{ name = $dimension }, @{ name = 'Currency' }, @{ name = 'Cost' }) + rows = @(, @($category, $currency, 100.0)) + } + } | ConvertTo-Json -Depth 8) + } } $subscriptions = @( [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'First' } @@ -640,9 +846,11 @@ Describe 'FinOps Multitool safety' { $dimension = if ($request.type -eq 'ActualCost') { 'ChargeType' } else { 'PricingModel' } $category = if ($request.type -eq 'ActualCost') { 'Usage' } else { 'Reservation' } [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ - columns = @(@{ name = 'Cost' }, @{ name = $dimension }, @{ name = 'Currency' }) - rows = @(, @(100.0, $category, 'EUR')) - } } | ConvertTo-Json -Depth 8) } + columns = @(@{ name = 'Cost' }, @{ name = $dimension }, @{ name = 'Currency' }) + rows = @(, @(100.0, $category, 'EUR')) + } + } | ConvertTo-Json -Depth 8) + } } Mock Search-AzGraphSafe { @{ Data = @([pscustomobject]@{ vmSize = 'Standard_D2s_v5'; location = 'eastus' }) } } Mock Get-AhbVmRates { [pscustomobject]@{ HourlyPremium = 0.1 } } @@ -947,10 +1155,11 @@ Describe 'FinOps Multitool cost math' { InModuleScope FinOpsMultitool { $subscriptionId = '44444444-4444-4444-4444-444444444444' $data = [pscustomobject]@{ CostBasis = 'ActualCost'; Rows = @( - [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = 10; Currency = 'USD'; ResourceId = "/subscriptions/$subscriptionId/resourceGroups/test/providers/Microsoft.Compute/disks/test" } - [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = 20; Currency = 'USD'; ResourceId = '' } - [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = -5; Currency = 'USD'; ResourceId = $null } - ) } + [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = 10; Currency = 'USD'; ResourceId = "/subscriptions/$subscriptionId/resourceGroups/test/providers/Microsoft.Compute/disks/test" } + [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = 20; Currency = 'USD'; ResourceId = '' } + [pscustomobject]@{ SubscriptionId = $subscriptionId; Cost = -5; Currency = 'USD'; ResourceId = $null } + ) + } $subscriptions = @([pscustomobject]@{ Id = $subscriptionId; Name = 'test' }) $rows = @(ConvertTo-ResourceCostsFromExport -ExportData $data -Subscriptions $subscriptions) @@ -988,10 +1197,10 @@ Describe 'FinOps Multitool cost math' { $subscriptionId = '44444444-4444-4444-4444-444444444444' $data = [pscustomobject]@{ CostBasis = 'ActualCost' - Rows = @([pscustomobject]@{ - SubscriptionId = $subscriptionId; Cost = 100; Currency = 'EUR'; Date = '2026-08-31' - ResourceId = "/subscriptions/$subscriptionId/resourceGroups/test/providers/Microsoft.Compute/disks/test" - }) + Rows = @([pscustomobject]@{ + SubscriptionId = $subscriptionId; Cost = 100; Currency = 'EUR'; Date = '2026-08-31' + ResourceId = "/subscriptions/$subscriptionId/resourceGroups/test/providers/Microsoft.Compute/disks/test" + }) } $subscriptions = @([pscustomobject]@{ Id = $subscriptionId; Name = 'test' }) @@ -1036,9 +1245,9 @@ Describe 'FinOps Multitool cost math' { $exportData = [pscustomobject]@{ CostBasis = 'ActualCost' - Currency = 'USD' - ColMap = [pscustomobject]@{ Cost = 'Cost'; SubscriptionId = 'SubscriptionId'; ResourceId = $null } - Rows = @( + Currency = 'USD' + ColMap = [pscustomobject]@{ Cost = 'Cost'; SubscriptionId = 'SubscriptionId'; ResourceId = $null } + Rows = @( [pscustomobject]@{ SubscriptionId = $selected; Cost = '10.00' } [pscustomobject]@{ SubscriptionId = $other; Cost = '999.00' } ) @@ -1153,9 +1362,10 @@ Describe 'FinOps Multitool cost math' { Mock Get-PlainAccessToken { 'test-token' } Mock Get-StorageBlobList { @{ Listed = $true; Blobs = @( - [pscustomobject]@{ Name = 'export/run/part1.csv'; LastModified = [datetime]'2026-09-15' } - [pscustomobject]@{ Name = 'export/run/part2.csv'; LastModified = [datetime]'2026-09-15' } - ) } + [pscustomobject]@{ Name = 'export/run/part1.csv'; LastModified = [datetime]'2026-09-15' } + [pscustomobject]@{ Name = 'export/run/part2.csv'; LastModified = [datetime]'2026-09-15' } + ) + } } Mock Get-StorageBlobBytes { if ($Uri -like '*part2.csv') { return $null } @@ -1282,11 +1492,11 @@ Describe 'FinOps Multitool cost math' { $subscriptionId = '66666666-6666-6666-6666-666666666666' $targetResourceId = "/subscriptions/$subscriptionId/resourceGroups/test/providers/Microsoft.CognitiveServices/accounts/test" $rows = @([pscustomobject]@{ - SubAccountId = $subscriptionId; SubAccountName = 'test'; BilledCost = 12000; EffectiveCost = 1000 - BillingCurrency = 'USD'; ResourceId = $targetResourceId; ResourceType = 'microsoft.cognitiveservices/accounts' - Tags = '{"CostCenter":"test"}'; ConsumedQuantity = 0 - ChargePeriodStart = '2026-08-31T00:00:00Z' - }) + SubAccountId = $subscriptionId; SubAccountName = 'test'; BilledCost = 12000; EffectiveCost = 1000 + BillingCurrency = 'USD'; ResourceId = $targetResourceId; ResourceType = 'microsoft.cognitiveservices/accounts' + Tags = '{"CostCenter":"test"}'; ConsumedQuantity = 0 + ChargePeriodStart = '2026-08-31T00:00:00Z' + }) Mock Resolve-VmAssociation { $associated = [System.Collections.Generic.HashSet[string]]::new() [void]$associated.Add($targetResourceId) @@ -1301,7 +1511,7 @@ Describe 'FinOps Multitool cost math' { (Get-VmCostBreakdown -VmName 'test' -HubData $rows).Period | Should -Be '2026-08-31 to 2026-08-31' (ConvertTo-AIHubAggregates -HubData $rows).AICost | Should -Be 1000 (ConvertTo-AIHubAggregates -HubData $rows).Period | Should -Be '2026-08-31 to 2026-08-31' - { Resolve-HubCostColumn -Props @('BilledCost') -CostBasis 'AmortizedCost' } | Should -Throw '*AmortizedCost*' + { Resolve-HubCostColumn -Props @('BilledCost') -CostBasis 'AmortizedCost' } | Should -Throw '*AmortizedCost*EffectiveCost*FOCUS*API*' { Resolve-HubCostColumn -Props @('EffectiveCost') -CostBasis 'ActualCost' } | Should -Throw '*ActualCost*' } } @@ -1348,6 +1558,46 @@ Describe 'FinOps Multitool cost math' { ($result.CostByTag.env | Measure-Object Cost -Sum).Sum | Should -Be 25 } } + + It 'Handles case-variant tag keys within the same hub record without double counting' { + InModuleScope FinOpsMultitool { + $rows = @( + [pscustomobject]@{ BilledCost = 10; BillingCurrency = 'USD'; ResourceId = '/subscriptions/test/resources/one'; Tags = '{"project":"shared","Project":"shared","Environment":"Prod"}' } + [pscustomobject]@{ BilledCost = 20; BillingCurrency = 'USD'; ResourceId = '/subscriptions/test/resources/two'; Tags = '{"PROJECT":"shared","Environment":"prod"}' } + [pscustomobject]@{ BilledCost = -5; BillingCurrency = 'USD'; ResourceId = ''; Tags = '' } + ) + + $result = ConvertTo-CostByTagFromHub -HubData $rows + $inventory = ConvertTo-TagInventoryFromHub -HubData $rows + + @($result.TagsQueried | Where-Object { $_ -ieq 'Project' }).Count | Should -Be 1 + ($result.CostByTag.Project | Where-Object TagValue -EQ 'shared').Cost | Should -Be 30 + ($result.CostByTag.Project | Measure-Object Cost -Sum).Sum | Should -Be 25 + ($result.CostByTag.Environment | Where-Object { $_.TagValue -ceq 'Prod' }).Cost | Should -Be 10 + ($result.CostByTag.Environment | Where-Object { $_.TagValue -ceq 'prod' }).Cost | Should -Be 20 + $inventory.TaggedCount | Should -Be 2 + $inventory.TagNames.Project.TotalResources | Should -Be 2 + @($inventory.TagNames.Environment.Values).Count | Should -Be 2 + } + } + + It 'Reports conflicting case-variant tag values once instead of choosing one' { + InModuleScope FinOpsMultitool { + $rows = @( + [pscustomobject]@{ BilledCost = 10; BillingCurrency = 'USD'; ResourceId = '/subscriptions/test/resources/one'; Tags = '{"project":"team-a","Project":"team-b"}' } + [pscustomobject]@{ BilledCost = 20; BillingCurrency = 'USD'; ResourceId = '/subscriptions/test/resources/two'; Tags = '"project":"team-a"' } + ) + + $result = ConvertTo-CostByTagFromHub -HubData $rows + $inventory = ConvertTo-TagInventoryFromHub -HubData $rows + + ($result.CostByTag.Project | Where-Object TagValue -EQ '(conflicting tag values)').Cost | Should -Be 10 + ($result.CostByTag.Project | Where-Object TagValue -EQ 'team-a').Cost | Should -Be 20 + ($result.CostByTag.Project | Measure-Object Cost -Sum).Sum | Should -Be 30 + $inventory.TagNames.Project.TotalResources | Should -Be 2 + ($inventory.TagNames.Project.Values | Where-Object TagValue -EQ '(conflicting tag values)').ResourceCount | Should -Be 1 + } + } } Context 'Amortized query currency' { @@ -1383,6 +1633,37 @@ Describe 'FinOps Multitool cost math' { } Context 'Hub storage completeness' { + It 'Labels the actual reader instead of inferring it from the cost columns' -ForEach @( + @{ Format = 'CSV' } + @{ Format = 'Parquet' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ ExpectedFormat = $Format } { + param($ExpectedFormat) + $useParquet = $ExpectedFormat -eq 'Parquet' + Mock Write-Host { } + Mock New-AzStorageContext { $null } + Mock Install-ParquetReader { $true } + Mock Get-AzDataLakeGen2ChildItem { + if ($FileSystem -eq 'ingestion') { + if ($useParquet) { [pscustomobject]@{ Name = 'part.parquet'; Path = 'Costs/2026/09/part.parquet'; IsDirectory = $false } } + } + else { [pscustomobject]@{ Name = 'part.csv'; Path = 'export/20260901-20260930/202609180001/run/part.csv'; IsDirectory = $false } } + } + Mock Get-AzDataLakeGen2ItemContent { } + Mock Read-ParquetFile { [pscustomobject]@{ BilledCost = 10; BillingCurrency = 'USD'; x_SkuTier = 'Premium' } } + Mock Import-Csv { [pscustomobject]@{ BilledCost = 10; BillingCurrency = 'USD' } } + + $rows = @(Read-FinOpsHubData -StorageAccountName 'fixture' -ResourceGroupName 'fixture' -Months 1) + + $rows.Count | Should -Be 1 + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { + "$Object" -match "Total rows from Hub \($ExpectedFormat\): 1" + } + $csvReads = if ($useParquet) { 0 } else { 1 } + Should -Invoke Import-Csv -Times $csvReads -Exactly + } + } + It 'Propagates a Parquet parsing failure instead of returning an empty dataset' { $missingFile = Join-Path $TestDrive 'unreadable.parquet' { Read-ParquetFile -Path $missingFile } | Should -Throw '*Parquet*incomplete*' @@ -1465,7 +1746,7 @@ Describe 'FinOps Multitool cost math' { Mock Import-Csv { [pscustomobject]@{ BilledCost = 100; BillingCurrency = 'USD'; SubAccountId = '44444444-4444-4444-4444-444444444444' } } { Read-FinOpsHubData -StorageAccountName 'test' -ResourceGroupName 'test' -SubscriptionIds @('44444444-4444-4444-4444-444444444444') } | - Should -Throw '*incomplete*' + Should -Throw '*incomplete*' } } @@ -1478,7 +1759,7 @@ Describe 'FinOps Multitool cost math' { Mock Read-ParquetFile { [pscustomobject]@{ BilledCost = 100; BillingCurrency = 'USD'; SubAccountId = '44444444-4444-4444-4444-444444444444' } } { Read-FinOpsHubData -StorageAccountName 'test' -ResourceGroupName 'test' -SubscriptionIds @('44444444-4444-4444-4444-444444444444', '55555555-5555-5555-5555-555555555555') } | - Should -Throw '*coverage*' + Should -Throw '*coverage*' } } @@ -1565,7 +1846,73 @@ Describe 'FinOps Multitool cost math' { } } + Context 'Cost trend guidance' { + It 'Uses comparable completed months for ' -ForEach @( + @{ Case = 'the reported September data'; Now = '2026-09-18T12:00:00Z'; Dates = @('2026-05-01', '2026-07-01', '2026-08-01', '2026-09-01'); Costs = @(1359.56, 1205.47, 751.48, 441.78); Currencies = @('USD', 'USD', 'USD', 'USD'); Expected = 'decreased 37.7% from Jul 2026 to Aug 2026' } + @{ Case = 'a year boundary'; Now = '2026-02-18T12:00:00Z'; Dates = @('2025-12-01', '2026-01-01', '2026-02-01'); Costs = @(100, 110, 10); Currencies = @('EUR', 'EUR', 'EUR'); Expected = 'increased 10% from Dec 2025 to Jan 2026' } + @{ Case = 'only one completed month'; Now = '2026-09-18T12:00:00Z'; Dates = @('2026-08-01', '2026-09-01'); Costs = @(100, 10); Currencies = @('USD', 'USD'); Expected = 'needs two completed months' } + @{ Case = 'a missing calendar month'; Now = '2026-09-18T12:00:00Z'; Dates = @('2026-05-01', '2026-08-01'); Costs = @(100, 10); Currencies = @('USD', 'USD'); Expected = 'two consecutive completed months' } + @{ Case = 'different billing currencies'; Now = '2026-09-18T12:00:00Z'; Dates = @('2026-07-01', '2026-08-01'); Costs = @(100, 10); Currencies = @('EUR', 'USD'); Expected = 'unknown or different currencies' } + @{ Case = 'a zero baseline'; Now = '2026-09-18T12:00:00Z'; Dates = @('2026-07-01', '2026-08-01'); Costs = @(0, 10); Currencies = @('USD', 'USD'); Expected = 'no positive net cost' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ ModuleRoot = $script:ModuleRoot; Now = $Now; Dates = $Dates; Costs = $Costs; Currencies = $Currencies; Expected = $Expected } { + param($ModuleRoot, $Now, $Dates, $Costs, $Currencies, $Expected) + $fixtureNow = [datetime]::Parse($Now, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal) + Mock Get-Date { $fixtureNow } + $months = @(for ($index = 0; $index -lt $Dates.Count; $index++) { + $monthDate = [datetime]::ParseExact($Dates[$index], 'yyyy-MM-dd', [cultureinfo]::InvariantCulture) + [pscustomobject]@{ Month = $monthDate.ToString('MMM yyyy'); MonthDate = $monthDate; Cost = $Costs[$index]; Currency = $Currencies[$index] } + }) + $data = [pscustomobject]@{ Months = $months } + $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $switches = $launcherAst.FindAll({ $args[0] -is [System.Management.Automation.Language.SwitchStatementAst] }, $true) + $branch = @($switches.Clauses | Where-Object { $_.Item1.Value -eq 'Get-CostTrend' -and $_.Item2.Extent.Text.Contains('$guidanceItems') }) + $branch.Count | Should -Be 1 + $guidanceItems = @() + $body = ($branch[0].Item2.Statements | ForEach-Object { $_.Extent.Text }) -join "`n" + + . ([scriptblock]::Create("param(`$data)`n$body")) $data + + ($guidanceItems.Message -join ' ') | Should -Match ([regex]::Escape($Expected)) + ($guidanceItems.Message -join ' ') | Should -Not -Match '67.5%|Optimization efforts are working|Good cost discipline' + $guidanceItems.Severity | Should -Not -Contain 'Green' + } + } + } + Context 'Hourly cost reporting' { + It 'Keeps small unit costs and hourly rates above zero' { + InModuleScope FinOpsMultitool { + Mock Write-Host { } + Mock Get-Date { [datetime]::new(2026, 9, 17, 0, 0, 0, [DateTimeKind]::Utc) } + Mock Search-AzGraphSafe { + if ($Query -match 'virtualmachines') { + @{ Data = @([pscustomobject]@{ cnt = 4; vmSize = 'Standard_D2s_v5'; loc = 'eastus'; subId = '11111111-1111-1111-1111-111111111111' }) } + } + else { @{ Data = @([pscustomobject]@{ totalGb = 0 }) } } + } + Mock Get-VmSizeCapability { @{ VCpu = 2; MemGb = 8 } } + Mock Get-StorageAccountUsedGb { 0.9 } + Mock Resolve-CostMgId { $null } + Mock Invoke-AzRestMethodWithRetry { + [pscustomobject]@{ StatusCode = 200; Content = '{"properties":{"columns":[{"name":"Cost"},{"name":"MeterCategory"},{"name":"Currency"}],"rows":[[0.12,"Virtual Machines","USD"],[9.08,"Storage","USD"]]}}' } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result = Get-UnitEconomics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + $hourly = Get-KpiComputedValue -KpiId 'hourly-cost-per-cpu-core' -Data $result + + $result.CostPerVCpu | Should -Be 0.015 + $result.CostPerGbRam | Should -Be 0.00375 + $result.CostPerVm | Should -Be 0.03 + (Format-FinOpsUnitRate -Value $result.CostPerGbRam -Currency $result.Currency) | Should -Be 'USD 0.00375' + $hourly.Value | Should -Be 0.0000390625 + $hourly.Display | Should -Be 'USD 0.00003906 per vCPU / hour' + (Format-FinOpsUnitRate -Value 0.000000000001 -Currency 'USD') | Should -Be 'USD 1E-12' + (Format-FinOpsUnitRate -Value 0 -Currency 'USD') | Should -Be 'USD 0' + } + } + It 'Uses the UTC month instead of a local calendar that is still in August' { InModuleScope FinOpsMultitool { Mock Get-Date { [datetime]::new(2026, 9, 1, 2, 0, 0, [DateTimeKind]::Utc) } @@ -1583,10 +1930,10 @@ Describe 'FinOps Multitool cost math' { Mock Get-Date { [datetime]::new(2026, 10, 2, 0, 0, 0, [DateTimeKind]::Utc) } Mock Get-Date { [datetime]::new(2026, 10, 1) } -ParameterFilter { $Day -eq 1 } $data = [pscustomobject]@{ - CostPerVCpu = 384.0 - Currency = 'USD' + CostPerVCpu = 384.0 + Currency = 'USD' CostPeriodStartUtc = [datetime]::new(2026, 9, 1, 0, 0, 0, [DateTimeKind]::Utc) - CostPeriodEndUtc = [datetime]::new(2026, 9, 17, 0, 0, 0, [DateTimeKind]::Utc) + CostPeriodEndUtc = [datetime]::new(2026, 9, 17, 0, 0, 0, [DateTimeKind]::Utc) } $result = Get-KpiComputedValue -KpiId 'hourly-cost-per-cpu-core' -Data $data @@ -1644,9 +1991,10 @@ Describe 'FinOps Multitool cost math' { It 'Reports comparable known budgets using their currency' { InModuleScope FinOpsMultitool { $data = [pscustomobject]@{ Budgets = @( - [pscustomobject]@{ Amount = 1000; ActualSpend = 500; Currency = 'EUR'; TimeGrain = 'Monthly'; Category = 'Cost'; SubscriptionId = 'one'; TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-1) } } - [pscustomobject]@{ Amount = 1000; ActualSpend = 1500; Currency = 'EUR'; TimeGrain = 'Monthly'; Category = 'Cost'; SubscriptionId = 'two'; TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-1) } } - ) } + [pscustomobject]@{ Amount = 1000; ActualSpend = 500; Currency = 'EUR'; TimeGrain = 'Monthly'; Category = 'Cost'; SubscriptionId = 'one'; TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-1) } } + [pscustomobject]@{ Amount = 1000; ActualSpend = 1500; Currency = 'EUR'; TimeGrain = 'Monthly'; Category = 'Cost'; SubscriptionId = 'two'; TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-1) } } + ) + } $variance = Get-KpiComputedValue -KpiId 'variance-budget-vs-actual' -Data $data $burn = Get-KpiComputedValue -KpiId 'budget-burn-rate' -Data $data diff --git a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 index 0f4c66ccd..2578d6af4 100644 --- a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 +++ b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 @@ -202,6 +202,9 @@ function Invoke-FinOpsMultitool { Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-CostData -DataSource $Source -OutputPath $reportPath -NonInteractive -ErrorAction Stop + $runs = @(Get-ChildItem -LiteralPath $reportPath -Directory) + $runs.Count | Should -Be 1 + $reportPath = $runs[0].FullName $result = Get-Variable -Name FinOpsResults -Scope Global -ValueOnly $result.ContainsKey('_error_Get-CostData') | Should -BeFalse $result['Get-CostData']['11111111-1111-1111-1111-111111111111'].Actual | Should -Be 100 @@ -245,9 +248,9 @@ function Invoke-FinOpsMultitool { Set-Variable -Name NonInteractive -Value $false -Scope Local $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:RealMultitoolRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) foreach ($definition in $launcherAst.FindAll({ - $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $args[0].Name -in @('Select-DataSource', 'Read-FinOpsAnswer', 'Invoke-SelectedScans', 'Write-SectionHeader') - }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -in @('Select-DataSource', 'Read-FinOpsAnswer', 'Invoke-SelectedScans', 'Write-SectionHeader') + }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } Mock Read-FinOpsAnswer { '1' } Mock Search-AzGraph { [pscustomobject]@{ name = 'test-hub-storage'; resourceGroup = 'test-hub' } } Mock Resolve-FOHubProvider { @@ -265,11 +268,42 @@ function Invoke-FinOpsMultitool { Should -Invoke Read-FinOpsHubData -Times 0 -Exactly } + It 'Keeps scanner logs separate from progress when the scan ' -ForEach @( + @{ Outcome = 'succeeds'; FailScan = $false } + @{ Outcome = 'fails'; FailScan = $true } + ) { + $shouldFail = $FailScan + $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:RealMultitoolRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + foreach ($definition in $launcherAst.FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -in @('Invoke-SelectedScans', 'Write-SectionHeader') + }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } + Mock Get-TagInventory { + Write-Information 'Scanner detail on its own line.' -InformationAction Continue + if ($shouldFail) { throw "Fixture error on a separate line.`nMore diagnostic detail." } + [pscustomobject]@{ TagNames = @{}; TagCount = 0 } + } + + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + $result = Invoke-SelectedScans -Modules @(@{ Name = 'Tag Inventory'; Fn = 'Get-TagInventory'; Selected = $true }) -Subscriptions $subscriptions -DataSource @{ Source = 'API' } + + Should -Invoke Write-Host -Times 0 -Exactly -ParameterFilter { $NoNewline -or "$Object".Contains("`r") } + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { "$Object" -match '^ \[.+\] 100% \(1/1\) Tag Inventory$' } + if ($shouldFail) { + $result['_error_Get-TagInventory'] | Should -Match 'Fixture error' + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { "$Object" -eq ' FAILED: Tag Inventory' } + } + else { + $result.ContainsKey('_error_Get-TagInventory') | Should -BeFalse + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { "$Object" -match '^ Completed: Tag Inventory' } + } + } + It 'Does not query Hub costs for a Graph-only run with a Kusto override' { $env:FINOPS_HUB_KUSTO_URI = 'http://localhost:8082' Mock Get-TagInventory { [pscustomobject]@{ TagNames = @{}; TotalResources = 0; TaggedCount = 0; UntaggedCount = 0; TagCoverage = 0 } } - Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-TagInventory -DataSource GraphOnly -NonInteractive -ErrorAction Stop + Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-TagInventory -DataSource GraphOnly -OutputPath (Join-Path $TestDrive 'graph-only') -NonInteractive -ErrorAction Stop Should -Invoke Get-TagInventory -Times 1 -Exactly Should -Invoke Invoke-FOHubKustoQuery -ModuleName FinOpsMultitool -Times 0 -Exactly @@ -282,11 +316,46 @@ function Invoke-FinOpsMultitool { $env:FINOPS_HUB_KUSTO_URI = $null { Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-CostData -DataSource Hub -NonInteractive -ErrorAction Stop } | - Should -Throw '*No FinOps hub*' + Should -Throw '*No FinOps hub*' Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly } + It 'Keeps scan results in memory when the export destination is unsafe' { + $env:FINOPS_HUB_KUSTO_URI = $null + $repository = Join-Path $TestDrive 'blocked-report-repository' + [void](New-Item -ItemType Directory -Path (Join-Path $repository '.git') -Force) + + { Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-CostData -DataSource API -OutputPath $repository -NonInteractive -ErrorAction Stop } | + Should -Throw '*report saving failed*Git*Results remain*' + + $results = Get-Variable -Name FinOpsResults -Scope Global -ValueOnly + $results['Get-CostData']['11111111-1111-1111-1111-111111111111'].Actual | Should -Be 100 + @(Get-ChildItem -LiteralPath $repository -File -Recurse -Force).Count | Should -Be 0 + Should -Invoke Read-Host -Times 0 -Exactly + } + + It 'Preserves a caught payload-scan error in every report format' { + $env:FINOPS_HUB_KUSTO_URI = $null + $reportRoot = Join-Path $TestDrive 'failed-payload-scan' + Mock Get-OrphanedResources { throw '403 AuthorizationFailed: orphan fixture.' } + + Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-OrphanedResources -DataSource API -OutputPath $reportRoot -NonInteractive -ErrorAction Stop + + $results = Get-Variable -Name FinOpsResults -Scope Global -ValueOnly + $results['_error_Get-OrphanedResources'] | Should -Be '403 AuthorizationFailed: orphan fixture.' + @($results['Get-OrphanedResources']).Count | Should -Be 0 + $runs = @(Get-ChildItem -LiteralPath $reportRoot -Directory) + $runs.Count | Should -Be 1 + $status = Import-Csv -LiteralPath (Join-Path $runs[0].FullName 'Get-OrphanedResources.csv') + $status.RecordType | Should -Be 'Status' + $status.Status | Should -Be 'Error' + $status.Error | Should -Be '403 AuthorizationFailed: orphan fixture.' + Get-Content -LiteralPath (Join-Path $runs[0].FullName 'FinOpsReport.html') -Raw | Should -Match '403 AuthorizationFailed: orphan fixture' + Get-Content -LiteralPath (Join-Path $runs[0].FullName 'ScanSummary.txt') -Raw | Should -Match 'ERROR: 403 AuthorizationFailed: orphan fixture' + Should -Invoke Read-Host -Times 0 -Exactly + } + It 'Keeps selected-source failure details for ' -ForEach @( @{ Mode = 'ApiDenied'; Source = 'API'; HubUri = $null; ExpectedRole = 'Cost Management Reader' } @{ Mode = 'StorageHubDenied'; Source = 'Hub'; HubUri = $null; ExpectedRole = 'Storage Blob Data Reader' } @@ -307,6 +376,9 @@ function Invoke-FinOpsMultitool { Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-CostData -DataSource $Source -OutputPath $reportPath -NonInteractive -ErrorAction Stop + $runs = @(Get-ChildItem -LiteralPath $reportPath -Directory) + $runs.Count | Should -Be 1 + $reportPath = $runs[0].FullName $result = Get-Variable -Name FinOpsResults -Scope Global -ValueOnly $result['_error_Get-CostData'] | Should -Match '403' $result['Get-CostData'] | Should -BeNullOrEmpty @@ -315,7 +387,11 @@ function Invoke-FinOpsMultitool { $html | Should -Match 'Scans Run
1
' $html | Should -Match 'Errors
1
' Get-Content (Join-Path $reportPath 'ScanSummary.txt') -Raw | Should -Match 'ERROR:.*403' - @(Get-ChildItem -LiteralPath $reportPath -Filter '*.csv').Count | Should -Be 0 + $csvFiles = @(Get-ChildItem -LiteralPath $reportPath -Filter '*.csv') + $csvFiles.Count | Should -Be 1 + $status = Import-Csv -LiteralPath $csvFiles[0].FullName + $status.Status | Should -Be 'Error' + $status.Error | Should -Match '403' Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { "$Object" -match "Required role:\s+$([regex]::Escape($ExpectedRole))" } Should -Invoke Read-Host -Times 0 -Exactly if ($Source -eq 'Hub') { From dc5be034af0fba7fc153ebca4372a23d176bd816 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Sat, 19 Sep 2026 21:50:52 -0600 Subject: [PATCH 136/142] fix(multitool): improve reports and policy coverage --- .../Invoke-FinOpsMultitool.ps1 | 318 ++++++++++++++---- .../Private/FinOpsMultitool/README.md | 10 + .../FinOpsMultitool/modules/Get-CostData.ps1 | 72 +++- .../modules/Get-PolicyInventory.ps1 | 16 +- .../modules/Get-PolicyRecommendations.ps1 | 74 +++- .../modules/helpers/Read-FinOpsHubData.ps1 | 2 +- .../Public/Start-FinOpsMultitool.ps1 | 4 + .../Tests/Unit/CostQueryPagination.Tests.ps1 | 93 +++++ .../Tests/Unit/MultitoolSafety.Tests.ps1 | 194 ++++++++++- .../Tests/Unit/PolicyEffect.Tests.ps1 | 195 ++++++++++- .../Unit/Start-FinOpsMultitool.Tests.ps1 | 64 ++++ 11 files changed, 946 insertions(+), 96 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 index a4bf577aa..6c7795619 100644 --- a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -26,7 +26,7 @@ param() function Invoke-FinOpsMultitool { [CmdletBinding()] - [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'NonInteractive', Justification = 'Read by the nested picker functions, which PSScriptAnalyzer does not trace into. Verified on PowerShell 5.1 and 7.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'NonInteractive', Justification = 'Read by the nested picker functions, which PSScriptAnalyzer does not trace into.')] param( [string]$SubscriptionId, [string]$OutputPath, @@ -36,6 +36,10 @@ function Invoke-FinOpsMultitool { [switch]$NonInteractive ) + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "FinOps Multitool requires PowerShell 7 or later. This session is PowerShell $($PSVersionTable.PSVersion). Open PowerShell 7 with 'pwsh', import the module there, and run the scan again. No scan was started." + } + # -- Load modules (always force-reimport to pick up latest changes) ---- $multitoolRoot = $PSScriptRoot # Re-probe the console every run; the host can differ between invocations. @@ -1112,6 +1116,10 @@ function Invoke-FinOpsMultitool { $output = & $fn -ExistingTags $tags; break } 'Get-PolicyRecommendations' { + if ($results.ContainsKey('_error_Get-PolicyInventory') -or -not $results.ContainsKey('Get-PolicyInventory') -or + $results['Get-PolicyInventory'].CoverageIncomplete) { + throw 'Policy recommendations are unavailable because the policy inventory did not complete. No policies are assumed missing.' + } $assignments = if ($results.ContainsKey('Get-PolicyInventory') -and $results['Get-PolicyInventory'].Assignments) { $results['Get-PolicyInventory'].Assignments } @@ -1417,9 +1425,9 @@ function Invoke-FinOpsMultitool { if ($primaryRows.Count -eq 0) { $record = [ordered]@{ RecordType = 'Status' - Scan = $Fn - Status = if ($Data.AccessDenied -or $Data.Error) { 'Error' } else { 'No data' } - Error = if ($Data.Error) { $Data.Error } elseif ($Data.AccessDenied) { $Data.Note } else { $null } + Scan = $Fn + Status = if ($Data.AccessDenied -or $Data.Error) { 'Error' } else { 'No data' } + Error = if ($Data.Error) { $Data.Error } elseif ($Data.AccessDenied) { $Data.Note } else { $null } } foreach ($field in $metadata.GetEnumerator()) { $record[$field.Key] = $field.Value } $rows = @([PSCustomObject]$record) + @($rows) @@ -1972,7 +1980,11 @@ function Invoke-FinOpsMultitool { [PSCustomObject]@{ Policy = $_.DisplayName; Status = $_.Status; Category = $_.Category; Priority = $_.Priority; Effect = $_.DefaultEffect } } $cols = @('Policy', 'Status', 'Category', 'Priority', 'Effect') - Write-Host " Compliance: $($data.CompliancePct)%" -ForegroundColor White + $assignmentCoverage = if ($data.CoverageIncomplete -or $null -eq $data.CompliancePct) { 'unverified' } else { "$($data.CompliancePct)%" } + Write-Host " Assignment coverage: $assignmentCoverage (recommended definition IDs found, not resource compliance)" -ForegroundColor White + foreach ($issue in @($data.InitiativeErrors)) { + Write-Host " Initiative lookup unavailable: $($issue.InitiativeId) - $($issue.Error)" -ForegroundColor Yellow + } } 'Get-BudgetStatus' { Write-Host " Budgets: $($data.TotalBudgets) | " -ForegroundColor White -NoNewline @@ -2667,24 +2679,22 @@ function Invoke-FinOpsMultitool { 'Get-PolicyRecommendations' { if ($data.Analysis -and @($data.Analysis).Count -gt 0) { $missing = @($data.Analysis | Where-Object { $_.Status -eq 'Missing' }) - if ($missing.Count -gt 5) { + if ($data.CoverageIncomplete) { $guidanceItems = @( - @{ Severity = 'Red'; Message = "$($missing.Count) recommended FinOps policies are not assigned. Governance foundation is incomplete." } - @{ Severity = 'Yellow'; Message = "Priority policies: tag enforcement, allowed VM SKUs, allowed locations, resource naming." } - @{ Severity = 'Yellow'; Message = "FinOps Practice: Policy-driven governance prevents cost waste at deployment time — cheaper than cleanup."; Docs = 'https://learn.microsoft.com/azure/governance/policy/samples/built-in-policies' } + @{ Severity = 'Yellow'; Message = 'Some initiative definitions could not be read. Unmatched policies are Unknown, not confirmed missing. Review the initiative lookup errors.' } ) } elseif ($missing.Count -gt 0) { $guidanceItems = @( - @{ Severity = 'Yellow'; Message = "$($missing.Count) recommended policies not yet assigned. Review and deploy as needed." } - @{ Severity = 'Yellow'; Message = "Start with: tag enforcement, allowed locations, and allowed VM SKUs."; Docs = 'https://learn.microsoft.com/azure/governance/policy/samples/built-in-policies' } + @{ Severity = 'Yellow'; Message = "$($missing.Count) recommended definition IDs were not found in the reported assignments or their initiatives. Check equivalent custom policies and intended scopes before making changes." } ) } else { $guidanceItems = @( - @{ Severity = 'Green'; Message = "All recommended FinOps policies are assigned. Strong governance foundation." } + @{ Severity = 'Green'; Message = 'All recommended definition IDs were found in the reported assignments or their initiatives.' } ) } + $guidanceItems += @{ Severity = 'Yellow'; Message = 'Assignment presence does not prove enforcement or compliance. Review scopes, exclusions, parameters, and enforcement modes.'; Docs = 'https://learn.microsoft.com/azure/governance/policy/concepts/initiative-definition-structure' } } } 'Get-OptimizationAdvice' { @@ -2712,10 +2722,8 @@ function Invoke-FinOpsMultitool { } 'Get-CostData' { if ($data -is [hashtable] -and $data.Count -gt 0) { - $totalActual = 0 - foreach ($sub in $data.GetEnumerator()) { $totalActual += [double]$sub.Value.Actual } $guidanceItems = @( - @{ Severity = 'Green'; Message = "Current period spend: $("{0:C0}" -f $totalActual) across $($data.Count) subscription(s)." } + @{ Severity = 'Green'; Message = 'Actual costs and forecasts are separate amounts. Compare subscriptions only when their currencies and reporting periods match.' } @{ Severity = 'Green'; Message = "FinOps Practice: Review actual vs. forecast regularly. Pair this data with Budget Status to track variance." } ) } @@ -2723,10 +2731,8 @@ function Invoke-FinOpsMultitool { 'Get-ResourceCosts' { $topCount = if ($data) { @($data).Count } else { 0 } if ($topCount -gt 0) { - $topCost = [double](@($data) | Sort-Object { [double]$_.Actual } -Descending | Select-Object -First 1).Actual $guidanceItems = @( - @{ Severity = 'Yellow'; Message = "Top resource costs $("{0:C2}" -f $topCost). Focus optimization on the largest cost drivers." } - @{ Severity = 'Yellow'; Message = "FinOps Practice: The top 20% of resources typically drive 80% of spend. Optimize these first." } + @{ Severity = 'Yellow'; Message = 'Review the largest resource costs for changes in demand or unused capacity. A high cost alone does not establish waste.' } ) } } @@ -2807,7 +2813,7 @@ function Invoke-FinOpsMultitool { } # -- HTML report -- - $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", [cultureinfo]::InvariantCulture) $subList = if ($Subscriptions) { ($Subscriptions | ForEach-Object { if ($_.Name) { $_.Name } else { $_.Id } }) -join ', ' } else { 'N/A' } $htmlSb = [System.Text.StringBuilder]::new() [void]$htmlSb.Append(@" @@ -2844,7 +2850,7 @@ h3 { color: var(--navy); font-size: 12px; font-weight: 600; letter-spacing: 0.1e .tabpane.active { display: block; } table { border-collapse: collapse; width: 100%; margin: 12px 0 22px 0; font-size: 13px; } th { background: var(--surface); color: var(--navy); text-align: left; padding: 9px 12px; border-bottom: 2px solid var(--light-gray); font-weight: 600; font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; } -td { padding: 7px 12px; border-bottom: 1px solid #ECECEA; } +td { padding: 7px 12px; border-bottom: 1px solid #ECECEA; vertical-align: top; overflow-wrap: anywhere; } tr:hover td { background: var(--surface); } .severity-red { color: var(--danger); font-weight: 600; } .severity-yellow { color: var(--warning); font-weight: 600; } @@ -2853,13 +2859,33 @@ tr:hover td { background: var(--surface); } .guidance.red { border-left-color: var(--danger); } .guidance.yellow { border-left-color: var(--warning); } .guidance.green { border-left-color: var(--success); } -.guidance a { color: var(--blue); text-decoration: none; } +.guidance a { color: var(--blue); text-decoration: none; overflow-wrap: anywhere; } .guidance a:hover { text-decoration: underline; } .table-note { color: var(--muted); font-size: 12px; font-style: italic; margin: -14px 0 22px 0; max-width: 74ch; } .story-intro { font-size: 14px; color: var(--ink); max-width: 78ch; margin: 20px 0 4px 0; } .story-summary { font-size: 15px; font-weight: 600; color: var(--navy); margin: 10px 0 4px 0; } .story-detail { font-size: 13px; color: var(--muted); max-width: 78ch; margin: 0 0 14px 0; } .story-caps { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); margin: 0 0 26px 0; } +.story-meta { display: grid; grid-template-columns: minmax(7rem, 11rem) minmax(0, 1fr); gap: 8px 16px; margin: 20px 0; font-size: 13px; } +.story-meta dt { font-weight: 600; color: var(--muted); } +.story-meta dd { margin: 0; overflow-wrap: anywhere; } +.table-scroll { width: 100%; overflow-x: auto; } +.report-jump { color: var(--blue); text-underline-offset: 3px; } +.evidence-state { font-weight: 600; white-space: nowrap; } +.story-note { color: var(--muted); font-size: 13px; margin: 8px 0 20px; max-width: 85ch; } +.tabs, .tab, .masthead h1, .masthead .eyebrow, .summary-card .label, th, h3, .story-caps, .kpi-name, .kpi-next-label { letter-spacing: 0; } +h2[id] { scroll-margin-top: 85px; } +@media (max-width: 640px) { + .wrap { padding: 0 16px; } + .masthead { padding: 20px 16px; } + .masthead h1 { font-size: 25px; } + .summary-card { min-width: 0; flex: 1 1 125px; padding: 10px 12px; } + .story-meta { grid-template-columns: 1fr; gap: 4px; } + .story-meta dd { margin-bottom: 10px; } + .tabs { position: static; } + .tab { padding: 10px; } + th, td { padding: 7px 8px; } +} .kpi { border-left: 4px solid var(--blue); background: var(--surface); border-radius: 0 4px 4px 0; padding: 10px 16px; margin: 0 0 10px 0; max-width: 78ch; } .kpi-name { font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); } .kpi-value { font-size: 17px; font-weight: 600; color: var(--navy); margin: 2px 0; } @@ -2880,8 +2906,10 @@ tr:hover td { background: var(--surface); } .wrap { padding: 0; } body { padding: 16px; } tr:hover td { background: none; } + .table-scroll { overflow: visible; } } +
@@ -2892,18 +2920,63 @@ tr:hover td { background: var(--surface); }
"@) - # Summary cards - $errorCount = @($Modules | Where-Object { $_.Selected } | Where-Object { $Results.ContainsKey("_error_$($_.Fn)") }).Count + $selectedMods = @($Modules | Where-Object { $_.Selected }) + $scanEvidence = @(foreach ($selectedMod in $selectedMods) { + $scanData = $Results[$selectedMod.Fn] + $notes = @(@($scanData.Note; $scanData.Reason; $scanData.CostIssue; $scanData.AHBIssue; $scanData.Error) | + Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) + $state = 'Data returned' + if ($Results.ContainsKey("_error_$($selectedMod.Fn)")) { + $state = 'Failed' + $notes = @([string]$Results["_error_$($selectedMod.Fn)"]) + } + elseif (-not $scanData -or @($scanData).Count -eq 0 -or $scanData.HasData -contains $false) { $state = 'No data' } + if ($state -ne 'Failed' -and ($scanData.CoverageIncomplete -contains $true -or $scanData.AccessDenied -contains $true -or + @(@($scanData.CostIssue; $scanData.AHBIssue; $scanData.Error) | Where-Object { $_ }).Count -gt 0 -or + @($scanData.MetricFailures | Where-Object { $_ -gt 0 }).Count -gt 0 -or + @($scanData.Status | Where-Object { $_ -in @('Unavailable', 'Unknown') }).Count -gt 0)) { + $state = 'Limited data' + } + if ($state -ne 'Failed' -and $selectedMod.Fn -eq 'Get-CostData' -and $scanData -is [System.Collections.IDictionary]) { + foreach ($entry in $scanData.GetEnumerator()) { + $entryName = if ($entry.Value.Name) { [string]$entry.Value.Name } elseif ($subNameLookup.ContainsKey($entry.Key)) { $subNameLookup[$entry.Key] } else { [string]$entry.Key } + if ($entry.Value.Currency -eq 'Mixed' -or (Format-BudgetAmount -Value $entry.Value.Actual -Currency $entry.Value.Currency) -eq 'Unavailable') { + $state = 'Limited data' + $notes += "${entryName}: Actual cost or billing currency unavailable." + } + if (-not $entry.Value.ActualPeriod -or $entry.Value.ActualPeriod -eq 'Unknown') { + $state = 'Limited data' + $notes += "${entryName}: Observed period not recorded." + } + if (-not $entry.Value.ForecastSource -or $entry.Value.ForecastSource -in @('Actual', 'Unavailable') -or + (Format-BudgetAmount -Value $entry.Value.Forecast -Currency $entry.Value.Currency) -eq 'Unavailable') { + $state = 'Limited data' + $notes += "${entryName}: Full-month forecast unavailable." + } + } + } + if ($state -eq 'No data' -and @($notes).Count -eq 0) { $notes = @('This scan returned no data; that is not a measured zero.') } + if ($state -eq 'Limited data' -and @($notes).Count -eq 0) { $notes = @('Some data or coverage could not be verified. Review the scan details.') } + [pscustomobject]@{ + Name = $selectedMod.Name + Function = $selectedMod.Fn + Target = 'tab-' + ($selectedMod.Category -replace '[^A-Za-z0-9]', '') + Anchor = 'scan-' + ($selectedMod.Fn -replace '[^a-zA-Z0-9\-]', '') + Status = $state + Note = ($notes -join ' ') + } + }) + $errorCount = @($scanEvidence | Where-Object Status -EQ 'Failed').Count + $dataGapCount = @($scanEvidence | Where-Object { $_.Status -in @('Limited data', 'No data') }).Count [void]$htmlSb.Append('
') - [void]$htmlSb.Append("
Total Findings
$totalFindings
") - [void]$htmlSb.Append("
Scans Run
$(@($Modules | Where-Object { $_.Selected }).Count)
") + [void]$htmlSb.Append("
Scans run
$($selectedMods.Count)
") + [void]$htmlSb.Append("
Scans with gaps
$dataGapCount
") if ($errorCount -gt 0) { [void]$htmlSb.Append("
Errors
$errorCount
") } [void]$htmlSb.Append('
') # Per-module sections, grouped into one tab per category - $selectedMods = @($Modules | Where-Object { $_.Selected }) $presentCats = @($selectedMods | ForEach-Object { $_.Category } | Select-Object -Unique) # Cost Analysis leads; the rest sort alphabetically so a new category # lands in a predictable spot instead of being appended. @@ -2915,22 +2988,111 @@ tr:hover td { background: var(--surface); } if (Get-Command Get-KpiCatalog -ErrorAction SilentlyContinue) { try { $storyCatalog = Get-KpiCatalog } catch { $storyCatalog = $null } } - $hasStory = ($storyCatalog -and @($storyCatalog.domains).Count -gt 0 -and $kpiCollected.Count -gt 0) + $hasStory = $true [void]$htmlSb.Append('') if ($hasStory) { [void]$htmlSb.Append('
') - [void]$htmlSb.Append('

The FinOps Framework organizes cloud cost management into four domains. This report presents your scan results in that order, so each measure appears alongside the FinOps capability it supports. Every measure below is a FinOps Foundation KPI.

') + [void]$htmlSb.Append('

Observed spend, opportunities, and evidence gaps for the selected subscriptions. Estimates are not realized savings, and unavailable data is not treated as zero.

') + $tenantIds = @($Subscriptions | ForEach-Object { $_.TenantId } | Where-Object { $_ } | Select-Object -Unique) + $tenantLabel = if ($tenantIds.Count -gt 0) { $tenantIds -join ', ' } else { 'Not recorded' } + $scopeLabel = if ($Subscriptions) { @($Subscriptions | ForEach-Object { "$($_.Name) [$($_.Id)]" }) -join '; ' } else { 'Not recorded' } + [void]$htmlSb.Append('') + [void]$htmlSb.Append('

Observed spend

') + $costData = $Results['Get-CostData'] + if (-not $Results.ContainsKey('_error_Get-CostData') -and $costData -is [System.Collections.IDictionary] -and $costData.Count -gt 0) { + [void]$htmlSb.Append('
') + foreach ($entry in $costData.GetEnumerator() | Sort-Object Key) { + $currency = if ($entry.Value.Currency -eq 'Mixed') { '' } else { [string]$entry.Value.Currency } + $forecastSource = if ($entry.Value.ForecastSource) { [string]$entry.Value.ForecastSource } else { 'Unavailable' } + $forecastText = if ($forecastSource -in @('Unavailable', 'Actual')) { 'Unavailable' } else { Format-BudgetAmount -Value $entry.Value.Forecast -Currency $currency } + $cells = @( + $(if ($entry.Value.Name) { [string]$entry.Value.Name } elseif ($subNameLookup.ContainsKey($entry.Key)) { $subNameLookup[$entry.Key] } else { [string]$entry.Key }) + (Format-BudgetAmount -Value $entry.Value.Actual -Currency $currency) + $(if ($entry.Value.ActualPeriod) { [string]$entry.Value.ActualPeriod } else { 'Not recorded' }) + $forecastText + $forecastSource + ) + [void]$htmlSb.Append('') + foreach ($cell in $cells) { [void]$htmlSb.Append("") } + [void]$htmlSb.Append('') + } + [void]$htmlSb.Append('
SubscriptionActual costObserved periodFull-month forecastForecast source
$([System.Net.WebUtility]::HtmlEncode([string]$cell))
') + [void]$htmlSb.Append('

Amounts remain separate by subscription, currency, and reported period. Forecasts are separate full-month estimates, not amounts to add to actual cost.

') + } + else { [void]$htmlSb.Append('

Subscription cost totals are unavailable in this run. Other scan results do not establish a zero-spend baseline.

') } + $resourceEvidence = $scanEvidence | Where-Object Function -EQ 'Get-ResourceCosts' | Select-Object -First 1 + if ($resourceEvidence -and $resourceEvidence.Status -ne 'Failed' -and $Results['Get-ResourceCosts']) { + $driverRows = @(foreach ($resource in $Results['Get-ResourceCosts']) { + if ((Format-BudgetAmount -Value $resource.Actual -Currency $resource.Currency) -eq 'Unavailable') { continue } + if ($resource.Currency -eq 'Mixed' -or [double]$resource.Actual -le 0) { continue } + $resource + }) + [void]$htmlSb.Append('

Largest resource costs

') + [void]$htmlSb.Append('

Up to five positive costs per subscription, currency, and reported period among the returned rows. Source query limits can omit resources. The detail table retains every returned row, including credits. High cost is not proof of waste.

') + if ($driverRows.Count -gt 0) { + [void]$htmlSb.Append('
') + foreach ($group in $driverRows | Group-Object -Property @{ + Expression = { + if ($_.SubscriptionId) { [string]$_.SubscriptionId } + elseif ($_.ResourcePath -match '^/subscriptions/([^/]+)/') { $Matches[1] } + else { [string]$_.Subscription } + } + }, Currency, ActualPeriod | Sort-Object Name) { + foreach ($resource in $group.Group | Sort-Object { [double]$_.Actual } -Descending | Select-Object -First 5) { + [void]$htmlSb.Append('') + $driverCells = @( + [string]$resource.Subscription + $(if ($resource.ResourcePath) { [string]$resource.ResourcePath } else { 'No resource ID recorded' }) + [string]$resource.ResourceType + (Format-BudgetAmount -Value $resource.Actual -Currency $resource.Currency) + $(if ($resource.ActualPeriod) { [string]$resource.ActualPeriod } else { 'Not recorded' }) + ) + foreach ($cell in $driverCells) { [void]$htmlSb.Append("") } + [void]$htmlSb.Append('') + } + } + [void]$htmlSb.Append('
SubscriptionResourceTypeActual costObserved period
$([System.Net.WebUtility]::HtmlEncode([string]$cell))
') + } + else { [void]$htmlSb.Append('

No positive resource costs with a known currency were available to rank.

') } + [void]$htmlSb.Append("

All returned resource costs

") + } + [void]$htmlSb.Append('

Scan status

') + foreach ($evidence in $scanEvidence) { + $stateClass = if ($evidence.Status -eq 'Failed') { 'severity-red' } elseif ($evidence.Status -in @('Limited data', 'No data')) { 'severity-yellow' } else { '' } + [void]$htmlSb.Append("") + } + [void]$htmlSb.Append('
ScanEvidenceCoverage and notes
$([System.Net.WebUtility]::HtmlEncode([string]$evidence.Name))$([System.Net.WebUtility]::HtmlEncode($evidence.Status))$([System.Net.WebUtility]::HtmlEncode($evidence.Note))

Data returned means the scan produced a result, not that every field is available or that the environment is optimized. Individual scans can use live APIs even when the primary cost source is a Hub.

') + $followUps = @(foreach ($evidence in $scanEvidence) { + if ($evidence.Status -in @('Failed', 'Limited data', 'No data')) { + [pscustomobject]@{ Evidence = $evidence; Action = 'Review the reported limits before using this scan for a decision.' } + } + elseif ($guidanceByFn.ContainsKey($evidence.Function)) { + $action = @($guidanceByFn[$evidence.Function] | Where-Object { $_.Severity -in @('Red', 'Yellow') } | Select-Object -First 1) + if ($action.Count -gt 0) { [pscustomobject]@{ Evidence = $evidence; Action = [string]$action[0].Message } } + } + }) + if ($followUps.Count -gt 0) { + [void]$htmlSb.Append('

Review next

') + } foreach ($dom in $storyCatalog.domains) { $domKpis = @($kpiCollected | Where-Object { $_.domain -eq $dom.id }) if ($domKpis.Count -eq 0) { continue } @@ -2956,13 +3118,19 @@ tr:hover td { background: var(--surface); } $notMeasured = @($domKpis | Where-Object { $_.status -ne 'computed' -or -not $_.yourValue }) if ($notMeasured.Count -gt 0) { - $names = ($notMeasured | ForEach-Object { $_.kpiName }) -join ', ' - [void]$htmlSb.Append("

Other KPIs in this domain that this scan didn't measure: $([System.Net.WebUtility]::HtmlEncode($names)). Run the scans that inform them to complete the picture.

") + [void]$htmlSb.Append('

Not measured

    ') + foreach ($kpi in $notMeasured) { + $reason = if ($kpi.yourValue) { [string]$kpi.yourValue } elseif ($kpi.exploreHint) { [string]$kpi.exploreHint } else { 'No comparable measurement was available in this run.' } + [void]$htmlSb.Append("
  • $([System.Net.WebUtility]::HtmlEncode([string]$kpi.kpiName)): $([System.Net.WebUtility]::HtmlEncode($reason))
  • ") + } + [void]$htmlSb.Append('
') } [void]$htmlSb.Append("

FinOps capabilities in this domain: $([System.Net.WebUtility]::HtmlEncode([string]$dom.capabilities))

") } - $lm = [System.Net.WebUtility]::HtmlEncode([string]$storyCatalog.learnMoreBase) - [void]$htmlSb.Append("

Domain and capability names follow the FinOps Framework. KPI definitions are published by the FinOps Foundation at $lm.

") + if ($storyCatalog.learnMoreBase) { + $lm = [System.Net.WebUtility]::HtmlEncode([string]$storyCatalog.learnMoreBase) + [void]$htmlSb.Append("

FinOps KPI reference: $lm. Scan-derived estimates and proxies are labeled separately from measured values.

") + } [void]$htmlSb.Append('
') } @@ -2979,7 +3147,8 @@ tr:hover td { background: var(--surface); } $fn = $mod.Fn $data = $Results[$fn] $eName = [System.Net.WebUtility]::HtmlEncode($mod.Name) - [void]$htmlSb.Append("

$eName

") + $scanAnchor = 'scan-' + ($fn -replace '[^a-zA-Z0-9\-]', '') + [void]$htmlSb.Append("

$eName

") # Anything appended past this point counts as content for the section. $sectionMark = $htmlSb.Length @@ -3061,11 +3230,9 @@ tr:hover td { background: var(--surface); } [void]$htmlSb.Append("
Case-variant tag keys found. Azure resolves tag keys case-insensitively, so these spellings are a single key to Azure, but Resource Graph and cost exports report each one separately. $cvText
") } if ($data.TagNames) { - $htmlRows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | Select-Object -First 15 | ForEach-Object { + $htmlRows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | ForEach-Object { $vals = @($_.Value.Values | Sort-Object ResourceCount -Descending) - $shown = @($vals | Select-Object -First 5 | ForEach-Object { "$($_.Value) ($($_.ResourceCount))" }) - $more = $vals.Count - $shown.Count - $valText = ($shown -join ', ') + $(if ($more -gt 0) { ", +$more more" } else { '' }) + $valText = (@($vals | ForEach-Object { "$($_.Value) ($($_.ResourceCount))" }) -join ', ') [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; Values = $vals.Count; 'Top values' = $valText } } $htmlCols = @('Tag', 'Resources', 'Values', 'Top values') @@ -3089,10 +3256,17 @@ tr:hover td { background: var(--surface); } } } 'Get-ResourceCosts' { - $htmlRows = @($data) | Sort-Object { $_.Actual } -Descending | Select-Object -First 50 | ForEach-Object { - [PSCustomObject]@{ ResourceGroup = $_.ResourceGroup; ResourceType = ($_.ResourceType -split '/')[-1]; Cost = Format-BudgetAmount -Value $_.Actual -Currency $_.Currency } + $htmlRows = @($data) | Sort-Object { $_.Actual } -Descending | ForEach-Object { + [PSCustomObject]@{ + Subscription = $_.Subscription + Resource = if ($_.ResourcePath) { $_.ResourcePath } else { 'No resource ID recorded' } + ResourceGroup = $_.ResourceGroup + ResourceType = $_.ResourceType + Cost = Format-BudgetAmount -Value $_.Actual -Currency $_.Currency + ActualPeriod = if ($_.ActualPeriod) { $_.ActualPeriod } else { 'Not recorded' } + } } - $htmlCols = @('ResourceGroup', 'ResourceType', 'Cost') + $htmlCols = @('Subscription', 'Resource', 'ResourceGroup', 'ResourceType', 'Cost', 'ActualPeriod') } 'Get-CostByTag' { if ($data.CostByTag) { @@ -3223,8 +3397,12 @@ tr:hover td { background: var(--surface); } $htmlCols = @('Name', 'Effect', 'Enforcement', 'Scope') } 'Get-PolicyRecommendations' { - $htmlRows = $data.Analysis | ForEach-Object { [PSCustomObject]@{ Policy = $_.DisplayName; Status = $_.Status; Category = $_.Category; Priority = $_.Priority; Effect = $_.DefaultEffect } } - $htmlCols = @('Policy', 'Status', 'Category', 'Priority', 'Effect') + $htmlRows = $data.Analysis | ForEach-Object { + $assignmentLabels = @($_.MatchedAssignments | ForEach-Object { "$($_.AssignmentName) [$($_.Source); $($_.EnforcementMode); $($_.Scope)]" }) + [PSCustomObject]@{ Policy = $_.DisplayName; Status = $_.Status; Category = $_.Category; Priority = $_.Priority; Effect = $_.DefaultEffect; Assignments = ($assignmentLabels -join '; '); Purpose = $_.Purpose; Note = $_.Note } + } + $htmlCols = @('Policy', 'Status', 'Category', 'Priority', 'Effect', 'Assignments', 'Purpose', 'Note') + $tableNote = 'Assignment coverage compares recommended definition IDs with the reported assignments and their initiative members. It does not measure enforcement or resource compliance.' } 'Get-BillingStructure' { $htmlRows = $data.BillingAccounts | ForEach-Object { [PSCustomObject]@{ Account = $_.DisplayName; Agreement = $_.AgreementType; Type = $_.AccountType; Status = $_.AccountStatus } } @@ -3323,7 +3501,7 @@ tr:hover td { background: var(--surface); } # Render HTML table if ($htmlRows -and $htmlCols) { - [void]$htmlSb.Append('') + [void]$htmlSb.Append('
') foreach ($c in $htmlCols) { [void]$htmlSb.Append("") } [void]$htmlSb.Append('') foreach ($r in $htmlRows) { @@ -3331,15 +3509,7 @@ tr:hover td { background: var(--surface); } foreach ($c in $htmlCols) { $val = $r.$c $raw = [string]$val - $tdAttr = '' - if ($raw.Length -gt 60) { - # Keep the full value reachable on hover rather than blowing out the column. - $tdAttr = " title=`"$([System.Net.WebUtility]::HtmlEncode($raw))`"" - $enc = [System.Net.WebUtility]::HtmlEncode($raw.Substring(0, 57)) + '…' - } - else { - $enc = [System.Net.WebUtility]::HtmlEncode($raw) - } + $enc = [System.Net.WebUtility]::HtmlEncode($raw) # Colorize money values and risk/severity if ($enc -match '^\$') { $enc = "$enc" } if ($c -eq 'Risk' -and $r.PSObject.Properties['_riskClass']) { $enc = "$enc" } @@ -3347,11 +3517,11 @@ tr:hover td { background: var(--surface); } $impClass = switch ($val) { 'High' { 'severity-red' } 'Medium' { 'severity-yellow' } default { 'severity-green' } } $enc = "$enc" } - [void]$htmlSb.Append("$enc") + [void]$htmlSb.Append("") } [void]$htmlSb.Append('') } - [void]$htmlSb.Append('
$([System.Net.WebUtility]::HtmlEncode($c))
$enc
') + [void]$htmlSb.Append('
') if ($tableNote) { [void]$htmlSb.Append("

$([System.Net.WebUtility]::HtmlEncode($tableNote))

") } @@ -3389,15 +3559,39 @@ tr:hover td { background: var(--surface); } (function () { var tabs = Array.prototype.slice.call(document.querySelectorAll('.tab')); var panes = Array.prototype.slice.call(document.querySelectorAll('.tabpane')); + function activate(target) { + tabs.forEach(function (tab) { + var active = tab.getAttribute('data-target') === target; + tab.classList.toggle('active', active); + tab.setAttribute('aria-selected', String(active)); + tab.tabIndex = active ? 0 : -1; + }); + panes.forEach(function (pane) { pane.classList.toggle('active', pane.id === target); }); + } tabs.forEach(function (t) { - t.addEventListener('click', function () { - tabs.forEach(function (x) { x.classList.remove('active'); }); - panes.forEach(function (x) { x.classList.remove('active'); }); - t.classList.add('active'); - var pane = document.getElementById(t.getAttribute('data-target')); - if (pane) { pane.classList.add('active'); } + t.addEventListener('click', function () { activate(t.getAttribute('data-target')); }); + t.addEventListener('keydown', function (event) { + var index = tabs.indexOf(t); + if (event.key === 'ArrowRight') { index = (index + 1) % tabs.length; } + else if (event.key === 'ArrowLeft') { index = (index + tabs.length - 1) % tabs.length; } + else if (event.key === 'Home') { index = 0; } + else if (event.key === 'End') { index = tabs.length - 1; } + else { return; } + event.preventDefault(); + activate(tabs[index].getAttribute('data-target')); + tabs[index].focus(); }); }); + Array.prototype.forEach.call(document.querySelectorAll('.report-jump'), function (link) { + link.addEventListener('click', function (event) { + var heading = document.getElementById(link.getAttribute('href').slice(1)); + if (!heading) { return; } + event.preventDefault(); + activate(link.getAttribute('data-target')); + heading.focus(); + heading.scrollIntoView({ block: 'start' }); + }); + }); })(); diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index 4064b512a..ee2001259 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -92,6 +92,10 @@ Guidance includes FinOps Foundation best practices, actionable next steps, and l Each completed run automatically saves one CSV file per selected scan, a `FinOpsReport.html` summary, and a `ScanSummary.txt` text summary on the machine running the multitool. Failed or empty scans have a CSV status record. There's no export prompt or format picker. +The HTML report opens with the **FinOps story**: selected tenant and subscriptions, observed spend, largest resource costs, scan status, and follow-up actions. Actual costs stay separate by subscription, currency, and reported period. Full-month forecasts are separate estimates, and unavailable amounts aren't treated as zero. Failed scans and evidence gaps link to their detailed results. + +The story highlights up to five positive resource costs per subscription, currency, and period. **All returned resource costs** opens the complete returned resource table, including credits and any resource IDs and periods the data source provided. Source query limits can omit resources; this view doesn't prove the inventory is complete. A high cost alone isn't evidence of waste. + By default, reports go under the current user's local application data directory, in `FinOpsToolkit/Multitool/Reports`. On Windows, that's usually `%LOCALAPPDATA%\FinOpsToolkit\Multitool\Reports`. Each run creates a timestamped, uniquely named subfolder. The terminal prints its full path. `-OutputPath` selects a different local parent folder; it doesn't replace reports from an earlier run. The run folder allows access only to the current user through filesystem permissions. On Unix, directories use mode `700` and files use mode `600`. The tool rejects Git repositories and worktrees, UNC paths, mapped Windows network drives, symbolic links, and junctions, and adds an ignore-all `.gitignore` as a backup against accidental staging. Unix network mounts aren't detected; choose a path on a local filesystem. If it can't safely save, it reports an error and keeps the scan results in `$FinOpsResults`; it doesn't fall back to the working directory. @@ -100,6 +104,8 @@ Reports are plaintext and can contain subscription, resource, tag, and billing d CSV files use `RecordType` to distinguish datasets when a scan returns several collections, such as reservations and savings plans. Scalar `Summary.*` columns retain scan diagnostics and estimate assumptions. Nested summary collections appear once as separate record types, such as `Summary.UnderutilizedRIs`, instead of repeating in every row. Nested values within a record are JSON. CSV headers include fields from every exported record type, amounts use a decimal point regardless of your system locale, and dates use ISO 8601. Aggregate and detailed records are separate views, not amounts to add together. +The terminal limits tag inventory to a compact preview. The HTML tag inventory includes every returned tag and value, and wraps long cell text instead of shortening it. CSV exports preserve the underlying value records and their counts. + ## Required permissions Each scan requires specific permissions. The TUI identifies the required role when a scan fails because of missing permissions. Billing permissions depend on your agreement, such as a Microsoft Customer Agreement (MCA) or Enterprise Agreement (EA). @@ -140,6 +146,10 @@ Subscription Reader access alone doesn't grant billing access. See [MCA billing | Policy Inventory | Azure Policy assignments with scope and compliance | | Policy Recommendations | Gaps in policy coverage for cost governance | +**Policy Recommendations** checks definition IDs in direct assignments and in assigned initiatives. Policies found through an initiative appear as **Assigned (Initiative)**, with the matching assignment, scope, and enforcement mode in the report. Reading custom initiative members requires access to the definition's subscription or management group. Each distinct initiative is read once per scan. + +If an initiative can't be read, unmatched policies appear as **Unknown**, not **Missing**. If the effective assignment inventory is incomplete, the launcher keeps the partial inventory but skips recommendations; it doesn't assume unread assignments are missing. Assignment coverage is the percentage of recommended definition IDs found in the supplied inventory, not Azure Policy compliance or proof of enforcement. Review parameters, exclusions, enforcement modes, and equivalent custom policies before treating a recommendation as a governance gap. + ### Cost Analysis | Scan | What it finds | diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 index 44787a92a..fadfd7e21 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 @@ -113,21 +113,23 @@ function Get-CostData { # Guessing at positions here would attribute real money to the wrong # subscription, so fail into the per-subscription path instead. - if ($aCostIdx -lt 0 -or $aSubIdx -lt 0) { - throw "Actual cost response did not expose the expected Cost and SubscriptionId columns." + if ($aCostIdx -lt 0 -or $aSubIdx -lt 0 -or $aCurIdx -lt 0) { + throw "Actual cost response did not expose the expected Cost, SubscriptionId, and Currency columns." } if ($result.properties.rows) { foreach ($row in $result.properties.rows) { $subId = [string]$row[$aSubIdx] $amount = [double]$row[$aCostIdx] - $currency = if ($aCurIdx -ge 0) { $row[$aCurIdx] } else { 'USD' } + $currency = ([string]$row[$aCurIdx]).Trim().ToUpperInvariant() if ($selectedSubs -and -not $selectedSubs.Contains($subId)) { continue } + if (-not $currency) { throw "Actual cost currency is unavailable for $subId." } if (-not $costMap.ContainsKey($subId)) { - $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = $currency; ForecastSource = 'Actual' } + $costMap[$subId] = @{ Actual = 0; Forecast = $null; Currency = $currency; ForecastSource = 'Unavailable'; ActualPeriod = 'Month to date (UTC query window)' } } + if ($costMap[$subId].Currency -ne $currency) { throw "Actual cost contains mixed currency values for $subId." } $costMap[$subId].Actual += $amount $costMap[$subId].Currency = $currency } @@ -187,14 +189,16 @@ function Get-CostData { } $forecastSums = @{} + $forecastCurrencies = @{} foreach ($page in (Get-CostQueryResponsePage -FirstResponse $fResponse -Payload $forecastBody -Context 'forecast')) { $fResult = $page.Content | ConvertFrom-Json if ($fResult.properties.rows.Count -eq 0) { continue } $fCols = $fResult.properties.columns $fSubIdx = Get-CostColumnIndex -Columns $fCols -Names @('subscriptionid') $fCostIdx = Get-CostColumnIndex -Columns $fCols -Names @('cost', 'pretaxcost', 'costusd') - if ($fCostIdx -lt 0 -or $fSubIdx -lt 0) { - throw "Forecast response did not expose the expected Cost and SubscriptionId columns." + $fCurIdx = Get-CostColumnIndex -Columns $fCols -Names @('currency') + if ($fCostIdx -lt 0 -or $fSubIdx -lt 0 -or $fCurIdx -lt 0) { + throw "Forecast response did not expose the expected Cost, SubscriptionId, and Currency columns." } foreach ($row in @($fResult.properties.rows)) { @@ -202,6 +206,10 @@ function Get-CostData { if ($subId -notmatch '^[0-9a-fA-F]{8}-') { continue } if ($selectedSubs -and -not $selectedSubs.Contains($subId)) { continue } $amount = [double]$row[$fCostIdx] + $currency = ([string]$row[$fCurIdx]).Trim().ToUpperInvariant() + if (-not $currency) { throw "Forecast currency is unavailable for $subId." } + if ($forecastCurrencies.ContainsKey($subId) -and $forecastCurrencies[$subId] -ne $currency) { throw "Forecast contains mixed currency values for $subId." } + $forecastCurrencies[$subId] = $currency if (-not $forecastSums.ContainsKey($subId)) { $forecastSums[$subId] = 0 } $forecastSums[$subId] += $amount } @@ -209,8 +217,9 @@ function Get-CostData { if ($forecastSums.Count -gt 0) { foreach ($subId in $forecastSums.Keys) { if (-not $costMap.ContainsKey($subId)) { - $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = 'USD' } + $costMap[$subId] = @{ Actual = $null; Forecast = $null; Currency = $forecastCurrencies[$subId]; ActualPeriod = 'Month to date (UTC query window)' } } + if ($costMap[$subId].Currency -ne $forecastCurrencies[$subId]) { throw "Actual cost and forecast currency differ for $subId." } $costMap[$subId].Forecast = [math]::Round($forecastSums[$subId], 2) $costMap[$subId].ForecastSource = 'Forecast' } @@ -266,17 +275,26 @@ function Get-CostData { if ($fResp.StatusCode -eq 200) { $total = 0.0 $rowCount = 0 + $forecastCurrency = $null foreach ($page in (Get-CostQueryResponsePage -FirstResponse $fResp -Payload $fBody -Context "forecast for $($sub.Id)")) { $fRes = $page.Content | ConvertFrom-Json if ($fRes.properties.rows.Count -eq 0) { continue } $costIndex = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('cost', 'pretaxcost', 'costusd') - if ($costIndex -lt 0) { throw 'Forecast response did not expose the expected Cost column.' } - foreach ($row in $fRes.properties.rows) { $total += [double]$row[$costIndex]; $rowCount++ } + $currencyIndex = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('currency') + if ($costIndex -lt 0 -or $currencyIndex -lt 0) { throw 'Forecast response did not expose the expected Cost and Currency columns.' } + foreach ($row in $fRes.properties.rows) { + $rowCurrency = ([string]$row[$currencyIndex]).Trim().ToUpperInvariant() + if (-not $rowCurrency -or ($forecastCurrency -and $forecastCurrency -ne $rowCurrency)) { throw 'Forecast currency is unavailable or mixed.' } + $forecastCurrency = $rowCurrency + $total += [double]$row[$costIndex] + $rowCount++ + } } if ($rowCount -gt 0) { if (-not $costMap.ContainsKey($sub.Id)) { - $costMap[$sub.Id] = @{ Actual = 0; Forecast = 0; Currency = 'USD' } + $costMap[$sub.Id] = @{ Actual = $null; Forecast = $null; Currency = $forecastCurrency; ActualPeriod = 'Month to date (UTC query window)' } } + if ($costMap[$sub.Id].Currency -ne $forecastCurrency) { throw 'Actual cost and forecast currency differ.' } $costMap[$sub.Id].Forecast = [math]::Round($total, 2) $costMap[$sub.Id].ForecastSource = 'Forecast' $hitCount++ @@ -303,6 +321,12 @@ function Get-CostData { } } + foreach ($sub in $Subscriptions) { + if (-not $costMap.ContainsKey($sub.Id)) { + $costMap[$sub.Id] = @{ Actual = $null; Forecast = $null; Currency = $null; ForecastSource = 'Unavailable'; ActualPeriod = 'Month to date (UTC query window)' } + } + } + return $costMap } @@ -340,21 +364,25 @@ function Get-CostDataPerSubscription { $path = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement" $resp = Invoke-AzRestMethodWithRetry -Path "$path/query?api-version=2023-11-01" -Method POST -Payload $body - $actual = 0; $currency = 'USD' + $actual = $null; $currency = $null if ($resp.StatusCode -eq 200) { $sum = 0.0 + $actualRowCount = 0 foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context "actual cost for $($sub.Id)")) { $res = $page.Content | ConvertFrom-Json if ($res.properties.rows.Count -eq 0) { continue } $cIdx = Get-CostColumnIndex -Columns $res.properties.columns -Names @('cost', 'pretaxcost', 'costusd') $curIdx = Get-CostColumnIndex -Columns $res.properties.columns -Names @('currency') - if ($cIdx -lt 0) { throw 'Actual cost response did not expose the expected Cost column.' } + if ($cIdx -lt 0 -or $curIdx -lt 0) { throw 'Actual cost response did not expose the expected Cost and Currency columns.' } foreach ($row in $res.properties.rows) { + $rowCurrency = ([string]$row[$curIdx]).Trim().ToUpperInvariant() + if (-not $rowCurrency -or ($currency -and $currency -ne $rowCurrency)) { throw 'Actual cost currency is unavailable or mixed.' } $sum += [double]$row[$cIdx] - if ($curIdx -ge 0 -and $row[$curIdx]) { $currency = $row[$curIdx] } + $currency = $rowCurrency + $actualRowCount++ } } - $actual = [math]::Round($sum, 2) + if ($actualRowCount -gt 0) { $actual = [math]::Round($sum, 2) } } elseif ($resp.StatusCode -in @(400, 403) -and $resp.Content) { $errMsg = try { ($resp.Content | ConvertFrom-Json).error.message } catch { '' } @@ -373,7 +401,7 @@ function Get-CostDataPerSubscription { # Forecast starts as actual so a sub with no forecast still reports a # number; ForecastSource records that it is month-to-date, not a projection. - $costMap[$sub.Id] = @{ Actual = $actual; Forecast = $actual; Currency = $currency; ForecastSource = 'Actual' } + $costMap[$sub.Id] = @{ Actual = $actual; Forecast = $actual; Currency = $currency; ForecastSource = 'Actual'; ActualPeriod = 'Month to date (UTC query window)' } # Per-sub forecast (skipped for large tenants) if (-not $skipForecast) { @@ -404,14 +432,24 @@ function Get-CostDataPerSubscription { if ($fResp.StatusCode -eq 200) { $total = 0.0 $rowCount = 0 + $forecastCurrency = $null foreach ($page in (Get-CostQueryResponsePage -FirstResponse $fResp -Payload $fBody -Context "forecast for $($sub.Id)")) { $fRes = $page.Content | ConvertFrom-Json if ($fRes.properties.rows.Count -eq 0) { continue } $fcIdx = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('cost', 'pretaxcost', 'costusd') - if ($fcIdx -lt 0) { throw 'Forecast response did not expose the expected Cost column.' } - foreach ($fRow in $fRes.properties.rows) { $total += [double]$fRow[$fcIdx]; $rowCount++ } + $fcCurIdx = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('currency') + if ($fcIdx -lt 0 -or $fcCurIdx -lt 0) { throw 'Forecast response did not expose the expected Cost and Currency columns.' } + foreach ($fRow in $fRes.properties.rows) { + $rowCurrency = ([string]$fRow[$fcCurIdx]).Trim().ToUpperInvariant() + if (-not $rowCurrency -or ($forecastCurrency -and $forecastCurrency -ne $rowCurrency)) { throw 'Forecast currency is unavailable or mixed.' } + $forecastCurrency = $rowCurrency + $total += [double]$fRow[$fcIdx] + $rowCount++ + } } if ($rowCount -gt 0) { + if ($currency -and $currency -ne $forecastCurrency) { throw 'Actual cost and forecast currency differ.' } + $costMap[$sub.Id].Currency = $forecastCurrency $costMap[$sub.Id].Forecast = [math]::Round($total, 2) $costMap[$sub.Id].ForecastSource = 'Forecast' } diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 index a6df04a80..7fae92e6a 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -52,7 +52,7 @@ function Resolve-PolicyEffect { if (-not [string]::IsNullOrWhiteSpace($AssignmentEffect) -and $AssignmentEffect -ne '-') { return (Format-PolicyEffectName $AssignmentEffect) } - if ($IsInitiative) { return 'varies' } + if ($IsInitiative) { return 'varies (Initiative)' } if (-not $Definition) { return '-' } # "[parameters('effect')]" defers to the parameter default; a bare word is the effect. @@ -122,6 +122,7 @@ function Get-PolicyInventory { $complianceMap = @{} $gotAssignments = $false $gotCompliance = $false + $subFailures = [System.Collections.Generic.List[string]]::new() # -- Strategy 1: ARM REST API for ALL effective assignments ---------- # Resource Graph policyresources at subscription scope only returns @@ -131,7 +132,6 @@ function Get-PolicyInventory { try { Write-Host " Querying policy assignments via ARM REST API..." -ForegroundColor Cyan $seenIds = @{} - $subFailures = [System.Collections.Generic.List[string]]::new() foreach ($sub in $Subscriptions) { # Scoped per subscription: a transient failure on one must not abandon # the rest of the tenant and leave a partial result looking complete. @@ -145,7 +145,13 @@ function Get-PolicyInventory { break } $body = $resp.Content | ConvertFrom-Json + if ($null -eq $body -or $body.value -isnot [array]) { + throw 'The policy assignment response has no valid value collection.' + } foreach ($a in $body.value) { + if ([string]::IsNullOrWhiteSpace([string]$a.id) -or [string]::IsNullOrWhiteSpace([string]$a.properties.policyDefinitionId)) { + throw 'A policy assignment has no resource ID or policy definition ID.' + } # De-duplicate (same MG assignment appears under each sub) if ($seenIds.ContainsKey($a.id)) { continue } $seenIds[$a.id] = $true @@ -189,12 +195,13 @@ function Get-PolicyInventory { foreach ($f in ($subFailures | Select-Object -First 3)) { Write-Verbose " $f" } } - if ($allAssignments.Count -gt 0) { + if ($subFailures.Count -eq 0 -or $allAssignments.Count -gt 0) { $gotAssignments = $true Write-Host " ARM REST API: $($allAssignments.Count) unique policy assignments (including inherited)" -ForegroundColor Green } } catch { + [void]$subFailures.Add("ARM REST policy query: $($_.Exception.Message)") Write-Warning " ARM REST policy query failed: $($_.Exception.Message)" } @@ -411,6 +418,9 @@ policyresources return [PSCustomObject]@{ Assignments = $unique AssignmentCount = $unique.Count + CoverageIncomplete = ($subFailures.Count -gt 0) + AssignmentErrors = $subFailures.ToArray() + Note = if ($subFailures.Count -gt 0) { 'Some effective policy assignments could not be read. Missing assignments cannot be determined from this inventory.' } else { $null } ComplianceBySubMap = $complianceMap CompliancePct = $compliancePct TotalCompliant = $totalCompliant diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 index 1b2bf221e..8fe880e1c 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 @@ -25,6 +25,7 @@ function Get-PolicyRecommendations { [CmdletBinding()] param( [Parameter(Mandatory)] + [AllowEmptyCollection()] [object[]]$ExistingAssignments # Policy assignment objects from Get-PolicyInventory ) @@ -201,20 +202,63 @@ function Get-PolicyRecommendations { # -- Match existing assignments against recommendations ------------ $existingDefIds = @{} - $existingNames = @{} - foreach ($a in $ExistingAssignments) { - if ($a.PolicyDefId) { - $existingDefIds[$a.PolicyDefId.ToLower()] = $true + $initiativeCache = @{} + $initiativeErrors = [System.Collections.Generic.List[object]]::new() + $scopePattern = '(?:(?:/subscriptions/[0-9a-fA-F-]{36})|(?:/providers/Microsoft\.Management/managementGroups/[A-Za-z0-9._()-]+))?' + $initiativePattern = "^$scopePattern/providers/Microsoft\.Authorization/policySetDefinitions/[A-Za-z0-9._()-]+$" + $policyPattern = "^$scopePattern/providers/Microsoft\.Authorization/policyDefinitions/[A-Za-z0-9._()-]+(?:/versions/[0-9.]+)?$" + foreach ($assignment in $ExistingAssignments) { + $definitionId = ([string]$assignment.PolicyDefId).TrimEnd('/') + if (-not $definitionId) { continue } + $isInitiative = $assignment.Origin -eq 'Initiative' -or $definitionId -match '/policySetDefinitions/' + $memberIds = @($definitionId) + if ($isInitiative) { + if (-not $initiativeCache.ContainsKey($definitionId)) { + try { + if ($definitionId -notmatch $initiativePattern) { throw 'The initiative ID is not a valid policy set definition resource ID.' } + $response = Invoke-AzRestMethodWithRetry -Path "$($definitionId)?api-version=2023-04-01" -Method GET + if (-not $response -or $response.StatusCode -ne 200 -or -not $response.Content) { + throw "Initiative membership returned HTTP $($response.StatusCode)." + } + $properties = ($response.Content | ConvertFrom-Json -ErrorAction Stop).properties + if (-not $properties -or $null -eq $properties.policyDefinitions -or $properties.policyDefinitions -isnot [array]) { + throw 'The initiative response has no valid policyDefinitions collection.' + } + $members = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($member in $properties.policyDefinitions) { + $memberId = ([string]$member.policyDefinitionId).TrimEnd('/') + if ($memberId -notmatch $policyPattern) { throw 'An initiative member has an invalid policy definition ID.' } + [void]$members.Add(($memberId -replace '/versions/[0-9.]+$', '')) + } + $initiativeCache[$definitionId] = @($members) + } + catch { + $initiativeCache[$definitionId] = @() + [void]$initiativeErrors.Add([pscustomobject]@{ InitiativeId = $definitionId; Error = $_.Exception.Message }) + } + } + $memberIds = @($initiativeCache[$definitionId]) } - if ($a.AssignmentName) { - $existingNames[$a.AssignmentName.ToLower()] = $true + foreach ($memberId in $memberIds) { + $policyId = $memberId -replace '/versions/[0-9.]+$', '' + if (-not $existingDefIds.ContainsKey($policyId)) { $existingDefIds[$policyId] = [System.Collections.Generic.List[object]]::new() } + [void]$existingDefIds[$policyId].Add([pscustomobject]@{ + AssignmentId = $assignment.AssignmentId + AssignmentName = $assignment.AssignmentName + Scope = $assignment.Scope + EnforcementMode = $assignment.EnforcementMode + Source = if ($isInitiative) { 'Initiative' } else { 'Direct' } + InitiativeId = if ($isInitiative) { $definitionId } else { $null } + }) } } $analysis = foreach ($rec in $recommendedPolicies) { - $foundById = $existingDefIds.ContainsKey($rec.PolicyDefId.ToLower()) - $foundByName = $existingNames.ContainsKey($rec.DisplayName.ToLower()) - $status = if ($foundById -or $foundByName) { 'Assigned' } else { 'Missing' } + $matchedAssignments = @($existingDefIds[$rec.PolicyDefId] | Where-Object { $null -ne $_ }) + $status = if (@($matchedAssignments | Where-Object Source -EQ 'Direct').Count -gt 0) { 'Assigned' } + elseif ($matchedAssignments.Count -gt 0) { 'Assigned (Initiative)' } + elseif ($initiativeErrors.Count -gt 0) { 'Unknown' } + else { 'Missing' } [PSCustomObject]@{ DisplayName = $rec.DisplayName @@ -228,16 +272,24 @@ function Get-PolicyRecommendations { PolicyDefId = $rec.PolicyDefId Reference = $rec.Reference Parameters = if ($rec.Parameters) { $rec.Parameters } else { @() } + MatchedAssignments = $matchedAssignments + Note = if ($status -eq 'Unknown') { 'Initiative membership is incomplete. This policy cannot be confirmed missing.' } + elseif ($status -eq 'Missing') { 'No matching definition ID was found in the supplied assignments or readable initiatives. Equivalent custom policies are not assessed.' } + else { 'Assignment presence does not establish enforcement, parameter settings, exclusions, or compliance. Review the matched assignments.' } } } $missing = @($analysis | Where-Object { $_.Status -eq 'Missing' }) - $assigned = @($analysis | Where-Object { $_.Status -eq 'Assigned' }) + $assigned = @($analysis | Where-Object { $_.Status -in @('Assigned', 'Assigned (Initiative)') }) + $unknown = @($analysis | Where-Object Status -EQ 'Unknown') return [PSCustomObject]@{ Analysis = $analysis Missing = $missing Assigned = $assigned - CompliancePct = [math]::Round(($assigned.Count / $analysis.Count) * 100, 0) + Unknown = $unknown + InitiativeErrors = $initiativeErrors.ToArray() + CoverageIncomplete = ($initiativeErrors.Count -gt 0) + CompliancePct = if ($initiativeErrors.Count -eq 0) { [math]::Round(($assigned.Count / $analysis.Count) * 100, 0) } else { $null } } } diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 index dfa4cc02a..85c7cda0d 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -1263,7 +1263,7 @@ function ConvertTo-TagInventoryFromHub { $valArray = @() foreach ($v in $kv.Value.Values.GetEnumerator()) { $valArray += [PSCustomObject]@{ - TagValue = $v.Key + Value = $v.Key ResourceCount = $v.Value.ResourceCount ResourceTypes = @($v.Value.ResourceTypes.Keys) } diff --git a/src/powershell/Public/Start-FinOpsMultitool.ps1 b/src/powershell/Public/Start-FinOpsMultitool.ps1 index 74a1c0cff..dd0597939 100644 --- a/src/powershell/Public/Start-FinOpsMultitool.ps1 +++ b/src/powershell/Public/Start-FinOpsMultitool.ps1 @@ -97,6 +97,10 @@ function Start-FinOpsMultitool { [switch]$NonInteractive ) + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "FinOps Multitool requires PowerShell 7 or later. This session is PowerShell $($PSVersionTable.PSVersion). Open PowerShell 7 with 'pwsh', import the module there, and run Start-FinOpsMultitool again. No scan was started." + } + # Locate the Multitool TUI implementation $multitoolRoot = Join-Path -Path $PSScriptRoot -ChildPath '../Private/FinOpsMultitool' $tuiScript = Join-Path -Path $multitoolRoot -ChildPath 'Invoke-FinOpsMultitool.ps1' diff --git a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 index d52036bd0..d281e3b4b 100644 --- a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 +++ b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 @@ -203,6 +203,99 @@ Describe 'Cost Management query pagination' { } Context 'Actual and forecast totals' { + It 'Keeps missing actuals unavailable and preserves forecast currency ()' -ForEach @( + @{ QueryPath = 'PerSubscription' } + @{ QueryPath = 'ManagementGroup' } + @{ QueryPath = 'ManagementGroupFallback' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath } { + param($QueryPath) + $fixturePath = $QueryPath + Mock Resolve-CostMgId { 'test-management-group' } + Mock Invoke-AzRestMethodWithRetry { + $isForecast = $Path -like '*forecast*' + if ($fixturePath -eq 'ManagementGroupFallback' -and $isForecast -and $Path -like '/providers/Microsoft.Management/*') { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $properties = @{ + columns = @(@{ name = 'Currency' }, @{ name = 'SubscriptionId' }, @{ name = 'Cost' }) + rows = @() + } + if ($isForecast) { $properties.rows = @(, @('EUR', '11111111-1111-1111-1111-111111111111', 375.0)) } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Forecast only' }) + + $result = if ($fixturePath -eq 'PerSubscription') { Get-CostDataPerSubscription -Subscriptions $subscriptions } + else { Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + + $entry = $result[$subscriptions[0].Id] + $entry.Actual | Should -BeNullOrEmpty + $entry.Forecast | Should -Be 375 + $entry.Currency | Should -Be 'EUR' + $entry.ForecastSource | Should -Be 'Forecast' + } + } + + It 'Preserves measured actuals and rejects a different forecast currency ()' -ForEach @( + @{ QueryPath = 'PerSubscription'; Amount = 0.0 } + @{ QueryPath = 'ManagementGroup'; Amount = 0.0 } + @{ QueryPath = 'PerSubscription'; Amount = -5.25 } + @{ QueryPath = 'ManagementGroup'; Amount = -5.25 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath; Amount = $Amount } { + param($QueryPath, $Amount) + $fixturePath = $QueryPath + $fixtureAmount = $Amount + $forecastUnit = 'EUR' + Mock Resolve-CostMgId { 'test-management-group' } + Mock Invoke-AzRestMethodWithRetry { + $isForecast = $Path -like '*forecast*' + $unit = if ($isForecast) { $forecastUnit } else { 'EUR' } + $value = if ($isForecast) { 375.0 } else { $fixtureAmount } + $properties = @{ + columns = @(@{ name = 'Currency' }, @{ name = 'SubscriptionId' }, @{ name = 'Cost' }) + rows = @(, @($unit, '11111111-1111-1111-1111-111111111111', $value)) + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result = if ($fixturePath -eq 'PerSubscription') { Get-CostDataPerSubscription -Subscriptions $subscriptions } + else { Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + + $result[$subscriptions[0].Id].Actual | Should -Be $fixtureAmount + $result[$subscriptions[0].Id].Currency | Should -Be 'EUR' + $result[$subscriptions[0].Id].ActualPeriod | Should -Be 'Month to date (UTC query window)' + $forecastUnit = 'USD' + { + if ($fixturePath -eq 'PerSubscription') { Get-CostDataPerSubscription -Subscriptions $subscriptions } + else { Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + } | Should -Throw '*currency*' + } + } + + It 'Retains selected subscriptions absent from both management-group result sets as unavailable' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { 'test-management-group' } + Mock Invoke-AzRestMethodWithRetry { + [pscustomobject]@{ StatusCode = 200; Content = '{"properties":{"columns":[{"name":"Currency"},{"name":"SubscriptionId"},{"name":"Cost"}],"rows":[["EUR","11111111-1111-1111-1111-111111111111",100]]}}' } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Present' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Absent' } + ) + + $result = Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + + $result.Count | Should -Be 2 + $result[$subscriptions[1].Id].Actual | Should -BeNullOrEmpty + $result[$subscriptions[1].Id].Forecast | Should -BeNullOrEmpty + $result[$subscriptions[1].Id].Currency | Should -BeNullOrEmpty + $result[$subscriptions[1].Id].ForecastSource | Should -Be 'Unavailable' + } + } + It 'Sums complete pages once (, empty first page: )' -ForEach @( @{ QueryPath = 'PerSubscription'; EmptyFirstPage = $false } @{ QueryPath = 'ManagementGroup'; EmptyFirstPage = $false } diff --git a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 index 1f5950064..6240c3707 100644 --- a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 +++ b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 @@ -206,6 +206,164 @@ Describe 'FinOps Multitool safety' { Should -Invoke Read-Host -Times 0 -Exactly } + It 'Starts the HTML story with scoped spend and visible evidence gaps' { + $reportRoot = Join-Path $TestDrive 'finops-story' + Mock Write-Host { } + $permissionInfo = @{ 'Get-CostTrend' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Query' } } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Production '; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Development'; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } + ) + $results = @{ + 'Get-CostData' = @{ + $subscriptions[0].Id = @{ Actual = 100; Currency = 'EUR'; Name = $subscriptions[0].Name; ActualPeriod = '2026-08-01 to 2026-08-31'; Forecast = $null; ForecastSource = 'Unavailable' } + $subscriptions[1].Id = @{ Actual = 200; Currency = 'USD'; Name = $subscriptions[1].Name; ActualPeriod = '2026-09-01 to 2026-09-18'; Forecast = 300; ForecastSource = 'Forecast' } + } + 'Get-CostTrend' = @() + '_error_Get-CostTrend' = '429 Too Many Requests: retry later ' + } + $modules = @( + @{ Fn = 'Get-CostData'; Name = 'Cost Data'; Selected = $true; Category = 'Cost Analysis' } + @{ Fn = 'Get-CostTrend'; Name = 'Cost Trend'; Selected = $true; Category = 'Cost Analysis' } + ) + + $null = Show-ResultsSummary -Results $results -Modules $modules -Subscriptions $subscriptions -ExportPath $reportRoot -DataSourceLabel 'FinOps Hub (fixture)' -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $story = [regex]::Match($html, '(?s)
]*>.*?
').Value + $story | Should -Not -BeNullOrEmpty + $story | Should -Match 'Observed spend' + $story | Should -Match '2026-08-01 to 2026-08-31' + $story | Should -Match '2026-09-01 to 2026-09-18' + $story | Should -Match 'EUR 100.00' + $story | Should -Match 'USD 200.00' + $story | Should -Match 'Production <east>' + $story | Should -Match 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + $story | Should -Match 'FinOps Hub \(fixture\)' + $story | Should -Match 'Scan status' + $story | Should -Match '429 Too Many Requests' + $story | Should -Match '<script>not markup</script>' + $story | Should -Match 'href="#scan-Get-CostTrend"' + $html | Should -Match ([regex]::Escape($permissionInfo['Get-CostTrend'].Role)) + $story | Should -Match 'Full-month forecast unavailable' + $html | Should -Not -Match 'Total Findings|Every measure below is a FinOps Foundation KPI' + $html | Should -Not -Match 'Current period spend:' + $story | Should -Not -Match '' + $tags = @{} + foreach ($index in 1..20) { $tags[('Tag{0:D2}' -f $index)] = 'Fixture' } + $tags.Tag20 = $longValue + $hubRows = @([pscustomobject]@{ ResourceId = '/resources/one'; ResourceType = 'fixture'; Tags = ($tags | ConvertTo-Json -Compress) }) + foreach ($index in 1..6) { + $hubRows += [pscustomobject]@{ ResourceId = "/resources/extra-$index"; ResourceType = 'fixture'; Tags = (@{ Tag20 = "Other-$index" } | ConvertTo-Json -Compress) } + } + $inventory = ConvertTo-TagInventoryFromHub -HubData $hubRows + $modules = @(@{ Fn = 'Get-TagInventory'; Name = 'Tag Inventory'; Selected = $true; Category = 'Governance' }) + + $null = Show-ResultsSummary -Results @{ 'Get-TagInventory' = $inventory } -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + [regex]::Matches($html, 'Tag\d{2}').Count | Should -Be 20 + $tagRow = [regex]::Match($html, '(?s)Tag20.*?').Value + $tagRow | Should -Match ([regex]::Escape([System.Net.WebUtility]::HtmlEncode($longValue))) + foreach ($index in 1..6) { $tagRow | Should -Match "Other-$index" } + $tagRow | Should -Not -Match '…|" + $inventory = ConvertTo-TagInventoryFromHub -HubData @([pscustomobject]@{ + ResourceId = '/resources/fixture'; ResourceType = 'Fixture'; Tags = (@{ CostCenter = $payload } | ConvertTo-Json -Compress) + }) + $modules = @(@{ Fn = 'Get-TagInventory'; Name = 'Tag Inventory'; Selected = $true; Category = 'Governance' }) + + $null = Show-ResultsSummary -Results @{ 'Get-TagInventory' = $inventory } -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + ($captured -join '') | Should -Not -Match '[\p{Cc}\p{Cf}]' + ($captured -join '') | Should -Match '\\u001B\[2J' + $inventory.TagNames.CostCenter.Values[0].Value | Should -BeExactly $payload + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match '<script>example</script>' + $html | Should -Not -Match '' + } + It 'Creates distinct run folders without changing existing reports' { $root = Join-Path $TestDrive 'reports' @@ -993,6 +1039,8 @@ Describe 'FinOps Multitool safety' { InModuleScope FinOpsMultitool -Parameters @{ ModuleRoot = $script:ModuleRoot } { param($ModuleRoot) $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $console = $launcherAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Write-FinOpsConsole' }, $true) + . ([scriptblock]::Create($console.Extent.Text)) $formatter = $launcherAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Write-ColorizedLine' }, $true) . ([scriptblock]::Create($formatter.Extent.Text)) $captured = [System.Collections.Generic.List[string]]::new() @@ -1766,6 +1814,8 @@ Describe 'FinOps Multitool cost math' { ) $data = ConvertTo-TagInventoryFromHub -HubData $hubRows $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $console = $launcherAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Write-FinOpsConsole' }, $true) + . ([scriptblock]::Create($console.Extent.Text)) $switches = $launcherAst.FindAll({ $args[0] -is [System.Management.Automation.Language.SwitchStatementAst] }, $true) $branches = @($switches.Clauses | Where-Object { $_.Item1.Value -eq 'Get-TagInventory' -and $_.Item2.Extent.Text.Contains('Top values') }) $branches.Count | Should -Be 2 @@ -1872,7 +1922,7 @@ Describe 'FinOps Multitool cost math' { $fixtureDate = $ChargeDate $mixedPeriods = $Period -eq 'Mixed' $scriptAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) - foreach ($definition in $scriptAst.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -in @('Invoke-SelectedScans', 'Write-SectionHeader') }, $true)) { + foreach ($definition in $scriptAst.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -in @('Invoke-SelectedScans', 'Write-SectionHeader', 'Write-FinOpsConsole') }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } Mock Get-Date { [datetime]::new(2026, 9, 17, 12, 0, 0, [DateTimeKind]::Utc) } @@ -1964,6 +2014,8 @@ Describe 'FinOps Multitool cost math' { param($Source, $ModuleRoot) $sourceName = $Source $scriptAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $console = $scriptAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Write-FinOpsConsole' }, $true) + . ([scriptblock]::Create($console.Extent.Text)) $runner = $scriptAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Invoke-SelectedScans' }, $true) . ([scriptblock]::Create($runner.Extent.Text)) $sectionHeader = $scriptAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Write-SectionHeader' }, $true) diff --git a/src/powershell/Tests/Unit/ParquetPackageClient.Tests.ps1 b/src/powershell/Tests/Unit/ParquetPackageClient.Tests.ps1 index 6627f03cf..5475510f5 100644 --- a/src/powershell/Tests/Unit/ParquetPackageClient.Tests.ps1 +++ b/src/powershell/Tests/Unit/ParquetPackageClient.Tests.ps1 @@ -24,6 +24,124 @@ Describe 'Parquet package acquisition' { Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue } + Context 'Private data directories' { + It 'Uses application data without a shared-temp fallback' { + $base = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData, [Environment+SpecialFolderOption]::DoNotVerify) + Get-FinOpsParquetCachePath | Should -Be (Join-Path $base 'FinOpsMultitool/parquet') + (Get-Command Get-FinOpsParquetCachePath).Definition | Should -Not -Match 'GetTempPath' + } + + It 'Creates a new owner-only directory and refuses to reuse it' { + $path = New-FinOpsPrivateDirectory -Path (Join-Path $TestDrive 'private-data') -RequireNew + if ($IsWindows) { + $security = Get-Acl -LiteralPath $path + $security.AreAccessRulesProtected | Should -BeTrue + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + try { + $rules = @($security.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) + $rules.Count | Should -Be 1 + $rules[0].IdentityReference.Value | Should -Be $identity.User.Value + } + finally { $identity.Dispose() } + } + else { + $mode = [IO.File]::GetUnixFileMode($path) + ([int]$mode -band 511) | Should -Be 448 + } + { New-FinOpsPrivateDirectory -Path $path -RequireNew } | Should -Throw '*already exists*' + } + + It 'Refuses a cache writable by other Windows users' -Skip:(-not $IsWindows) { + $path = New-FinOpsPrivateDirectory -Path (Join-Path $TestDrive 'unsafe-cache') -RequireNew + $security = Get-Acl -LiteralPath $path + $security.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + [Security.Principal.SecurityIdentifier]::new('S-1-1-0'), [Security.AccessControl.FileSystemRights]::Write, + [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow)) + [IO.FileSystemAclExtensions]::SetAccessControl([IO.DirectoryInfo]::new($path), $security) + + { New-FinOpsPrivateDirectory -Path $path } | Should -Throw '*writes by another account*' + } + + It 'Refuses a Windows parent directory writable by other users' -Skip:(-not $IsWindows) { + $parent = New-FinOpsPrivateDirectory -Path (Join-Path $TestDrive 'unsafe-parent') -RequireNew + $security = Get-Acl -LiteralPath $parent + $security.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + [Security.Principal.SecurityIdentifier]::new('S-1-1-0'), [Security.AccessControl.FileSystemRights]::Write, + [Security.AccessControl.InheritanceFlags]::None, [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + [IO.FileSystemAclExtensions]::SetAccessControl([IO.DirectoryInfo]::new($parent), $security) + + { New-FinOpsPrivateDirectory -Path (Join-Path $parent 'download') -RequireNew } | Should -Throw '*writes by another account*' + Test-Path -LiteralPath (Join-Path $parent 'download') | Should -BeFalse + } + + It 'Rejects replacement permissions on a higher Windows ancestor before creating children' -Skip:(-not $IsWindows) { + $grandparent = New-FinOpsPrivateDirectory -Path (Join-Path $TestDrive 'unsafe-grandparent') -RequireNew + $parent = New-FinOpsPrivateDirectory -Path (Join-Path $grandparent 'protected-parent') -RequireNew + $security = Get-Acl -LiteralPath $grandparent + $security.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + [Security.Principal.SecurityIdentifier]::new('S-1-1-0'), [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]::None, [Security.AccessControl.PropagationFlags]::None, + [Security.AccessControl.AccessControlType]::Allow)) + [IO.FileSystemAclExtensions]::SetAccessControl([IO.DirectoryInfo]::new($grandparent), $security) + + { New-FinOpsPrivateDirectory -Path (Join-Path $parent 'new-parent/cache') -RequireNew } | Should -Throw '*writes by another account*' + Test-Path -LiteralPath (Join-Path $parent 'new-parent') | Should -BeFalse + } + + It 'Makes the raw download directory private before downloading and removes it on failure' { + InModuleScope FinOpsMultitool -Parameters @{ FixtureRoot = (Join-Path $TestDrive 'download-root') } { + param($FixtureRoot) + $cachePath = Join-Path $FixtureRoot 'parquet' + $probe = @{ Path = $null; Private = $false } + Mock Get-FinOpsParquetCachePath { $cachePath } + Mock New-AzStorageContext { $null } + Mock Get-AzDataLakeGen2ChildItem { + if ($FileSystem -eq 'ingestion') { return @() } + [pscustomobject]@{ IsDirectory = $false; Path = 'fixture/20260901-20260930/202609201200/run/part.csv' } + } + Mock Get-AzDataLakeGen2ItemContent { + $probe.Path = Split-Path $Destination -Parent + $probe.Private = if ($IsWindows) { (Get-Acl -LiteralPath $probe.Path).AreAccessRulesProtected } + else { ([int][IO.File]::GetUnixFileMode($probe.Path) -band 511) -eq 448 } + throw 'Intentional synthetic stop before downloading.' + } + + { Read-FinOpsHubData -StorageAccountName 'synthetic' -ResourceGroupName 'synthetic' -Months 1 } | Should -Throw '*Intentional synthetic stop*' + + $probe.Private | Should -BeTrue + Split-Path $probe.Path -Parent | Should -Be $FixtureRoot + Test-Path -LiteralPath $probe.Path | Should -BeFalse + } + } + } + + Context 'Pinned restore inputs' { + It 'Restores exact package versions through ' -ForEach @( + @{ ClientKind = 'dotnet' } + @{ ClientKind = 'nuget.exe' } + ) { + $root = Join-Path $TestDrive $ClientKind + [void](New-Item -ItemType Directory -Path $root -Force) + $clientPath = Join-Path $root 'synthetic-client.ps1' + Set-Content -LiteralPath $clientPath -Value 'exit 0' + + Invoke-NuGetRestore -Client @{ Kind = $ClientKind; Path = $clientPath } -PackageId 'Parquet.Net' -Version '4.24.0' -PackageDir (Join-Path $root 'packages') -WorkingPath $root + + $fileName = if ($ClientKind -eq 'dotnet') { 'parquet-restore.csproj' } else { 'packages.config' } + $document = [xml](Get-Content -LiteralPath (Join-Path $root "restore/$fileName") -Raw) + $locked = @(Get-FinOpsParquetPackageLock) + $entries = if ($ClientKind -eq 'dotnet') { @($document.Project.ItemGroup.PackageReference) } else { @($document.packages.package) } + $entries.Count | Should -Be $locked.Count + foreach ($package in $locked) { + if ($ClientKind -eq 'dotnet') { ($entries | Where-Object Include -EQ $package.Id).Version | Should -Be "[$($package.Version)]" } + else { ($entries | Where-Object id -EQ $package.Id).version | Should -Be $package.Version } + } + ($locked | Where-Object Id -EQ 'Snappier').Version | Should -Be '1.3.1' + } + } + Context 'Package root discovery' { It 'Finds the nuget.exe layout of id.version then lib' { @@ -104,6 +222,132 @@ Describe 'Parquet package acquisition' { } } + Context 'Cache provenance' { + It 'Reuses matching cached payloads only after package verification' { + InModuleScope FinOpsMultitool -Parameters @{ FixturePath = (Join-Path $TestDrive 'verified-cache') } { + param($FixturePath) + $cacheRoot = New-FinOpsPrivateDirectory -Path $FixturePath -RequireNew + $source = Join-Path $cacheRoot 'fixture-source' + $packageContent = Join-Path $source 'lib/net8.0' + $packages = Join-Path $cacheRoot 'packages' + $staged = Join-Path $cacheRoot 'lib' + foreach ($directory in @($packageContent, $packages, $staged)) { [void](New-Item -ItemType Directory -Path $directory -Force) } + Set-Content -LiteralPath (Join-Path $packageContent 'Parquet.dll') -Value 'Synthetic package payload' + [IO.Compression.ZipFile]::CreateFromDirectory($source, (Join-Path $packages 'fixture.nupkg')) + $algorithm = [Security.Cryptography.SHA512]::Create() + try { $fixtureHash = [Convert]::ToBase64String($algorithm.ComputeHash([IO.File]::ReadAllBytes((Join-Path $packages 'fixture.nupkg')))) } + finally { $algorithm.Dispose() } + Mock Get-FinOpsParquetPackageLock { @{ Id = 'Fixture'; Version = '1.0.0'; Sha512 = $fixtureHash } } + Copy-Item -LiteralPath (Join-Path $packageContent 'Parquet.dll') -Destination (Join-Path $staged 'Parquet.dll') + New-ParquetManifest -BasePath $cacheRoot -ManifestPath (Join-Path $cacheRoot 'parquet-manifest.json') + $events = [Collections.Generic.List[string]]::new() + Mock Get-FinOpsParquetCachePath { $cacheRoot } + Mock Resolve-NuGetClient { [pscustomobject]@{ Kind = 'nuget.exe'; Path = 'unused' } } + Mock Assert-NuGetPackageSignature { [void]$events.Add('Verify') } + Mock Invoke-NuGetRestore { throw 'A verified cache must not restore packages.' } + Mock Import-ParquetAssemblies { [void]$events.Add('Load') } + + Install-ParquetReader | Should -BeTrue + + @($events) | Should -Be @('Verify', 'Load') + Should -Invoke Invoke-NuGetRestore -Times 0 -Exactly + Should -Invoke Assert-NuGetPackageSignature -Times 1 -Exactly + } + } + + It 'Discards incomplete restore state before retrying package acquisition' { + InModuleScope FinOpsMultitool -Parameters @{ FixturePath = (Join-Path $TestDrive 'partial-cache') } { + param($FixturePath) + $cacheRoot = New-FinOpsPrivateDirectory -Path $FixturePath -RequireNew + $marker = Join-Path $cacheRoot 'restore/obj/unverified-state.txt' + [void](New-Item -ItemType Directory -Path (Split-Path $marker -Parent) -Force) + Set-Content -LiteralPath $marker -Value 'Synthetic incomplete restore state' + Mock Get-FinOpsParquetCachePath { $cacheRoot } + Mock Resolve-NuGetClient { [pscustomobject]@{ Kind = 'dotnet'; Path = 'unused' } } + Mock Invoke-NuGetRestore { + Test-Path -LiteralPath $marker | Should -BeFalse + throw 'Downloads are prohibited in this test.' + } + Mock Import-ParquetAssemblies { } + + Install-ParquetReader -WarningAction SilentlyContinue | Should -BeFalse + + Should -Invoke Invoke-NuGetRestore -Times 1 -Exactly + Should -Invoke Import-ParquetAssemblies -Times 0 -Exactly + } + } + + It 'Requires staged payloads to match the verified package archives' { + $root = Join-Path $TestDrive 'package-payload' + $packageContent = Join-Path $root 'source/lib/net8.0' + $otherFramework = Join-Path $root 'source/lib/net6.0' + $otherRuntime = Join-Path $root 'source/runtimes/other-x64/native' + $staged = Join-Path $root 'cache/lib' + $packages = Join-Path $root 'cache/packages' + foreach ($directory in @($packageContent, $otherFramework, $otherRuntime, $staged, $packages)) { [void](New-Item -ItemType Directory -Path $directory -Force) } + $assembly = Join-Path $packageContent 'Parquet.dll' + Set-Content -LiteralPath $assembly -Value 'Synthetic verified package payload' + Set-Content -LiteralPath (Join-Path $otherFramework 'Parquet.dll') -Value 'Different target framework payload' + Set-Content -LiteralPath (Join-Path $otherRuntime 'fixture.dll') -Value 'Different runtime payload' + [IO.Compression.ZipFile]::CreateFromDirectory((Join-Path $root 'source'), (Join-Path $packages 'fixture.nupkg')) + $algorithm = [Security.Cryptography.SHA512]::Create() + try { $fixtureHash = [Convert]::ToBase64String($algorithm.ComputeHash([IO.File]::ReadAllBytes((Join-Path $packages 'fixture.nupkg')))) } + finally { $algorithm.Dispose() } + Mock Get-FinOpsParquetPackageLock -ModuleName FinOpsMultitool { @{ Id = 'Fixture'; Version = '1.0.0'; Sha512 = $fixtureHash } } + Copy-Item -LiteralPath $assembly -Destination (Join-Path $staged 'Parquet.dll') + + { Assert-ParquetPackagePayload -BasePath (Join-Path $root 'cache') -PackageDir $packages } | Should -Not -Throw + + Copy-Item -LiteralPath (Join-Path $otherFramework 'Parquet.dll') -Destination (Join-Path $staged 'Parquet.dll') -Force + { Assert-ParquetPackagePayload -BasePath (Join-Path $root 'cache') -PackageDir $packages } | Should -Throw '*does not match*' + + Copy-Item -LiteralPath $assembly -Destination (Join-Path $staged 'Parquet.dll') -Force + $runtimeDestination = Join-Path $root 'cache/runtimes/other-x64/native' + [void](New-Item -ItemType Directory -Path $runtimeDestination -Force) + Copy-Item -LiteralPath (Join-Path $otherRuntime 'fixture.dll') -Destination $runtimeDestination + { Assert-ParquetPackagePayload -BasePath (Join-Path $root 'cache') -PackageDir $packages } | Should -Throw '*does not match*' + Remove-Item -LiteralPath (Join-Path $runtimeDestination 'fixture.dll') + + Set-Content -LiteralPath (Join-Path $staged 'Parquet.dll') -Value 'Different payload' + New-ParquetManifest -BasePath (Join-Path $root 'cache') -ManifestPath (Join-Path $root 'cache/parquet-manifest.json') + { Assert-ParquetPackagePayload -BasePath (Join-Path $root 'cache') -PackageDir $packages } | Should -Throw '*does not match*' + + Copy-Item -LiteralPath (Join-Path $packages 'fixture.nupkg') -Destination (Join-Path $packages 'duplicate.nupkg') + { Get-VerifiedParquetPackage -PackageDir $packages } | Should -Throw '*unexpected or duplicate*' + Remove-Item -LiteralPath (Join-Path $packages 'duplicate.nupkg') + Remove-Item -LiteralPath (Join-Path $packages 'fixture.nupkg') + { Get-VerifiedParquetPackage -PackageDir $packages } | Should -Throw '*missing pinned*' + } + + It 'Rejects an unrelated package before invoking the signature verifier' { + $packages = Join-Path $TestDrive 'unexpected-packages' + [void](New-Item -ItemType Directory -Path $packages -Force) + Set-Content -LiteralPath (Join-Path $packages 'unrelated.nupkg') -Value 'Synthetic unrelated archive' + + { Assert-NuGetPackageSignature -Client @{ Kind = 'dotnet'; Path = 'must-not-execute' } -PackageDir $packages } | Should -Throw '*unexpected or duplicate package*' + } + + It 'Does not load a self-hashed cache without verified packages' { + InModuleScope FinOpsMultitool -Parameters @{ FixturePath = (Join-Path $TestDrive 'unverified-cache') } { + param($FixturePath) + $cacheRoot = $FixturePath + $lib = Join-Path $cacheRoot 'lib' + [void](New-Item -ItemType Directory -Path $lib -Force) + Copy-Item -LiteralPath ([object].Assembly.Location) -Destination (Join-Path $lib 'Parquet.dll') + New-ParquetManifest -BasePath $cacheRoot -ManifestPath (Join-Path $cacheRoot 'parquet-manifest.json') + Mock Get-FinOpsParquetCachePath { $cacheRoot } + Mock Resolve-NuGetClient { [pscustomobject]@{ Kind = 'nuget.exe'; Path = 'unused' } } + Mock Assert-NuGetPackageSignature { throw 'Synthetic package verification failure.' } + Mock Invoke-NuGetRestore { throw 'Downloads are prohibited in this test.' } + Mock Import-ParquetAssemblies { } + + Install-ParquetReader -WarningAction SilentlyContinue | Should -BeFalse + + Should -Invoke Import-ParquetAssemblies -Times 0 -Exactly + } + } + } + Context 'Client resolution' { It 'Reports a runtime identifier shaped os-arch' { diff --git a/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 b/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 index 8ba482586..1ccf0c73f 100644 --- a/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 +++ b/src/powershell/Tests/Unit/PolicyEffect.Tests.ps1 @@ -278,6 +278,8 @@ Describe 'Policy effect resolution' { PolicyDefId = '/providers/Microsoft.Authorization/policySetDefinitions/fixture'; Origin = 'Initiative' }) $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $console = $launcherAst.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Write-FinOpsConsole' }, $true) + . ([scriptblock]::Create($console.Extent.Text)) $switches = $launcherAst.FindAll({ $args[0] -is [System.Management.Automation.Language.SwitchStatementAst] }, $true) $branches = @($switches.Clauses | Where-Object { $_.Item1.Value -eq 'Get-PolicyRecommendations' -and $_.Item2.Extent.Text.Contains('$data.Analysis') }) $branches.Count | Should -Be 3 diff --git a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 index d499990df..3ce041385 100644 --- a/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 +++ b/src/powershell/Tests/Unit/Start-FinOpsMultitool.Tests.ps1 @@ -48,6 +48,18 @@ InModuleScope 'FinOpsToolkit' { } Context 'Behavior' { + It 'Shows missing-module guidance before any Azure calls' { + Mock Import-Module { } + Mock Get-Module { $null } + Mock Write-Host { } + Mock Get-AzContext { throw 'Azure must not be queried without required modules.' } + + { Start-FinOpsMultitool -NonInteractive -ErrorAction Stop } | Should -Not -Throw + + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $Object -eq ' MISSING REQUIRED MODULES' } + Should -Invoke Get-AzContext -Times 0 -Exactly + } + It 'Rejects Windows PowerShell 5.1 before scanning through ' -Skip:(-not $IsWindows) -ForEach @( @{ Command = 'Start-FinOpsMultitool'; Script = '../../Public/Start-FinOpsMultitool.ps1' } @{ Command = 'Invoke-FinOpsMultitool'; Script = '../../Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1' } @@ -291,7 +303,7 @@ function Invoke-FinOpsMultitool { $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:RealMultitoolRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) foreach ($definition in $launcherAst.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $args[0].Name -in @('Select-DataSource', 'Read-FinOpsAnswer', 'Invoke-SelectedScans', 'Write-SectionHeader') + $args[0].Name -in @('Select-DataSource', 'Read-FinOpsAnswer', 'Invoke-SelectedScans', 'Write-SectionHeader', 'Write-FinOpsConsole') }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } Mock Read-FinOpsAnswer { '1' } Mock Search-AzGraph { [pscustomobject]@{ name = 'test-hub-storage'; resourceGroup = 'test-hub' } } @@ -318,7 +330,7 @@ function Invoke-FinOpsMultitool { $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:RealMultitoolRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) foreach ($definition in $launcherAst.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and - $args[0].Name -in @('Invoke-SelectedScans', 'Write-SectionHeader') + $args[0].Name -in @('Invoke-SelectedScans', 'Write-SectionHeader', 'Write-FinOpsConsole') }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } Mock Get-TagInventory { Write-Information 'Scanner detail on its own line.' -InformationAction Continue From ed43aabd56005b4ee0661a171d78c041b7a4a0b4 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Sun, 20 Sep 2026 15:46:35 -0600 Subject: [PATCH 138/142] fix(multitool): honor budget history filters --- .../Private/FinOpsMultitool/README.md | 15 +- .../modules/Get-BudgetStatus.ps1 | 87 ++++++++--- .../Tests/Unit/BudgetCoverage.Tests.ps1 | 146 ++++++++++++++++-- 3 files changed, 216 insertions(+), 32 deletions(-) diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md index 1b014f8fd..4361a2712 100644 --- a/src/powershell/Private/FinOpsMultitool/README.md +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -188,10 +188,17 @@ The AHB estimate is separate: `AHBSavingsMonthly` represents 730 hours for the c ### Monitoring -| Scan | What it finds | -| -------------- | --------------------------------- | -| Budget Status | Budget consumption vs. thresholds | -| Anomaly Alerts | Recent cost anomaly detections | +| Scan | What it finds | +| -------------- | -------------------------------------------------------------- | +| Budget Status | Budget consumption vs. thresholds | +| Budget History | Completed-month costs compared with the current monthly budget | +| Anomaly Alerts | Recent cost anomaly detections | + +**Budget History** supports monthly cost budgets with no filter, a tag or dimension `In` filter, or an `and` combination of those filters. An empty filter object (`{}`) means no filter. Filtered budgets use a Cost Management query with the matching filter, even when the primary cost source is a hub. Only unfiltered budgets can reuse the subscription cost trend. Results are cached separately for each subscription and exact filter, including case-sensitive tag values. + +Months before a budget was active for the full month remain **Unavailable**. Unsupported filters, nonmonthly periods, missing budget details, and currency mismatches also remain unavailable; the tool doesn't substitute whole-subscription spend for a filtered budget. Failed or incomplete cost queries remain errors rather than zero spend. Comparisons use the current budget amount and filter, not historical budget revisions. See the [budget filter schema](https://learn.microsoft.com/azure/templates/microsoft.consumption/2023-11-01/budgets#budgetfilter) and [Cost Management query API](https://learn.microsoft.com/rest/api/cost-management/query/usage?view=rest-cost-management-2023-11-01). + +Current forecasts in **Budget Status** come from Azure's budget response, independently of historical actual costs. If the response omits a forecast amount or a compatible currency, the forecast remains **Unavailable**. ### Sustainability diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 index 2bfa88540..4118e7611 100644 --- a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -70,8 +70,8 @@ function Get-BudgetStatus { $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET if ($resp.StatusCode -eq 200) { $sampleBudgets = @(foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -RootNextLink -Context "budget sample for $($sub.Name)")) { - ($page.Content | ConvertFrom-Json -ErrorAction Stop).value - }) + ($page.Content | ConvertFrom-Json -ErrorAction Stop).value + }) if ($sampleBudgets -and $sampleBudgets.Count -gt 0) { $sampleHits++ } } else { $sampleErrors++ } @@ -116,8 +116,8 @@ function Get-BudgetStatus { if ($resp.StatusCode -eq 200) { $budgetRows = @(foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -RootNextLink -Context "budgets for $($sub.Name)")) { - ($page.Content | ConvertFrom-Json -ErrorAction Stop).value - }) + ($page.Content | ConvertFrom-Json -ErrorAction Stop).value + }) if ($budgetRows.Count -gt 0) { $subsWithBudget++ foreach ($budget in $budgetRows) { @@ -295,6 +295,46 @@ function Get-BudgetStatus { } } +function ConvertTo-BudgetHistoryFilter { + [CmdletBinding()] + param( + [AllowNull()][object]$Filter, + [int]$Depth = 0 + ) + + if ($Depth -gt 4) { throw 'Budget filter nesting is unsupported.' } + if ($null -eq $Filter -and $Depth -eq 0) { return $null } + if ($Filter -isnot [System.Collections.IDictionary] -and $Filter -isnot [pscustomobject]) { + throw 'Budget filter must be a structured expression.' + } + $keys = @(if ($Filter -is [System.Collections.IDictionary]) { $Filter.Keys } + else { $Filter.PSObject.Properties | ForEach-Object Name }) + if ($keys.Count -eq 0 -and $Depth -eq 0) { return $null } + if ($keys.Count -ne 1 -or $keys[0] -notin @('and', 'dimensions', 'tags')) { + throw 'Budget filter contains an unsupported or ambiguous expression.' + } + $kind = ([string]$keys[0]).ToLowerInvariant() + if ($kind -eq 'and') { + if ($Filter.and -isnot [array] -or $Filter.and.Count -lt 2) { throw 'Budget filter AND must contain at least two expressions.' } + $children = @(foreach ($child in $Filter.and) { ConvertTo-BudgetHistoryFilter -Filter $child -Depth ($Depth + 1) }) + return [ordered]@{ and = $children } + } + + $comparison = $Filter.$kind + if ($comparison -isnot [System.Collections.IDictionary] -and $comparison -isnot [pscustomobject]) { + throw 'Budget filter comparison is invalid.' + } + $comparisonKeys = @(if ($comparison -is [System.Collections.IDictionary]) { $comparison.Keys } + else { $comparison.PSObject.Properties | ForEach-Object Name }) + if ($comparisonKeys.Count -ne 3 -or @($comparisonKeys | Where-Object { $_ -notin @('name', 'operator', 'values') }).Count -gt 0 -or + $comparison.name -isnot [string] -or [string]::IsNullOrWhiteSpace($comparison.name) -or + $comparison.operator -ne 'In' -or $comparison.values -isnot [array] -or $comparison.values.Count -eq 0 -or + @($comparison.values | Where-Object { $_ -isnot [string] }).Count -gt 0) { + throw 'Budget filter requires a name, the In operator, and string values.' + } + return [ordered]@{ $kind = [ordered]@{ name = $comparison.name; operator = 'In'; values = @($comparison.values) } } +} + function Get-BudgetHistory { [CmdletBinding()] param( @@ -318,7 +358,7 @@ function Get-BudgetHistory { $now = (Get-Date).ToUniversalTime() $monthStart = $now.Date.AddDays(1 - $now.Day) $monthDates = @(for ($monthsAgo = $MonthsBack; $monthsAgo -ge 1; $monthsAgo--) { $monthStart.AddMonths(-$monthsAgo) }) - $costCache = @{} + $costCache = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) foreach ($budget in $Budgets) { $reason = $null @@ -326,8 +366,15 @@ function Get-BudgetHistory { $periodStart = $null $periodEnd = [datetime]::MaxValue $subId = [string]$budget.SubscriptionId - if ($budget.Filter -or $budget.TagFilter) { $reason = 'Filtered budget history requires costs for the same filter.' } - elseif ($budget.Category -ne 'Cost' -or $budget.TimeGrain -ne 'Monthly') { $reason = 'Subscription monthly costs cannot reconstruct this budget category or period.' } + $queryFilter = $null + try { + $queryFilter = ConvertTo-BudgetHistoryFilter -Filter $budget.Filter + if ($null -eq $queryFilter -and $budget.TagFilter) { throw 'The structured budget filter is unavailable.' } + } + catch { $reason = "Budget history cannot apply this filter: $($_.Exception.Message)" } + $filterKey = if ($null -eq $queryFilter) { '' } else { ConvertTo-Json -InputObject $queryFilter -Depth 20 -Compress } + $cacheKey = "$subId|$filterKey" + if (-not $reason -and ($budget.Category -ne 'Cost' -or $budget.TimeGrain -ne 'Monthly')) { $reason = 'Subscription monthly costs cannot reconstruct this budget category or period.' } elseif (-not $budget.Currency) { $reason = 'Budget currency is unavailable.' } elseif ($budget.Scope -and $budget.Scope -ne "/subscriptions/$subId") { $reason = 'Budget scope differs from the subscription cost scope.' } try { @@ -343,9 +390,9 @@ function Get-BudgetHistory { catch { $reason = 'Budget validity period is unavailable.' } $activeMonths = if (-not $reason) { @($monthDates | Where-Object { $_ -ge $periodStart -and $_.AddMonths(1) -le $periodEnd }) } else { @() } - if (-not $reason -and $activeMonths.Count -gt 0 -and -not $costCache.ContainsKey($subId)) { + if (-not $reason -and $activeMonths.Count -gt 0 -and -not $costCache.ContainsKey($cacheKey)) { $monthlyCosts = @{} - if ($CostTrend -and $CostTrend.BySubscription -and $CostTrend.BySubscription[$subId]) { + if ($null -eq $queryFilter -and $CostTrend -and $CostTrend.BySubscription -and $CostTrend.BySubscription[$subId]) { try { foreach ($entry in $CostTrend.BySubscription[$subId]) { if ($entry.MonthDate -isnot [datetime] -or -not $entry.Currency) { throw 'Cached cost date or currency is missing.' } @@ -361,11 +408,13 @@ function Get-BudgetHistory { $covered = $true foreach ($month in $monthDates) { if (-not $monthlyCosts.ContainsKey($month.ToString('yyyy-MM'))) { $covered = $false } } if (-not $covered) { + $dataset = @{ granularity = 'Monthly'; aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } } + if ($null -ne $queryFilter) { $dataset.filter = $queryFilter } $body = @{ type = 'ActualCost'; timeframe = 'Custom' timePeriod = @{ from = $monthDates[0].ToString('yyyy-MM-dd'); to = $monthStart.AddDays(-1).ToString('yyyy-MM-dd') } - dataset = @{ granularity = 'Monthly'; aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } } - } | ConvertTo-Json -Depth 10 + dataset = $dataset + } | ConvertTo-Json -Depth 20 $costPath = "/subscriptions/$subId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" $response = Invoke-AzRestMethodWithRetry -Path $costPath -Method POST -Payload $body $result = Get-CostQueryResult -FirstResponse $response -Payload $body -Context "budget history for $($budget.Subscription)" @@ -395,7 +444,7 @@ function Get-BudgetHistory { $monthlyCosts[$key].Cost += [double]$row[$costIndex] } } - $costCache[$subId] = $monthlyCosts + $costCache[$cacheKey] = $monthlyCosts } foreach ($month in $monthDates) { @@ -408,7 +457,7 @@ function Get-BudgetHistory { $rowReason = 'The budget was not active for this full month.' } if (-not $rowReason) { - $cost = $costCache[$subId][$key] + $cost = $costCache[$cacheKey][$key] if ($cost.Currency -and $cost.Currency -ne $budget.Currency) { $rowReason = 'Cost currency does not match the budget currency.' } else { $actual = [math]::Round($cost.Cost, 2) @@ -417,11 +466,13 @@ function Get-BudgetHistory { } } [void]$history.Add([PSCustomObject]@{ - Subscription = $budget.Subscription; BudgetName = $budget.BudgetName; Month = $month.ToString('MMM yyyy'); MonthSort = $key - BudgetAmount = if ($budget.TimeGrain -eq 'Monthly') { $budgetAmount } else { $null } - ActualSpend = $actual; PctUsed = $pctUsed; Status = $status; Currency = $budget.Currency - Note = if ($rowReason) { $rowReason } else { 'Compared with the current budget amount; prior budget revisions are unavailable.' } - }) + Subscription = $budget.Subscription; BudgetName = $budget.BudgetName; Month = $month.ToString('MMM yyyy'); MonthSort = $key + BudgetAmount = if ($budget.TimeGrain -eq 'Monthly') { $budgetAmount } else { $null } + ActualSpend = $actual; PctUsed = $pctUsed; Status = $status; Currency = $budget.Currency + Note = if ($rowReason) { $rowReason } + elseif ($null -ne $queryFilter) { 'Costs use the current budget filter and amount; prior budget revisions are unavailable.' } + else { 'Compared with the current budget amount; prior budget revisions are unavailable.' } + }) } } diff --git a/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 index 4f141f1b0..2c158d843 100644 --- a/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 +++ b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 @@ -123,13 +123,41 @@ Describe 'Budget coverage reporting' { } } + It 'Calculates history when the budget API returns ' -ForEach @( + @{ FilterCase = 'no filter property'; FilterJson = $null } + @{ FilterCase = 'an empty filter object'; FilterJson = '{}' } + ) { + $properties = @{ + amount = 100; timeGrain = 'Monthly'; category = 'Cost' + currentSpend = @{ amount = 10; unit = 'USD' } + timePeriod = @{ startDate = '2020-01-01T00:00:00Z'; endDate = '2030-12-31T00:00:00Z' } + } + if ($null -ne $FilterJson) { $properties.filter = $FilterJson | ConvertFrom-Json } + $apiResponse = @{ value = @(@{ name = 'monthly-budget'; properties = $properties }) } | ConvertTo-Json -Depth 10 + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Method -ne 'GET' -or $Path -notlike '*Microsoft.Consumption/budgets*') { throw 'Cached unfiltered costs must not issue a cost query.' } + [pscustomobject]@{ StatusCode = 200; Content = $apiResponse } + } + + $inventory = Get-BudgetStatus -Subscriptions @($script:TwoSubs[0]) + $history = @(Get-BudgetHistory -Budgets $inventory.Budgets -MonthsBack 6 -CostTrend $script:Trend6) + + $history.Count | Should -Be 6 + foreach ($row in $history) { + $row.ActualSpend | Should -Be 10 + $row.PctUsed | Should -Be 10 + $row.Status | Should -Be 'Under' + } + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly + } + It 'Queries live cost when cached trend is shorter than the requested window' { Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { [PSCustomObject]@{ StatusCode = 403; Content = '{}' } } { Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 6 -CostTrend $script:Trend2 -WarningAction SilentlyContinue } | - Should -Throw '*403*incomplete*' + Should -Throw '*403*incomplete*' # Without the coverage check the four uncovered months would be # reported as zero spend and therefore as being under budget. @@ -147,10 +175,14 @@ Describe 'Budget coverage reporting' { @($rows).Count | Should -Be 6 } - It 'Includes monthly spend from every page (continuation fails: )' -ForEach @( - @{ PageFails = $false } - @{ PageFails = $true } + It 'Includes monthly spend from every page (continuation fails: , filtered: )' -ForEach @( + @{ PageFails = $false; HasFilter = $false } + @{ PageFails = $true; HasFilter = $false } + @{ PageFails = $false; HasFilter = $true } + @{ PageFails = $true; HasFilter = $true } ) { + $budget = $script:HistBudget[0] | Select-Object * + if ($HasFilter) { $budget.Filter = @{ tags = @{ name = 'Environment'; operator = 'In'; values = @('Prod') } } } Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { $isNextPage = $Path -like '*page=2' if ($PageFails -and $isNextPage) { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } @@ -159,28 +191,122 @@ Describe 'Budget coverage reporting' { $month = (Get-Date).AddMonths($monthsAgo).ToString('yyyyMM01') $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'BillingMonth' }, @{ name = 'Currency' }) - rows = @(, @($amount, $month, 'USD')) + rows = @(, @($amount, $month, 'USD')) } if (-not $isNextPage) { $properties.nextLink = "$Path&page=2" } [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } } if ($PageFails) { - { Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 2 } | Should -Throw '*incomplete*' + { Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 } | Should -Throw '*incomplete*' } else { - $rows = @(Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 2) + $rows = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2) $rows.Count | Should -Be 2 ($rows | Measure-Object -Property ActualSpend -Sum).Sum | Should -Be 160 @($rows | Where-Object Status -EQ 'Over').Count | Should -Be 1 } Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { - $Path -like '*page=2' -and $Method -eq 'POST' -and ($Payload | ConvertFrom-Json).dataset.granularity -eq 'Monthly' + $dataset = ($Payload | ConvertFrom-Json).dataset + $filterMatches = if ($HasFilter) { $dataset.filter.tags.name -eq 'Environment' -and $dataset.filter.tags.values[0] -ceq 'Prod' } + else { $null -eq $dataset.filter } + $Path -like '*page=2' -and $Method -eq 'POST' -and $dataset.granularity -eq 'Monthly' -and $filterMatches + } + } + + It 'Queries costs using the same as the budget instead of unfiltered cached totals' -ForEach @( + @{ FilterCase = 'tag filter'; FilterJson = '{"tags":{"name":"Environment","operator":"In","values":["Prod"]}}' } + @{ FilterCase = 'dimension filter'; FilterJson = '{"dimensions":{"name":"ResourceGroupName","operator":"In","values":["analytics"]}}' } + @{ FilterCase = 'combined filter'; FilterJson = '{"and":[{"tags":{"name":"Environment","operator":"In","values":["Prod"]}},{"dimensions":{"name":"ResourceGroupName","operator":"In","values":["analytics"]}}]}' } + ) { + $budget = $script:HistBudget[0] | Select-Object * + $budget.Filter = $FilterJson | ConvertFrom-Json + $capturedQueries = [Collections.Generic.List[object]]::new() + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + $capturedQueries.Add(($Payload | ConvertFrom-Json)) + $rows = @(2, 1 | ForEach-Object { , @(40.0, (Get-Date).AddMonths(-$_).ToString('yyyyMM01'), 'USD') }) + $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'BillingMonth' }, @{ name = 'Currency' }); rows = $rows } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + + $history = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6) + + $history.Count | Should -Be 2 + foreach ($row in $history) { $row.ActualSpend | Should -Be 40; $row.Status | Should -Be 'Under' } + $capturedQueries.Count | Should -Be 1 + ($capturedQueries[0].dataset.filter | ConvertTo-Json -Depth 10 -Compress) | Should -BeExactly $FilterJson + $capturedQueries[0].type | Should -Be 'ActualCost' + $capturedQueries[0].dataset.granularity | Should -Be 'Monthly' + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + $Method -eq 'POST' -and $Path -like "/subscriptions/$($script:SubA)/providers/Microsoft.CostManagement/query*" + } + } + + It 'Caches history by exact filter without mixing distinct tag values or unfiltered totals' { + $budgets = @(foreach ($name in @('Upper', 'Lower', 'Same filter', 'Unfiltered')) { + $budget = $script:HistBudget[0] | Select-Object * + $budget.BudgetName = $name + if ($name -ne 'Unfiltered') { + $budget.Filter = @{ tags = @{ name = 'Environment'; operator = 'In'; values = @($(if ($name -eq 'Lower') { 'prod' } else { 'Prod' })) } } + } + $budget + }) + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + $filter = ($Payload | ConvertFrom-Json).dataset.filter + $amount = if ($filter.tags.values[0] -ceq 'Prod') { 40.0 } else { 60.0 } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'BillingMonth' }, @{ name = 'Currency' }) + rows = @(, @($amount, (Get-Date).AddMonths(-1).ToString('yyyyMM01'), 'USD')) + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + + $history = @(Get-BudgetHistory -Budgets $budgets -MonthsBack 1 -CostTrend $script:Trend6) + + ($history | Where-Object BudgetName -EQ 'Upper').ActualSpend | Should -Be 40 + ($history | Where-Object BudgetName -EQ 'Lower').ActualSpend | Should -Be 60 + ($history | Where-Object BudgetName -EQ 'Same filter').ActualSpend | Should -Be 40 + ($history | Where-Object BudgetName -EQ 'Unfiltered').ActualSpend | Should -Be 10 + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 2 -Exactly + } + + It 'Leaves a unavailable without issuing an unfiltered query' -ForEach @( + @{ FilterCase = 'malformed tag comparison'; FilterJson = '{"tags":{"name":"CostCenter","operator":"In","values":"team"}}' } + @{ FilterCase = 'missing comparison values'; FilterJson = '{"tags":{"name":"CostCenter","operator":"In","values":[]}}' } + @{ FilterCase = 'empty AND'; FilterJson = '{"and":[]}' } + @{ FilterCase = 'empty AND child'; FilterJson = '{"and":[{},{}]}' } + @{ FilterCase = 'unsupported expression'; FilterJson = '{"or":[{"tags":{"name":"CostCenter","operator":"In","values":["team"]}},{"tags":{"name":"CostCenter","operator":"In","values":["other"]}}]}' } + @{ FilterCase = 'unknown filter field'; FilterJson = '{"dimensions":{"name":"ResourceGroupName","operator":"In","values":["analytics"]},"unknown":true}' } + ) { + $budget = $script:HistBudget[0] | Select-Object * + $budget.Filter = $FilterJson | ConvertFrom-Json + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw 'An unrecognized filter must not broaden the request.' } + + $history = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6) + + $history.Count | Should -Be 2 + foreach ($row in $history) { + $row.ActualSpend | Should -BeNullOrEmpty + $row.Status | Should -Be 'Unavailable' + $row.Note | Should -Match 'cannot apply this filter' + } + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + } + + It 'Does not replace a failed filtered query with cached subscription spend' { + $budget = $script:HistBudget[0] | Select-Object * + $budget.Filter = @{ dimensions = @{ name = 'ResourceGroupName'; operator = 'In'; values = @('analytics') } } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { [pscustomobject]@{ StatusCode = 403; Content = '{}' } } + + { Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6 } | Should -Throw '*403*incomplete*' + + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + ($Payload | ConvertFrom-Json).dataset.filter.dimensions.name -eq 'ResourceGroupName' } } It 'Does not compare subscription history with a budget' -ForEach @( - @{ Case = 'filtered'; Change = 'Filter' } + @{ Case = 'unsupported filter'; Change = 'Filter' } @{ Case = 'quarterly'; Change = 'Quarter' } @{ Case = 'usage'; Change = 'Usage' } @{ Case = 'missing amount'; Change = 'Amount' } @@ -189,7 +315,7 @@ Describe 'Budget coverage reporting' { ) { $budget = $script:HistBudget[0] | Select-Object * switch ($Change) { - 'Filter' { $budget.Filter = @{ tags = @{ name = 'CostCenter'; operator = 'In'; values = @('team') } } } + 'Filter' { $budget.Filter = @{ tags = @{ name = 'CostCenter'; operator = 'NotIn'; values = @('team') } } } 'Quarter' { $budget.TimeGrain = 'Quarterly' } 'Usage' { $budget.Category = 'Usage' } 'Amount' { $budget.Amount = $null } From 87e03fab01084eb606d1267fc31fbc2cb0521523 Mon Sep 17 00:00:00 2001 From: Zac Larsen Date: Sun, 20 Sep 2026 22:36:08 -0600 Subject: [PATCH 139/142] fix(multitool): handle incomplete scan results --- docs-mslearn/toolkit/changelog.md | 8 +- .../multitool/finops-multitool-overview.md | 14 +-- .../multitool/finops-multitool-commands.md | 18 ++- .../multitool/start-finopsmultitool.md | 20 +++- docs/multitool.md | 4 +- .../FinOpsMultitool/FinOpsMultitool.psm1 | 17 +-- .../Invoke-FinOpsMultitool.ps1 | 46 +++++--- .../Private/FinOpsMultitool/README.md | 55 +++++---- .../modules/Get-AHBOpportunities.ps1 | 12 +- .../modules/Get-AIWorkloadMetrics.ps1 | 25 +--- .../FinOpsMultitool/modules/Get-CostTrend.ps1 | 48 +++----- .../FinOpsMultitool/modules/Get-IdleVMs.ps1 | 47 ++++---- .../modules/Get-LegacyResources.ps1 | 20 ++-- .../modules/Get-OrphanedResources.ps1 | 20 ++-- .../modules/Get-PolicyInventory.ps1 | 66 ++++++++--- .../modules/Get-SavingsRealized.ps1 | 2 +- .../modules/Get-StorageTierAdvice.ps1 | 39 +++--- .../modules/Get-UnitEconomics.ps1 | 7 +- .../modules/helpers/Read-FinOpsHubData.ps1 | 38 ++++-- .../helpers/Resolve-CostDataSource.ps1 | 9 +- .../modules/helpers/Search-AzGraphSafe.ps1 | 22 ++-- .../Public/Start-FinOpsMultitool.ps1 | 10 +- .../Tests/Unit/AzGraphPagination.Tests.ps1 | 111 +++++++++++++++--- .../Tests/Unit/CostQueryPagination.Tests.ps1 | 44 +++++++ .../Tests/Unit/FOHubProvider.Tests.ps1 | 23 ++++ .../Tests/Unit/MultitoolSafety.Tests.ps1 | 55 +++++++++ .../Tests/Unit/ParquetPackageClient.Tests.ps1 | 34 ++++++ .../Tests/Unit/PolicyEffect.Tests.ps1 | 89 ++++++++++++++ .../Unit/Start-FinOpsMultitool.Tests.ps1 | 66 +++++++++++ 29 files changed, 721 insertions(+), 248 deletions(-) diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index d4553a83d..b0d611507 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: 09/16/2026 +ms.date: 09/20/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -57,11 +57,11 @@ The following section lists features and enhancements that are currently in deve ### [FinOps multitool](multitool/finops-multitool-overview.md) - **Added** - - 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. + - Added the FinOps multitool, which scans an Azure environment for cost optimization, governance, and FinOps insights through a PowerShell 7 terminal UI ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). + - Includes 30 read-only scan modules, with 26 available in the menu, 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, estimated savings, budget status and history, anomaly alerts, Advisor recommendations, billing structure, and contract info. - 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 non-interactive mode so the same scans run from a pipeline or a scheduled job. Consoles that can't render the arrow-key menus, such as PowerShell remoting sessions, fall back to numbered prompts. + - Added a non-interactive mode for an already-authenticated pipeline or scheduled job, and automatic private CSV, HTML, and text reports. Consoles that can't render the arrow-key menus, such as PowerShell remoting sessions, fall back to numbered prompts. ### [Power BI reports](power-bi/reports.md) diff --git a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md index bae6b577b..80b57576c 100644 --- a/docs-mslearn/toolkit/multitool/finops-multitool-overview.md +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -3,7 +3,7 @@ title: FinOps multitool overview 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: 09/16/2026 +ms.date: 09/20/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit @@ -17,9 +17,9 @@ FinOps multitool scans an Azure environment for cost optimization, governance, a ## How it works -FinOps multitool runs 30 scan modules against the subscriptions you select and renders the findings in one place: +FinOps multitool provides 30 scan modules, with 26 available in the terminal menu, and renders findings for the subscriptions you select: -- **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. Consoles that can't render the arrow-key menus fall back to numbered prompts, and a non-interactive mode runs the same scans from a pipeline or a scheduled job. +- **Interactive scanning**
Choose the subscriptions and scan modules you want, then review results in the terminal. Every completed run automatically saves CSV files, an HTML report, and a text summary to a private local folder. See [Report storage](../powershell/multitool/start-finopsmultitool.md#report-storage) for locations and privacy limits. Consoles that can't render the arrow-key menus fall back to numbered prompts. Non-interactive runs require an existing Azure sign-in context. - **AI agent support**
Agent skills describe the same investigations, the queries behind them, and how to read the results, so AI assistants can answer cost questions from your environment's data. @@ -31,10 +31,10 @@ FinOps multitool runs 30 scan modules against the subscriptions you select and r FinOps multitool provides the following benefits: -- Run 30 scans across optimization, governance, cost analysis, commitments, monitoring, and sustainability in a single pass. +- Choose from 26 menu scans across optimization, governance, cost analysis, commitments, monitoring, and sustainability, with four more modules for direct investigations. - Scope each scan to the subscriptions you select. -- Export findings to a CSV file per scan, an HTML report, and a text summary. -- Read cost data from a FinOps hub, the Cost Management API, or Azure Resource Graph. +- Get a CSV file per selected scan, an HTML report, and a text summary saved automatically to a private local folder. +- Read costs from a FinOps hub or the Cost Management API, with resource inventory from Azure Resource Graph. - Run the same scans from a pipeline or a scheduled job with `-NonInteractive`. - Run the same investigations from an AI assistant through agent skills. @@ -44,7 +44,7 @@ FinOps multitool provides the following benefits: ## 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. +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 agreement-specific billing access: [Billing account reader or Billing profile reader for a Microsoft Customer Agreement](/azure/cost-management-billing/manage/understand-mca-roles), or [Enterprise Administrator (read only) for an Enterprise Agreement](/azure/cost-management-billing/manage/understand-ea-roles), at the scope the scan reads. Commitment utilization reads reservation and savings plan usage at billing account or billing profile scope, so it needs the same access as account scans. Reader on a subscription isn't enough. Without it, the scan tells you it couldn't reach a billing scope instead of showing zero commitments. diff --git a/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md index 4755d8981..d546e3f4d 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: 09/18/2026 +ms.date: 09/20/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -17,18 +17,20 @@ The FinOps multitool PowerShell commands help you scan an Azure environment for The multitool provides one scan engine with 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. +- **Terminal UI (TUI)** – An interactive terminal experience launched with [Start-FinOpsMultitool](start-finopsmultitool.md). It surfaces 26 of the 30 scans. - **Agent skills** – A set of skills that describe which investigation answers a question, the queries behind it, and how to read the results. The terminal UI prompts for each choice by default. Consoles that can't render the arrow-key menus, such as PowerShell remoting sessions, fall back to numbered prompts. To run the tool from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters. +Automation requires an existing Azure context established with the intended identity. Without one, `-NonInteractive` fails before scanning. Validation for this change was performed on Windows; native macOS/Linux behavior and live `dotnet restore` remain unverified. + CSV, HTML, and text reports are saved automatically on the machine running the multitool, in a new private folder under the current user's local application data. Use `-OutputPath` to select a different local parent folder outside Git repositories. For location details and privacy limits, see [Report storage](start-finopsmultitool.md#report-storage).
## 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.
@@ -48,7 +50,11 @@ The multitool includes 30 scan modules across the following categories: Analysis scans are read-only. Most need Reader or Cost Management Reader access. Account scans also need agreement-specific billing access, such as Billing account reader or Billing profile reader for a Microsoft Customer Agreement, or Enterprise Administrator (read only) for an Enterprise Agreement. Commitment utilization reads reservation and savings plan usage at billing account or billing profile scope, so it needs that billing access. The carbon scan needs Reader or Carbon Optimization Reader assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. -When a scan can't read every subscription you selected, it tells you instead of treating the gap as a result. Budget status reports coverage as unverified rather than a percentage. +Complete Resource Graph reads fail when a page is unreadable, a continuation token repeats, or the page limit is reached. A full page without a continuation token is also unverified, even if the true result happens to equal the page size. Affected inventory scans don't report partial rows as a successful complete inventory. Cost trend also rejects missing currency fields or monthly totals that would mix currencies. + +For large subscription selections, budget status can sample subscriptions first. If the sample contains no budgets, it skips the remainder and reports coverage as unverified. Unreadable subscriptions aren't counted as having no budget. Policy inventory tracks assignment coverage separately from compliance coverage; incomplete compliance reads suppress the overall percentage. Storage tier advice leaves accounts with missing measurements unevaluated rather than treating absent samples as zero activity. + +Budget history supports monthly cost budgets with no filter, tag or dimension `In` filters, or `and` combinations. Empty filter objects mean no filter. Filtered budgets query matching costs rather than reusing whole-subscription totals, including when the primary source is a hub. Months outside the budget's full-month validity, unsupported filters, and incompatible currencies remain unavailable. Comparisons use the current budget amount and filter, not historical budget revisions. Current forecasts separately remain unavailable when Azure doesn't return a forecast amount and compatible currency. The scan keeps the **Savings Realized** menu name for compatibility. It estimates savings using assumed discounts. It doesn't measure realized savings or calculate a savings percentage. Results include `IsEstimate` and `EstimateBasis`. Compare the estimates with matching pay-as-you-go rates and benefit usage before reporting realized savings. @@ -61,12 +67,14 @@ Commitment estimates cover usage charges in the reported UTC month-to-date perio When a [FinOps hub](../../hubs/finops-hubs-overview.md) is present, cost scans read from the hub and choose the path automatically: - **Kusto database (used when available)** – When the hub has an Azure Data Explorer or Microsoft Fabric cluster, the multitool discovers it through Azure Resource Graph and pushes aggregation into the engine, returning only summarized results. This scales to large datasets without loading raw cost rows into PowerShell. To query a local hub on your own hardware, set the `FINOPS_HUB_KUSTO_URI` environment variable to a local Kusto endpoint (optionally set `FINOPS_HUB_KUSTO_DB`, which defaults to `Hub`). -- **Storage reader (small-dataset fallback)**: when no Kusto endpoint is configured or discovered, the multitool reads the hub's storage export and aggregates in PowerShell. Use this for smaller datasets. Reading Parquet exports installs a reader the first time you read one, using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. The tool reports an unreadable export as an error instead of treating it as zero cost. +- **Storage reader (small-dataset fallback)**: when no Kusto endpoint is configured or discovered, the multitool reads the hub's storage export and aggregates in PowerShell. Use this for smaller datasets. Reading Parquet exports prepares a pinned reader using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. If the reader can't be prepared, the tool warns with the reason and attempts `msexports` CSV instead. A failed export read remains an error, not zero cost. Storage reads require Storage Blob Data Reader or equivalent data access. Kusto queries require database query access. Both paths need network access to the endpoint. Local Kusto queries are anonymous, but the public launcher still uses Azure context and resource metadata. An explicit `-DataSource API` or `-DataSource GraphOnly` takes precedence over `FINOPS_HUB_KUSTO_URI` and doesn't preload hub data. A configured Kusto URI can select a hub without a discovered storage account. An explicit `-DataSource Hub` reports an error if no configured endpoint or hub storage is available. +GraphOnly excludes cost-dependent scans and orphan cost enrichment. Remaining scans can still use Azure Monitor, Advisor, policy, and carbon APIs. Dependencies can't re-enable an excluded cost scan. + When no hub is available, the tool offers the Cost Management API. Once you select **FinOps Hub**, the tool reports any read or query failure as an error. Select **Cost Management API** to run a separate live scan. Kusto-only hubs don't currently support the AI workload scan. When forecasts are available for current-month storage data, the tool shows them as separate full-month API totals. It doesn't add forecasts to hub actuals.
diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md index 8e386af3e..15647eb5a 100644 --- a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -3,7 +3,7 @@ title: Start-FinOpsMultitool command 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: 09/18/2026 +ms.date: 09/20/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -19,10 +19,14 @@ The **Start-FinOpsMultitool** command launches the FinOps multitool interactive Results appear in the terminal and are saved automatically on the machine running the command. Each run creates a private folder with one CSV file per selected scan, a `FinOpsReport.html` summary, and a `ScanSummary.txt` file. Failed or empty scans have a CSV status record. The scans don't change Azure resources. -The command requires PowerShell 7 or later on Windows, macOS, and Linux. 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. Commitment utilization reads at billing account or billing profile scope, so it needs that same billing access. The carbon scan needs Reader or Carbon Optimization Reader assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. +The command requires PowerShell 7 or later and the `Az.Accounts`, `Az.ResourceGraph`, and `Az.Storage` modules. Validation for this change was performed on Windows; native macOS/Linux behavior and the `dotnet restore` path haven't been exercised. + +Most scans need Reader or Cost Management Reader access on the target scope. Account scans (billing structure, contract info, and MACC commitment) also need agreement-specific billing access: [Billing account reader or Billing profile reader for a Microsoft Customer Agreement](/azure/cost-management-billing/manage/understand-mca-roles), or [Enterprise Administrator (read only) for an Enterprise Agreement](/azure/cost-management-billing/manage/understand-ea-roles). Grant access at the scope the scan reads. Commitment utilization reads at billing account or billing profile scope, so it needs that same billing access. The carbon scan needs Reader or Carbon Optimization Reader assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. The tool prompts for each choice by default. To run it from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters. +`-NonInteractive` requires an existing Azure context. Authenticate with the intended identity using `Connect-AzAccount` before launching the scan. Without a context, the command fails before scanning instead of starting interactive sign-in. +
## Syntax @@ -45,9 +49,9 @@ Start-FinOpsMultitool ` | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `‑SubscriptionId` | Optional. Scopes the scan to a single subscription. When omitted, all accessible subscriptions are discovered. If the subscription can't be resolved and nothing can answer a prompt, the command returns an error rather than scanning every subscription. | | `‑OutputPath` | Optional. Local parent folder for reports. Defaults to `FinOpsToolkit/Multitool/Reports` under the current user's local application data. Each run creates a new timestamped subfolder. Git repositories, UNC paths, mapped Windows network drives, symbolic links, and junctions aren't accepted. Unix network mounts aren't detected. | -| `‑Scans` | Optional. Runs the specified scans instead of the default selection. Accepts a scan command name, such as `Get-OrphanedResources`, or its menu label, such as `Orphaned Resources`. Use `All` to select every scan. An unrecognized name returns an error. | +| `‑Scans` | Optional. Runs the specified scans instead of the default selection. Accepts a scan command name, such as `Get-OrphanedResources`, or its menu label, such as `Orphaned Resources`. Use `All` on its own to select every menu scan, including Billing Structure. An unrecognized name returns an error. | | `‑DataSource` | Optional. Sets the data source and skips the data source prompt. Valid values are `Hub`, `API`, and `GraphOnly`. `API` and `GraphOnly` take precedence over `FINOPS_HUB_KUSTO_URI` and don't preload hub data. An explicit `Hub` selection fails if no configured Kusto endpoint or hub storage is available. Select `API` separately for a live scan. | -| `‑NonInteractive` | Optional. Runs without prompting. Every choice comes from the parameters or their defaults. Reports are saved automatically, even when `-OutputPath` is omitted. | +| `‑NonInteractive` | Optional. Runs without prompting and requires an existing authenticated Azure context. Every choice comes from the parameters or their defaults. Reports are saved automatically, even when `-OutputPath` is omitted. |
@@ -81,6 +85,8 @@ Launches the terminal UI and saves reports in a new run subfolder under the spec ### Run specific scans without prompting +Authenticate with the intended identity first. Then run: + ```powershell Start-FinOpsMultitool ` -NonInteractive ` @@ -109,13 +115,17 @@ The tool uses arrow-key menus when the console supports them. Consoles that can' Use `-NonInteractive` when nothing can answer a prompt, such as a build agent. +## Resource Graph only + +`-DataSource GraphOnly` removes scans that require cost data, including budget history, AI workload metrics, unit economics, and MACC. Dependencies can't re-enable those scans, and orphan cost enrichment is skipped. The remaining scans can still call Azure Monitor metrics, Advisor, policy, and carbon APIs; the option doesn't restrict every request to Azure Resource Graph. +
## FinOps hub data paths When you select [FinOps Hub](../../hubs/finops-hubs-overview.md), the tool prefers the configured or discovered Kusto database. Kusto aggregates the data and returns summaries without loading raw cost records into PowerShell. To query a local hub, set `FINOPS_HUB_KUSTO_URI` to its endpoint. A configured endpoint doesn't require a discovered storage account. When no Kusto endpoint is configured or discovered, the tool reads hub storage exports, which is intended for smaller datasets. A failed query remains an error; it doesn't silently switch sources. For more information, see [FinOps multitool commands](finops-multitool-commands.md). -Reading Parquet exports installs a reader the first time you read one, using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. An unreadable export is reported as an error instead of being treated as zero cost. +Reading Parquet exports prepares a pinned reader using NuGet on Windows and .NET SDK 8 or later on macOS and Linux. Package signatures and hashes are checked before loading cached assemblies. An unavailable verifier leaves the cache unloaded but intact. If the reader can't be prepared, the tool warns you with the reason and attempts the hub's `msexports` CSV instead of normalized Parquet data. A failed export read remains an error, not zero cost.
diff --git a/docs/multitool.md b/docs/multitool.md index 9c8258bc3..9eeb36baf 100644 --- a/docs/multitool.md +++ b/docs/multitool.md @@ -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, plus agent skills for AI assistants, with a scalable FinOps hub Kusto data path for large environments. + The FinOps multitool is a new addition to the FinOps toolkit. It provides 30 read-only scan modules, with 26 in the terminal menu, plus agent skills for AI assistants and a FinOps hub Kusto data path for large environments.

See all changes

@@ -34,7 +34,7 @@ The FinOps multitool scans an Azure environment for cost optimization, governanc