diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..8e29de3de --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,12 @@ +{ + "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/TOC.yml b/docs-mslearn/TOC.yml index 4d48e0aff..6e968adfc 100644 --- a/docs-mslearn/TOC.yml +++ b/docs-mslearn/TOC.yml @@ -238,6 +238,12 @@ href: toolkit/powershell/cost/remove-finopscostexport.md - name: Start-FinOpsCostExport href: toolkit/powershell/cost/start-finopscostexport.md + - name: FinOps Multitool + items: + - name: FinOps Multitool commands + href: toolkit/powershell/multitool/finops-multitool-commands.md + - name: Start-FinOpsMultitool + href: toolkit/powershell/multitool/start-finopsmultitool.md - name: FinOps hubs items: - name: FinOps hubs commands diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 2328ea2c5..fac028665 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -3,7 +3,7 @@ title: FinOps toolkit changelog description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more. author: MSBrett ms.author: brettwil -ms.date: 05/12/2026 +ms.date: 07/02/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -32,6 +32,15 @@ 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 + +- **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)). + - 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. + - 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 - Cost Management export modules for subscriptions and resource groups. diff --git a/docs-mslearn/toolkit/finops-toolkit-overview.md b/docs-mslearn/toolkit/finops-toolkit-overview.md index 3ac530622..475eadba8 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: 04/01/2026 +ms.date: 07/02/2026 ms.topic: concept-article ms.service: finops ms.subservice: finops-toolkit @@ -33,6 +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. - [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/powershell/multitool/finops-multitool-commands.md b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md new file mode 100644 index 000000000..360c08534 --- /dev/null +++ b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md @@ -0,0 +1,91 @@ +--- +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.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. +--- + +# 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 Multitool delivers the same 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. + +
+ +## Commands + +- [Start-FinOpsMultitool](start-finopsmultitool.md) – Launch the interactive FinOps Multitool terminal UI. + +
+ +## Scan coverage + +The Multitool includes 30 scan modules across the following categories: + +- **Optimization** – Orphaned resources, idle VMs, storage tier advice, and Azure Hybrid Benefit opportunities. +- **Governance** – Tag inventory and recommendations, and policy inventory and recommendations. +- **Cost analysis** – Cost data, cost trend, cost by tag, and top resources by cost. +- **Commitments** – Reservation advice, commitment utilization, and realized savings. +- **Monitoring** – Budget status and anomaly alerts. +- **Advisor** – Azure Advisor cost recommendations. +- **Account** – Billing structure, contract info, and tenant hierarchy. + +Analysis scans are read-only and use Reader or Cost Management Reader access. + +
+ +## 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: + +- **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. + +
+ +## MCP server for AI agents + +`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 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. + +
+ +## 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%20the%20FinOps%20toolkit%20PowerShell%20module%3F/cvaQuestion/How%20valuable%20are%20the%20FinOps%20toolkit%20PowerShell%20module%3F/surveyId/FTK/bladeName/PowerShell/featureName/Multitool) + + +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%3A"reactions-%2B1-desc") + + +
+ +## Related content + +Related solutions: + +- [FinOps toolkit PowerShell module](../powershell-commands.md) +- [FinOps hubs](../../hubs/finops-hubs-overview.md) + +
diff --git a/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md new file mode 100644 index 000000000..1f417428a --- /dev/null +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -0,0 +1,88 @@ +--- +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.topic: reference +ms.service: finops +ms.subservice: finops-toolkit +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—covering cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance. + +Results are rendered in the terminal with export options for Excel, CSV, JSON, and Power BI. The scan modules are read-only. + +The 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. + +
+ +## Syntax + +```powershell +Start-FinOpsMultitool ` + [-SubscriptionId ] ` + [-OutputPath ] ` + [] +``` + +
+ +## 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. | + +
+ +## Examples + +The following examples demonstrate how to use the Start-FinOpsMultitool command. + +### Launch the Multitool + +```powershell +Start-FinOpsMultitool +``` + +Launches the terminal UI. You're prompted to authenticate, select a tenant if needed, and choose the subscriptions and modules to scan. + +### Scope to a single subscription + +```powershell +Start-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' +``` + +Launches the terminal UI scoped to a single subscription. + +### Set an output path for exports + +```powershell +Start-FinOpsMultitool -OutputPath './finops-results' +``` + +Launches the terminal UI and writes exported result files to the specified directory. + +
+ +## 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). + +
+ +## Related content + +Related solutions: + +- [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 d80414150..65e74f50b 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: 04/01/2026 +ms.date: 07/02/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -19,7 +19,7 @@ The FinOps toolkit PowerShell module is a collection of commands to automate and ## Install the module -The FinOps toolkit module requires PowerShell 7, which is built into [Azure Cloud Shell](https://portal.azure.com/#cloudshell) and supported on all major operating systems. +The FinOps toolkit module requires PowerShell 7, which is built into [Azure Cloud Shell](https://portal.azure.com/#cloudshell) and supported on all major operating systems. Azure Cloud Shell comes with PowerShell 7 and Azure PowerShell preinstalled. If you aren't using Azure Cloud Shell, you need to [Install PowerShell](/powershell/scripting/install/installing-powershell) first and then run the following commands to install Azure PowerShell: @@ -59,6 +59,10 @@ 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 + +- [Start-FinOpsMultitool](multitool/start-finopsmultitool.md) – Launch the interactive FinOps Multitool terminal UI to scan for cost, governance, and optimization insights. + ### FinOps hubs commands - [Deploy-FinOpsHub](hubs/Deploy-FinOpsHub.md) – Deploy your first hub or update to the latest version. diff --git a/docs/README.md b/docs/README.md index 14fc3db77..ced23f9b7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -70,6 +70,11 @@ Automate and extend the Microsoft Cloud with starter kits, scripts, and advanced
Automate and manage FinOps solutions and capabilities.
Learn more +
+
🛠️ FinOps Multitool
+
Scan your environment for cost, governance, and optimization insights.
+ Learn more +
🦾 Bicep Registry
Official repository for Bicep modules.
diff --git a/docs/multitool.md b/docs/multitool.md new file mode 100644 index 000000000..c5f59cce8 --- /dev/null +++ b/docs/multitool.md @@ -0,0 +1,104 @@ +--- +layout: default +title: FinOps Multitool +browser: FinOps Multitool - Scan your Azure environment for FinOps insights +nav_order: 52 +description: 'The FinOps Multitool scans an Azure environment for cost optimization, governance, and FinOps insights from an interactive terminal UI or an MCP server for AI agents.' +permalink: /multitool +#customer intent: As a FinOps practitioner, I need to learn about the FinOps Multitool +--- + +FinOps Multitool +Scan your Azure environment for cost optimization, governance, and FinOps insights from an interactive terminal UI or an MCP server for AI agents. +{: .fs-6 .fw-300 } + +Install +Documentation + +--- + +The FinOps Multitool scans an Azure environment for cost optimization, governance, and FinOps insights and grounds its findings in your live resource state. It surfaces cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance—from an interactive terminal UI or as tools an AI agent can call. + +
+

New in the FinOps toolkitv15

+

+ The FinOps Multitool is a new addition to the FinOps toolkit. It delivers 30 read-only scan modules through a cross-platform terminal UI and an MCP server for AI agents, with a scalable FinOps Hub Kusto data path for large environments. +

+

See all changes

+
+ + + +## Explore the Multitool + + + + + + + +## Install the module + +
+
+ +
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 + +
diff --git a/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 new file mode 100644 index 000000000..da1e6395e --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# FINOPSMULTITOOL.PSM1 +# MODULE LOADER +########################################################################### +# Purpose: Dot-sources all helpers and analysis modules so they can be +# imported via Import-Module and used by the TUI or MCP server. +# +# Usage: +# Import-Module .\FinOpsMultitool.psm1 +# $results = Get-OrphanedResources -Subscriptions $subs -TenantId $tid +########################################################################### + +# -- Ensure required Az modules are loaded --------------------------------- +# Some terminals have incomplete PSModulePath — add the edition-appropriate user +# module path. In PowerShell 7 (Core) we must NOT prepend the Windows PowerShell +# 5.1 module path: it can shadow Core's modules with older, incompatible versions +# (for example an old Az.Accounts that then blocks a newer Az.Storage from loading). +$userModDir = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'PowerShell\Modules' +if ($userModDir -and (Test-Path $userModDir) -and $env:PSModulePath -notlike "*$userModDir*") { + $env:PSModulePath = "$userModDir;$env:PSModulePath" +} +if ($PSEdition -eq 'Desktop') { + $userModDir5 = Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'WindowsPowerShell\Modules' + if ($userModDir5 -and (Test-Path $userModDir5) -and $env:PSModulePath -notlike "*$userModDir5*") { + $env:PSModulePath = "$userModDir5;$env:PSModulePath" + } +} + +foreach ($azMod in @('Az.Accounts', 'Az.Storage', 'Az.ResourceGraph')) { + if (-not (Get-Module $azMod)) { + Import-Module $azMod -ErrorAction SilentlyContinue + } +} +# -- Helpers (runspace pool, REST retry, ARG wrapper, MG-scope state) ---- +$helpersPath = Join-Path $PSScriptRoot 'modules\helpers' +. (Join-Path $helpersPath 'Get-PlainAccessToken.ps1') +. (Join-Path $helpersPath 'Invoke-AzRestMethodWithRetry.ps1') +. (Join-Path $helpersPath 'Search-AzGraphSafe.ps1') +. (Join-Path $helpersPath 'Confirm-WriteAction.ps1') +. (Join-Path $helpersPath 'MgCostScope.ps1') +. (Join-Path $helpersPath 'Read-FinOpsHubData.ps1') +. (Join-Path $helpersPath 'Invoke-FOHubKustoQuery.ps1') +. (Join-Path $helpersPath 'Get-FOHubProvider.ps1') +. (Join-Path $helpersPath 'Get-CostExport.ps1') +. (Join-Path $helpersPath 'Resolve-CostDataSource.ps1') +. (Join-Path $helpersPath 'Get-KpiInsights.ps1') + +# -- Set script-scope root (some modules reference $script:ScriptRootDir) - +$script:ScriptRootDir = $PSScriptRoot + +# -- Analysis Modules ---------------------------------------------------- +$modulePath = Join-Path $PSScriptRoot 'modules' +. (Join-Path $modulePath 'Initialize-Scanner.ps1') +. (Join-Path $modulePath 'Get-TenantHierarchy.ps1') +. (Join-Path $modulePath 'Get-ContractInfo.ps1') +. (Join-Path $modulePath 'Get-CostData.ps1') +. (Join-Path $modulePath 'Get-ResourceCosts.ps1') +. (Join-Path $modulePath 'Get-TagInventory.ps1') +. (Join-Path $modulePath 'Get-CostByTag.ps1') +. (Join-Path $modulePath 'Get-AhbVmSavingsRatio.ps1') +. (Join-Path $modulePath 'Get-AHBOpportunities.ps1') +. (Join-Path $modulePath 'Get-ReservationAdvice.ps1') +. (Join-Path $modulePath 'Get-OptimizationAdvice.ps1') +. (Join-Path $modulePath 'Get-TagRecommendations.ps1') +. (Join-Path $modulePath 'Get-CostTrend.ps1') +. (Join-Path $modulePath 'Deploy-ResourceTag.ps1') +. (Join-Path $modulePath 'Get-BillingStructure.ps1') +. (Join-Path $modulePath 'Get-CommitmentUtilization.ps1') +. (Join-Path $modulePath 'Get-OrphanedResources.ps1') +. (Join-Path $modulePath 'Remove-OrphanedResource.ps1') +. (Join-Path $modulePath 'Enable-HybridBenefit.ps1') +. (Join-Path $modulePath 'Stop-IdleVm.ps1') +. (Join-Path $modulePath 'Get-BudgetStatus.ps1') +. (Join-Path $modulePath 'Get-MaccCommitment.ps1') +. (Join-Path $modulePath 'Get-AnomalyAlerts.ps1') +. (Join-Path $modulePath 'Get-SavingsRealized.ps1') +. (Join-Path $modulePath 'Get-PolicyInventory.ps1') +. (Join-Path $modulePath 'Get-PolicyRecommendations.ps1') +. (Join-Path $modulePath 'Deploy-PolicyAssignment.ps1') +. (Join-Path $modulePath 'Get-StorageTierAdvice.ps1') +. (Join-Path $modulePath 'Get-IdleVMs.ps1') +. (Join-Path $modulePath 'Get-LegacyResources.ps1') +. (Join-Path $modulePath 'Get-UnitEconomics.ps1') +. (Join-Path $modulePath 'Get-VmCostBreakdown.ps1') +. (Join-Path $modulePath 'Get-SharedCostAllocation.ps1') +. (Join-Path $modulePath 'Set-CostAllocationRule.ps1') +. (Join-Path $modulePath 'Get-BillingAccount.ps1') +. (Join-Path $modulePath 'Get-UsageProportionalAllocation.ps1') +. (Join-Path $modulePath 'Get-AIWorkloadMetrics.ps1') +. (Join-Path $modulePath 'Get-CarbonMetrics.ps1') +. (Join-Path $modulePath 'New-PowerBITemplate.ps1') diff --git a/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 new file mode 100644 index 000000000..6466104ca --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -0,0 +1,2441 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# INVOKE-FINOPSMULTITOOL.PS1 +# INTERACTIVE TERMINAL LAUNCHER FOR FINOPS MULTITOOL +########################################################################### +# Purpose: Provides an arrow-key driven TUI for selecting and running +# FinOps Multitool scan modules without a GUI dependency. +# +# Usage: Invoke-FinOpsMultitool +# Invoke-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' +# Invoke-FinOpsMultitool -OutputPath './results' +# +# Requirements: +# - PowerShell 5.1+ (Windows) or 7+ (cross-platform) +# - Az PowerShell modules: Az.Accounts, Az.Resources, Az.ResourceGraph +# - Azure RBAC: Reader + Cost Management Reader on target scope +########################################################################### + +function Invoke-FinOpsMultitool { + [CmdletBinding()] + param( + [string]$SubscriptionId, + [string]$OutputPath + ) + + # -- Load modules (always force-reimport to pick up latest changes) ---- + $multitoolRoot = $PSScriptRoot + $psm1Path = Join-Path $multitoolRoot 'FinOpsMultitool.psm1' + if (Test-Path $psm1Path) { + Import-Module $psm1Path -Force + } + else { + Write-Error "FinOpsMultitool.psm1 not found at $psm1Path" + return + } + + # -- Pre-flight: verify required Az modules ---------------------------- + $requiredModules = @( + @{ Name = 'Az.Accounts'; Reason = 'Azure authentication' } + @{ Name = 'Az.ResourceGraph'; Reason = 'Resource Graph queries (optimization, governance scans)' } + @{ Name = 'Az.Storage'; Reason = 'FinOps Hub data access (reading cost exports)' } + ) + $missing = @() + foreach ($req in $requiredModules) { + if (-not (Get-Module $req.Name -ErrorAction SilentlyContinue) -and + -not (Get-Module $req.Name -ListAvailable -ErrorAction SilentlyContinue)) { + $missing += $req + } + } + if ($missing.Count -gt 0) { + Write-Host "" + Write-Host " MISSING REQUIRED MODULES" -ForegroundColor Red + Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray + foreach ($m in $missing) { + Write-Host " $($m.Name)" -ForegroundColor Red -NoNewline + Write-Host " — $($m.Reason)" -ForegroundColor DarkGray + } + Write-Host "" + Write-Host " Install with:" -ForegroundColor White + $names = ($missing.Name | ForEach-Object { "'$_'" }) -join ', ' + Write-Host " Install-Module $names -Scope CurrentUser" -ForegroundColor Yellow + Write-Host "" + return + } + + # -- Scan Module Registry ---------------------------------------------- + $scanModules = @( + # -- Optimization (Resource Graph) -- + @{ Name = 'Orphaned Resources'; Fn = 'Get-OrphanedResources'; Selected = $true; Category = 'Optimization' } + @{ Name = 'Idle VMs'; Fn = 'Get-IdleVMs'; Selected = $true; Category = 'Optimization' } + @{ Name = 'Storage Tier Advice'; Fn = 'Get-StorageTierAdvice'; Selected = $true; Category = 'Optimization' } + @{ Name = 'Legacy Resources'; Fn = 'Get-LegacyResources'; Selected = $true; Category = 'Optimization' } + @{ Name = 'AHB Opportunities'; Fn = 'Get-AHBOpportunities'; Selected = $true; Category = 'Optimization' } + # -- Governance (run early — other modules depend on these) -- + @{ Name = 'Tag Inventory'; Fn = 'Get-TagInventory'; Selected = $true; Category = 'Governance' } + @{ Name = 'Tag Recommendations'; Fn = 'Get-TagRecommendations'; Selected = $true; Category = 'Governance' } + @{ Name = 'Policy Inventory'; Fn = 'Get-PolicyInventory'; Selected = $true; Category = 'Governance' } + @{ Name = 'Policy Recommendations'; Fn = 'Get-PolicyRecommendations'; Selected = $true; Category = 'Governance' } + # -- Cost Analysis (depends on Tag Inventory for Cost by Tag) -- + @{ Name = 'Cost Data'; Fn = 'Get-CostData'; Selected = $true; Category = 'Cost Analysis' } + @{ Name = 'Resource Costs'; Fn = 'Get-ResourceCosts'; Selected = $true; Category = 'Cost Analysis' } + @{ Name = 'Cost by Tag'; Fn = 'Get-CostByTag'; Selected = $true; Category = 'Cost Analysis' } + @{ Name = 'Cost Trend'; Fn = 'Get-CostTrend'; Selected = $true; Category = 'Cost Analysis' } + @{ Name = 'Unit Economics'; Fn = 'Get-UnitEconomics'; Selected = $true; Category = 'Cost Analysis' } + # -- AI & ML (self-gating — only runs the deep scan when AI is present) -- + @{ Name = 'AI Workload Metrics'; Fn = 'Get-AIWorkloadMetrics'; Selected = $true; Category = 'AI & ML' } + # -- Commitments -- + @{ Name = 'Reservation Advice'; Fn = 'Get-ReservationAdvice'; Selected = $true; Category = 'Commitments' } + @{ Name = 'Commitment Utilization'; Fn = 'Get-CommitmentUtilization'; Selected = $true; Category = 'Commitments' } + @{ Name = 'Savings Realized'; Fn = 'Get-SavingsRealized'; Selected = $true; Category = 'Commitments' } + # -- Monitoring -- + @{ Name = 'Budget Status'; Fn = 'Get-BudgetStatus'; Selected = $true; Category = 'Monitoring' } + @{ Name = 'Budget History'; Fn = 'Get-BudgetHistory'; Selected = $true; Category = 'Monitoring' } + @{ Name = 'Anomaly Alerts'; Fn = 'Get-AnomalyAlerts'; Selected = $true; Category = 'Monitoring' } + # -- Advisor -- + @{ Name = 'Optimization Advice'; Fn = 'Get-OptimizationAdvice'; Selected = $true; Category = 'Advisor' } + # -- Sustainability -- + @{ Name = 'Carbon Emissions'; Fn = 'Get-CarbonMetrics'; Selected = $true; Category = 'Sustainability' } + # -- Account -- + @{ Name = 'Billing Structure'; Fn = 'Get-BillingStructure'; Selected = $false; Category = 'Account' } + @{ Name = 'Contract Info'; Fn = 'Get-ContractInfo'; Selected = $true; Category = 'Account' } + @{ Name = 'MACC Commitment'; Fn = 'Get-MaccCommitment'; Selected = $true; Category = 'Account' } + ) + + # -- Permission Requirements per Module -------------------------------- + # Maps each function to the Azure RBAC role(s) needed and a human-readable reason + $permissionInfo = @{ + 'Get-OrphanedResources' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to query resource metadata via Azure Resource Graph.' } + 'Get-IdleVMs' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph + Monitor Metrics'; Reason = 'Requires Reader to query VM metadata and Monitor metrics for CPU/network utilization.' } + 'Get-StorageTierAdvice' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to query storage account configurations.' } + 'Get-LegacyResources' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires Reader to query VM/disk/network SKUs for legacy and retiring resources.' } + 'Get-AHBOpportunities' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to query VM license types.' } + 'Get-TagInventory' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to inventory resource tags via Resource Graph.' } + 'Get-TagRecommendations' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Graph'; Reason = 'Requires read access to analyze existing tags and suggest improvements.' } + 'Get-PolicyInventory' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Manager'; Reason = 'Requires read access to list policy assignments and definitions.' } + 'Get-PolicyRecommendations' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Resource Manager'; Reason = 'Requires read access to evaluate policy coverage gaps.' } + 'Get-CostData' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action. Assign Cost Management Reader or Reader at the subscription or MG scope.' } + 'Get-ResourceCosts' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action. Assign Cost Management Reader or Reader at the subscription or MG scope.' } + 'Get-CostByTag' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to query cost grouped by tag dimensions.' } + 'Get-CostTrend' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to retrieve historical monthly cost data.' } + 'Get-UnitEconomics' = @{ Role = 'Cost Management Reader + Reader'; Scope = 'Management Group'; API = 'Cost Management Query API + Azure Resource Graph + Azure Monitor metrics'; Reason = 'Requires amortized cost (Cost Management), capacity counts (Resource Graph), and storage-account used capacity (Monitor UsedCapacity metric) to compute $/vCPU, $/GB RAM and $/GB stored.' } + 'Get-AIWorkloadMetrics' = @{ Role = 'Cost Management Reader + Reader'; Scope = 'Management Group'; API = 'Azure Resource Graph + Monitor Metrics + Cost Management Query API'; Reason = 'Requires Reader to detect AI resources and read Azure OpenAI token metrics, plus Cost Management Reader to map token usage to spend. Skips the deep scan when no AI workloads are present.' } + 'Get-ReservationAdvice' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Reservation Recommendations API'; Reason = 'Requires Microsoft.Consumption/reservationRecommendations/read to retrieve reservation purchase advice.' } + 'Get-CommitmentUtilization' = @{ Role = 'Cost Management Reader or Reservation Reader'; Scope = 'Reservation Order or Subscription'; API = 'Consumption Reservation Summaries API'; Reason = 'Requires Microsoft.Consumption/reservationSummaries/read. If no reservations exist, this will be empty.' } + 'Get-SavingsRealized' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Benefit Utilization API'; Reason = 'Requires Microsoft.CostManagement/benefitUtilizationSummaries/read. Returns empty if no active reservations or savings plans.' } + 'Get-BudgetStatus' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Budgets API'; Reason = 'Requires Microsoft.Consumption/budgets/read. Returns empty if no budgets are configured for scanned subscriptions.' } + 'Get-BudgetHistory' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to retrieve monthly actuals per budget. Runs only when Budget Status returns budgets.' } + 'Get-AnomalyAlerts' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Alerts API'; Reason = 'Requires Microsoft.CostManagement/alerts/read. Returns empty if no cost anomalies were detected.' } + 'Get-OptimizationAdvice' = @{ Role = 'Reader'; Scope = 'Subscription'; API = 'Azure Advisor API'; Reason = 'Requires Microsoft.Advisor/recommendations/read to retrieve cost optimization recommendations.' } + 'Get-CarbonMetrics' = @{ Role = 'Reader or Carbon Optimization Reader'; Scope = 'Subscription'; API = 'Carbon Optimization API'; Reason = 'Requires Microsoft.Carbon read access to query emissions. Emissions publish ~2 months in arrears; returns empty if no published months.' } + 'Get-BillingStructure' = @{ Role = 'Billing Reader or EA Reader'; Scope = 'Billing Account'; API = 'Billing API'; Reason = 'Requires Microsoft.Billing/*/read. This is a billing-scope role, not a subscription role. Contact your billing admin.' } + 'Get-ContractInfo' = @{ Role = 'Billing Reader'; Scope = 'Billing Account'; API = 'Billing API'; Reason = 'Requires Microsoft.Billing/billingProperty/read. May require billing account access beyond subscription Reader.' } + 'Get-MaccCommitment' = @{ Role = 'Billing Reader or EA Reader'; Scope = 'Billing Account'; API = 'Consumption Lots API'; Reason = 'Requires a billing role on an EA/MCA billing account to read consumption commitment (MACC) lots. Not applicable to PAYGO/CSP/MSDN.' } + } + + # ===================================================================== + # BANNER + # ===================================================================== + function Show-Banner { + Clear-Host + $banner = @" + + ╔════════════════════════════════════════════════════════════════════════╗ + ║ ║ + ║ ███████╗██╗███╗ ██╗ ██████╗ ██████╗ ███████╗ ║ + ║ ██╔════╝██║████╗ ██║██╔═══██╗██╔══██╗██╔════╝ ║ + ║ █████╗ ██║██╔██╗ ██║██║ ██║██████╔╝███████╗ ║ + ║ ██╔══╝ ██║██║╚██╗██║██║ ██║██╔═══╝ ╚════██║ ║ + ║ ██║ ██║██║ ╚████║╚██████╔╝██║ ███████║ ║ + ║ ╚═╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝ ╚══════╝ ║ + ║ ║ + ║ ███╗ ███╗██╗ ██╗██╗ ████████╗██╗████████╗ ██████╗ ██████╗ ██╗ ║ + ║ ████╗ ████║██║ ██║██║ ╚══██╔══╝██║╚══██╔══╝██╔═══██╗██╔═══██╗██║ ║ + ║ ██╔████╔██║██║ ██║██║ ██║ ██║ ██║ ██║ ██║██║ ██║██║ ║ + ║ ██║╚██╔╝██║██║ ██║██║ ██║ ██║ ██║ ██║ ██║██║ ██║██║ ║ + ║ ██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║ ██║ ╚██████╔╝╚██████╔╝███████╗ + ║ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝ + ║ ║ + ║ Azure FinOps Scanner & Optimizer v2.3.0 ║ + ║ ║ + ╚════════════════════════════════════════════════════════════════════════╝ + +"@ + Write-Host $banner -ForegroundColor Cyan + } + + # ===================================================================== + # DATA SOURCE PICKER + # ===================================================================== + function Select-DataSource { + param( + [string]$TenantId, + [array]$Subscriptions + ) + + Write-Host "" + Write-Host " DATA SOURCE" -ForegroundColor Cyan + Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray + Write-Host "" + + # Try to detect a FinOps Hub in the selected subscriptions + $hubStorage = $null + Write-Host " Checking for FinOps Hub deployment..." -ForegroundColor DarkGray + foreach ($sub in $Subscriptions) { + try { + $query = "resources | where type == 'microsoft.storage/storageaccounts' and tags['cm-resource-parent'] contains 'Microsoft.Cloud/hubs' | project name, resourceGroup, subscriptionId, location" + $result = Search-AzGraph -Query $query -Subscription $sub.Id -ErrorAction SilentlyContinue + if ($result -and @($result).Count -gt 0) { + $hubStorage = $result[0] + break + } + } + catch { } + } + + if ($hubStorage) { + Write-Host " FinOps Hub detected: " -ForegroundColor Green -NoNewline + Write-Host "$($hubStorage.name)" -ForegroundColor White -NoNewline + Write-Host " ($($hubStorage.resourceGroup))" -ForegroundColor DarkGray + Write-Host "" + Write-Host " [1] FinOps Hub" -ForegroundColor Green -NoNewline + Write-Host " - Pre-processed data from your Hub's ingestion pipeline" -ForegroundColor DarkGray + Write-Host " Faster, consistent, includes normalized/amortized costs" -ForegroundColor DarkGray + Write-Host "" + Write-Host " [2] Cost Management API" -ForegroundColor Yellow -NoNewline + Write-Host " - Query Azure Cost Management REST APIs directly" -ForegroundColor DarkGray + Write-Host " Real-time, no Hub required, subject to API throttling" -ForegroundColor DarkGray + Write-Host "" + Write-Host " [3] Resource Graph only" -ForegroundColor DarkGray -NoNewline + Write-Host " - Skip cost modules, run governance/optimization scans only" -ForegroundColor DarkGray + Write-Host "" + + while ($true) { + Write-Host " Select [1/2/3]: " -ForegroundColor White -NoNewline + $choice = Read-Host + switch ($choice.Trim()) { + '1' { + # Large-hub heads-up: the [1] Hub choice uses the scalable + # Kusto engine when the hub has an ADX/Fabric cluster (auto- + # discovered) or FINOPS_HUB_KUSTO_URI is set (ftklocal). If + # neither exists, cost scans fall back to the STORAGE READER, + # which loads cost rows into PowerShell - slow / memory-heavy + # on large hubs. Warn and let the user switch to the live API. + $hubSubIds = @($Subscriptions | ForEach-Object { $_.Id }) + $prov = $null + try { $prov = Resolve-FOHubProvider -Subscriptions $hubSubIds } catch { } + if ($prov -and $prov.Found) { + # A scalable Kusto path exists - no warning needed. + return @{ Source = 'Hub'; HubStorage = $hubStorage } + } + Write-Host "" + Write-Host " Note: this FinOps Hub has no Azure Data Explorer (Kusto) cluster." -ForegroundColor Yellow + Write-Host " Cost scans will use the storage reader, which loads cost rows into" -ForegroundColor DarkGray + Write-Host " memory. On a large hub (tens of GB) this can be slow or run out of" -ForegroundColor DarkGray + Write-Host " memory before completing." -ForegroundColor DarkGray + Write-Host " For the scalable engine path: deploy ADX/Fabric on the hub, or set" -ForegroundColor DarkGray + Write-Host " FINOPS_HUB_KUSTO_URI to a local ftklocal emulator, then re-run." -ForegroundColor DarkGray + Write-Host "" + Write-Host " Switch to the live Cost Management API instead? " -ForegroundColor White -NoNewline + Write-Host "(N = continue with the storage reader)" -ForegroundColor DarkGray + $useApi = Read-Host " Select [Y/N]" + if ($useApi.Trim() -match '^(y|yes)$') { + return @{ Source = 'API'; HubStorage = $hubStorage } + } + return @{ Source = 'Hub'; HubStorage = $hubStorage } + } + '2' { return @{ Source = 'API'; HubStorage = $hubStorage } } + '3' { return @{ Source = 'GraphOnly'; HubStorage = $hubStorage } } + default { Write-Host " Invalid choice." -ForegroundColor Red } + } + } + } + else { + Write-Host " No FinOps Hub found in selected subscriptions." -ForegroundColor DarkGray + Write-Host "" + Write-Host " [1] Cost Management API" -ForegroundColor Yellow -NoNewline + Write-Host " - Query Azure Cost Management REST APIs directly" -ForegroundColor DarkGray + Write-Host " Real-time, subject to API throttling on large tenants" -ForegroundColor DarkGray + Write-Host "" + Write-Host " [2] Resource Graph only" -ForegroundColor DarkGray -NoNewline + Write-Host " - Skip cost modules, run governance/optimization scans only" -ForegroundColor DarkGray + Write-Host "" + + while ($true) { + Write-Host " Select [1/2]: " -ForegroundColor White -NoNewline + $choice = Read-Host + switch ($choice.Trim()) { + '1' { return @{ Source = 'API'; HubStorage = $null } } + '2' { return @{ Source = 'GraphOnly'; HubStorage = $null } } + default { Write-Host " Invalid choice." -ForegroundColor Red } + } + } + } + } + + # ===================================================================== + # SUBSCRIPTION PICKER + # ===================================================================== + function Select-Subscription { + param([string]$PreselectedId) + + Write-Host " Checking Azure connection..." -ForegroundColor DarkGray + $ctx = Get-AzContext -ErrorAction SilentlyContinue + if (-not $ctx) { + Write-Host " Not connected. Launching browser login..." -ForegroundColor Yellow + Connect-AzAccount | Out-Null + $ctx = Get-AzContext + } + Write-Host " Signed in as: $($ctx.Account.Id)" -ForegroundColor Green + Write-Host "" + + # -- Tenant picker ------------------------------------------------ + $tenants = @(Get-AzTenant -ErrorAction SilentlyContinue) + if ($tenants.Count -gt 1) { + Write-Host " $($tenants.Count) tenants available:" -ForegroundColor White + Write-Host "" + + $tCursor = 0 + $currentTenantId = $ctx.Tenant.Id + # Pre-select current tenant + for ($t = 0; $t -lt $tenants.Count; $t++) { + if ($tenants[$t].TenantId -eq $currentTenantId) { $tCursor = $t; break } + } + + while ($true) { + [Console]::SetCursorPosition(0, [Console]::CursorTop) + for ($t = 0; $t -lt $tenants.Count; $t++) { + $tPrefix = if ($t -eq $tCursor) { ' > ' } else { ' ' } + $tColor = if ($t -eq $tCursor) { 'Green' } else { 'Gray' } + $tLabel = if ($tenants[$t].Name -and $tenants[$t].Name -ne $tenants[$t].TenantId) { + "$($tenants[$t].Name) ($($tenants[$t].TenantId))" + } + else { $tenants[$t].TenantId } + $current = if ($tenants[$t].TenantId -eq $currentTenantId) { ' (current)' } else { '' } + $tLine = "$tPrefix$tLabel$current" + if ($tLine.Length -gt 80) { $tLine = $tLine.Substring(0, 77) + '...' } + Write-Host $tLine.PadRight(85) -ForegroundColor $tColor + } + Write-Host "" + Write-Host " ↑↓ Navigate │ Enter = Select tenant │ Q = Stay in current" -ForegroundColor DarkGray + + $tKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + switch ($tKey.VirtualKeyCode) { + 38 { if ($tCursor -gt 0) { $tCursor-- } } + 40 { if ($tCursor -lt $tenants.Count - 1) { $tCursor++ } } + 13 { + $selectedTenant = $tenants[$tCursor] + if ($selectedTenant.TenantId -ne $currentTenantId) { + Write-Host "" + Write-Host " Switching to tenant: $($selectedTenant.Name)..." -ForegroundColor Yellow + Connect-AzAccount -TenantId $selectedTenant.TenantId | Out-Null + $ctx = Get-AzContext + Write-Host " Connected to: $($ctx.Tenant.Id)" -ForegroundColor Green + } + else { + Write-Host "" + Write-Host " Staying in current tenant." -ForegroundColor Green + } + break + } + 81 { + Write-Host "" + Write-Host " Staying in current tenant." -ForegroundColor Green + break + } + } + if ($tKey.VirtualKeyCode -eq 13 -or $tKey.VirtualKeyCode -eq 81) { break } + + # Move cursor back up to re-render + $tLinesToClear = $tenants.Count + 2 + [Console]::SetCursorPosition(0, [Console]::CursorTop - $tLinesToClear) + } + Write-Host "" + } + elseif ($tenants.Count -eq 1) { + $tLabel = if ($tenants[0].Name -and $tenants[0].Name -ne $tenants[0].TenantId) { $tenants[0].Name } else { $tenants[0].TenantId } + Write-Host " Tenant: $tLabel" -ForegroundColor Green + Write-Host "" + } + + if ($PreselectedId) { + $sub = Get-AzSubscription -SubscriptionId $PreselectedId -ErrorAction SilentlyContinue + if ($sub) { + Write-Host " Using subscription: $($sub.Name)" -ForegroundColor Green + return @($sub) + } + Write-Host " Subscription $PreselectedId not found, showing picker..." -ForegroundColor Yellow + } + + # Scope subscription enumeration to the SELECTED tenant only. + # Get-AzSubscription with no -TenantId returns subscriptions across every + # tenant the signed-in account can access, which incorrectly mixes tenants + # together when the user picks one tenant and chooses "All subscriptions". + $effectiveTenantId = (Get-AzContext -ErrorAction SilentlyContinue).Tenant.Id + $allSubs = @(Get-AzSubscription -TenantId $effectiveTenantId -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' }) + if ($allSubs.Count -eq 0) { + Write-Error "No enabled subscriptions found in tenant $effectiveTenantId." + return $null + } + if ($allSubs.Count -eq 1) { + Write-Host " Using only subscription: $($allSubs[0].Name)" -ForegroundColor Green + return $allSubs + } + + # Multi-sub picker + Write-Host " Found $($allSubs.Count) subscriptions. Select scope:" -ForegroundColor White + Write-Host "" + Write-Host " [A] All subscriptions" -ForegroundColor White + Write-Host " [S] Single subscription (pick from list)" -ForegroundColor White + Write-Host "" + $choice = Read-Host " Choice (A/S)" + + if ($choice -eq 'A' -or $choice -eq 'a') { + Write-Host " Scanning all $($allSubs.Count) subscriptions" -ForegroundColor Green + return $allSubs + } + + # Arrow-key single subscription picker + $cursor = 0 + $pageSize = 15 + $offset = 0 + + while ($true) { + # Render list + $renderStart = $offset + $renderEnd = [math]::Min($offset + $pageSize, $allSubs.Count) - 1 + [Console]::SetCursorPosition(0, [Console]::CursorTop) + + for ($i = $renderStart; $i -le $renderEnd; $i++) { + $prefix = if ($i -eq $cursor) { ' > ' } else { ' ' } + $color = if ($i -eq $cursor) { 'Green' } else { 'Gray' } + $line = "$prefix$($allSubs[$i].Name)" + if ($line.Length -gt 70) { $line = $line.Substring(0, 67) + '...' } + Write-Host $line.PadRight(75) -ForegroundColor $color + } + Write-Host "" + Write-Host " ↑↓ Navigate │ Enter = Select │ Q = Cancel" -ForegroundColor DarkGray + + $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + switch ($key.VirtualKeyCode) { + 38 { + # Up + if ($cursor -gt 0) { $cursor-- } + if ($cursor -lt $offset) { $offset = $cursor } + } + 40 { + # Down + if ($cursor -lt $allSubs.Count - 1) { $cursor++ } + if ($cursor -ge $offset + $pageSize) { $offset = $cursor - $pageSize + 1 } + } + 13 { + # Enter + Write-Host "" + Write-Host " Selected: $($allSubs[$cursor].Name)" -ForegroundColor Green + return @($allSubs[$cursor]) + } + 81 { return $null } # Q + } + + # Move cursor back up to re-render + $linesToClear = ($renderEnd - $renderStart + 1) + 2 + [Console]::SetCursorPosition(0, [Console]::CursorTop - $linesToClear) + } + } + + # ===================================================================== + # SCAN MODULE PICKER (checkbox menu) + # ===================================================================== + function Select-ScanModules { + param([array]$Modules) + + $cursor = 0 + $categories = $Modules | ForEach-Object { $_.Category } | Select-Object -Unique + + while ($true) { + # Build display lines grouped by category + $lines = @() + $lineToIndex = @{} # map display line -> module index + $moduleIdx = 0 + + foreach ($cat in $categories) { + $lines += " ── $cat ──" + $lineToIndex[$lines.Count - 1] = -1 # category header, not selectable + + $catModules = $Modules | Where-Object { $_.Category -eq $cat } + foreach ($mod in $catModules) { + $idx = [array]::IndexOf($Modules, $mod) + $check = if ($mod.Selected) { '[x]' } else { '[ ]' } + $lines += " $check $($mod.Name)" + $lineToIndex[$lines.Count - 1] = $idx + } + $lines += '' + $lineToIndex[$lines.Count - 1] = -1 + } + + # Find selectable line indices + $selectableLines = @() + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lineToIndex[$i] -ge 0) { $selectableLines += $i } + } + + if ($cursor -ge $selectableLines.Count) { $cursor = $selectableLines.Count - 1 } + $activeLine = $selectableLines[$cursor] + + # Render + Clear-Host + Write-Host "" + Write-Host " SELECT SCANS" -ForegroundColor White + Write-Host " ↑↓ Move │ Space = Toggle │ A = All │ N = None │ Enter = Run │ Q = Quit" -ForegroundColor DarkGray + Write-Host "" + + $selectedCount = ($Modules | Where-Object { $_.Selected }).Count + + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lineToIndex[$i] -eq -1) { + # Category header or blank + if ($lines[$i] -match '──') { + Write-Host $lines[$i] -ForegroundColor Yellow + } + else { + Write-Host $lines[$i] + } + } + else { + $isActive = ($i -eq $activeLine) + $mod = $Modules[$lineToIndex[$i]] + $check = if ($mod.Selected) { '[x]' } else { '[ ]' } + $pointer = if ($isActive) { ' >' } else { ' ' } + $color = if ($isActive -and $mod.Selected) { 'Green' } + elseif ($isActive) { 'White' } + elseif ($mod.Selected) { 'DarkGreen' } + else { 'Gray' } + Write-Host " $pointer $check $($mod.Name)" -ForegroundColor $color + } + } + + Write-Host "" + Write-Host " $selectedCount of $($Modules.Count) scans selected" -ForegroundColor DarkGray + Write-Host "" + + # Read key + $key = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + switch ($key.VirtualKeyCode) { + 38 { if ($cursor -gt 0) { $cursor-- } } # Up + 40 { if ($cursor -lt $selectableLines.Count - 1) { $cursor++ } } # Down + 32 { + # Space = toggle + $modIdx = $lineToIndex[$selectableLines[$cursor]] + $Modules[$modIdx].Selected = -not $Modules[$modIdx].Selected + } + 65 { + # A = select all + foreach ($m in $Modules) { $m.Selected = $true } + } + 78 { + # N = select none + foreach ($m in $Modules) { $m.Selected = $false } + } + 13 { + # Enter = run + $selected = $Modules | Where-Object { $_.Selected } + if ($selected.Count -eq 0) { + Write-Host " No scans selected. Press any key..." -ForegroundColor Red + $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + } + else { return $Modules } + } + 81 { return $null } # Q = quit + } + } + } + + # ===================================================================== + # RUN SELECTED SCANS + # ===================================================================== + function Invoke-SelectedScans { + param( + [array]$Modules, + [array]$Subscriptions, + [string]$TenantId, + [hashtable]$DataSource + ) + + $selected = $Modules | Where-Object { $_.Selected } + $results = @{} + $total = $selected.Count + $current = 0 + + # Pre-load Hub data if Hub source selected + $hubCostData = $null + $hubResourceCosts = $null + $hubRaw = $null + $hubTagInventory = $null + $hubCostByTag = $null + + # Scalable Kusto path: when a FinOps Hub Kusto database is reachable + # (a FINOPS_HUB_KUSTO_URI override for an ftklocal emulator or a pinned + # cluster, or a discovered ADX/Fabric cluster), push aggregation into + # the engine and return only summaries - never load raw rows. This is + # what lets the tool scale to large hub datasets. Falls back to the + # storage reader below when no cluster is available. + $kustoProvider = $null + if ($DataSource.Source -eq 'Hub' -or $env:FINOPS_HUB_KUSTO_URI) { + $subIdsForDisco = @($Subscriptions | ForEach-Object { $_.Id }) + $kp = Resolve-FOHubProvider -Subscriptions $subIdsForDisco + if ($kp -and $kp.Found) { $kustoProvider = $kp } + } + + if ($kustoProvider) { + Write-Host "" + Write-Host " Querying FinOps Hub Kusto database ($($kustoProvider.Mode))..." -ForegroundColor Green + $cs = Get-FOHubCostSummary -Provider $kustoProvider + if (-not ($cs -is [System.Collections.IDictionary] -and $cs.Contains('Error') -and $cs.Error)) { $hubCostData = $cs } + $rc = Get-FOHubResourceCosts -Provider $kustoProvider + if (-not ($rc -is [System.Collections.IDictionary] -and $rc.Contains('Error') -and $rc.Error)) { $hubResourceCosts = $rc } + $ct = Get-FOHubCostByTag -Provider $kustoProvider + if (-not ($ct -is [System.Collections.IDictionary] -and $ct.Contains('Error') -and $ct.Error)) { $hubCostByTag = $ct } + Write-Host " Hub data summarized in-engine (no rows loaded). Forecast is not included; choose API source for live forecast." -ForegroundColor DarkGray + } + elseif ($DataSource.HubStorage) { + # Storage reader: small-dataset convenience path (rows loaded into + # PowerShell). For large hubs, the Kusto path above is preferred. + $hub = $DataSource.HubStorage + if ($DataSource.Source -eq 'Hub') { + Write-Host "" + Write-Host " Loading cost data from FinOps Hub storage (small-dataset reader)..." -ForegroundColor Green + Write-Host " For large hubs, query the Kusto database instead (ADX/Fabric, or set FINOPS_HUB_KUSTO_URI for ftklocal)." -ForegroundColor DarkGray + } + else { + Write-Host " Loading Hub tag data for fast tag scans..." -ForegroundColor DarkGray + } + try { + $hubRaw = Read-FinOpsHubData -StorageAccountName $hub.name -ResourceGroupName $hub.resourceGroup -Months 1 + } + catch { + Write-Host " Hub data load failed: $($_.Exception.Message)" -ForegroundColor Yellow + $hubRaw = $null + } + if ($hubRaw -and @($hubRaw).Count -gt 0) { + $hubTagInventory = ConvertTo-TagInventoryFromHub -HubData $hubRaw + + # Hub tag coverage only reflects resources with cost data — query ARG for true counts + try { + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $totalBody = @{ + subscriptions = @($subIds) + query = "resources | summarize TotalCount = count()" + options = @{ resultFormat = 'objectArray' } + } | ConvertTo-Json -Depth 5 + $totalResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $totalBody + if ($totalResp.StatusCode -eq 200) { + $totalRows = @(($totalResp.Content | ConvertFrom-Json).data) + if ($totalRows.Count -gt 0) { + $argTotal = [int]$totalRows[0].TotalCount + + $untaggedBody = @{ + subscriptions = @($subIds) + query = "resources | where isnull(tags) or tags == '{}' | summarize UntaggedCount = count()" + options = @{ resultFormat = 'objectArray' } + } | ConvertTo-Json -Depth 5 + $untaggedResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $untaggedBody + if ($untaggedResp.StatusCode -eq 200) { + $untaggedRows = @(($untaggedResp.Content | ConvertFrom-Json).data) + $argUntagged = if ($untaggedRows.Count -gt 0) { [int]$untaggedRows[0].UntaggedCount } else { 0 } + + $argTagged = [math]::Max(0, $argTotal - $argUntagged) + $argCoverage = if ($argTotal -gt 0) { [math]::Round(($argTagged / $argTotal) * 100, 1) } else { 0 } + + # Override Hub coverage with ARG-based coverage + $hubTagInventory = $hubTagInventory | ForEach-Object { + $_.TotalResources = $argTotal + $_.TaggedCount = $argTagged + $_.UntaggedCount = $argUntagged + $_.TagCoverage = $argCoverage + $_ + } + Write-Host " Tag coverage corrected via Resource Graph: $argCoverage% ($argTagged/$argTotal)" -ForegroundColor DarkGray + } + } + } + } + catch { + Write-Host " Could not verify tag coverage via ARG: $($_.Exception.Message)" -ForegroundColor DarkGray + } + + if ($DataSource.Source -eq 'Hub') { + $hubCostData = ConvertTo-CostDataFromHub -HubData $hubRaw + $hubResourceCosts = ConvertTo-ResourceCostsFromHub -HubData $hubRaw + + # Hub exports are historical actuals — enrich with live forecast from Cost Management API + try { + Write-Host " Fetching forecast data from Cost Management API..." -ForegroundColor DarkGray + $now = Get-Date + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + $forecastFilled = $false + + # Try MG-scope forecast first + $fctTenantId = (Get-AzContext).Tenant.Id + if ($fctTenantId) { + $fctBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.ToString('yyyy-MM-dd') + to = $monthEnd.ToString('yyyy-MM-dd') + } + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'SubscriptionId' }) + } + includeActualCost = $false + includeFreshPartialCost = $false + } | ConvertTo-Json -Depth 10 + + $fctPath = "/providers/Microsoft.Management/managementGroups/$fctTenantId/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" + $fctResp = Invoke-AzRestMethodWithRetry -Path $fctPath -Method POST -Payload $fctBody + if ($fctResp.StatusCode -eq 200) { + $fctResult = ($fctResp.Content | ConvertFrom-Json) + if ($fctResult.properties.rows -and $fctResult.properties.rows.Count -gt 0) { + $fctSums = @{} + foreach ($row in $fctResult.properties.rows) { + $subId = $row[1] + if (-not $fctSums.ContainsKey($subId)) { $fctSums[$subId] = 0 } + $fctSums[$subId] += [double]$row[0] + } + foreach ($subId in $fctSums.Keys) { + if ($hubCostData.ContainsKey($subId)) { + # Full-month projection = actual MTD + remaining forecast + $actual = $hubCostData[$subId].Actual + $hubCostData[$subId].Forecast = [math]::Round($actual + $fctSums[$subId], 2) + } + } + $forecastFilled = $true + Write-Host " Forecast data loaded for $($fctSums.Count) subscription(s)" -ForegroundColor Green + } + } + } + + # Per-subscription fallback if MG-scope failed + if (-not $forecastFilled -and $Subscriptions) { + $fctHits = 0 + foreach ($sub in $Subscriptions) { + try { + $fBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.ToString('yyyy-MM-dd') + to = $monthEnd.ToString('yyyy-MM-dd') + } + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + } + includeActualCost = $false + includeFreshPartialCost = $false + } | ConvertTo-Json -Depth 10 + $fResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" -Method POST -Payload $fBody + if ($fResp.StatusCode -eq 200) { + $fRes = ($fResp.Content | ConvertFrom-Json) + if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) { + $fctTotal = 0 + foreach ($row in $fRes.properties.rows) { $fctTotal += [double]$row[0] } + if ($hubCostData.ContainsKey($sub.Id)) { + # Full-month projection = actual MTD + remaining forecast + $actual = $hubCostData[$sub.Id].Actual + $hubCostData[$sub.Id].Forecast = [math]::Round($actual + $fctTotal, 2) + $fctHits++ + } + } + } + } + catch { } + } + if ($fctHits -gt 0) { Write-Host " Forecast data loaded for $fctHits subscription(s)" -ForegroundColor Green } + } + } + catch { + Write-Host " Forecast data unavailable: $($_.Exception.Message)" -ForegroundColor DarkGray + } + + Write-Host " Hub data loaded: $(@($hubRaw).Count) cost records, $($hubTagInventory.TagCount) tags, $($hubTagInventory.TagCoverage)% coverage" -ForegroundColor Green + } + else { + Write-Host " Hub tag data ready: $($hubTagInventory.TagCount) tags, $($hubTagInventory.TagCoverage)% coverage" -ForegroundColor DarkGray + } + } + else { + $hubRaw = $null + if ($DataSource.Source -eq 'Hub') { + Write-Host " No Hub data found — falling back to Cost Management API" -ForegroundColor Yellow + $DataSource.Source = 'API' + } + } + if ($DataSource.Source -eq 'Hub') { Write-Host "" } + } + + $srcLabel = switch ($DataSource.Source) { + 'Hub' { "FinOps Hub ($($DataSource.HubStorage.name))" } + 'API' { "Cost Management API (real-time)" } + 'GraphOnly' { "Resource Graph only" } + } + Write-SectionHeader "RUNNING $total SCANS" + $srcColor = switch ($DataSource.Source) { 'Hub' { 'Green' } 'API' { 'Yellow' } 'GraphOnly' { 'DarkGray' } } + Write-Host " $srcLabel" -ForegroundColor $srcColor + Write-Host "" + + foreach ($mod in $selected) { + $current++ + $pct = [math]::Round(($current / $total) * 100) + $bar = ('█' * [math]::Floor($pct / 5)).PadRight(20, '░') + + Write-Host " [$bar] $pct% ($current/$total) $($mod.Name)" -ForegroundColor White -NoNewline + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + try { + $fn = $mod.Fn + $output = $null + + # Route parameters based on what each function expects + # Hub shortcut: return pre-loaded Hub data for cost/tag modules + switch ($fn) { + { $_ -eq 'Get-CostData' -and $hubCostData } { + $output = $hubCostData; break + } + { $_ -eq 'Get-ResourceCosts' -and $hubResourceCosts } { + $output = $hubResourceCosts; break + } + { $_ -eq 'Get-TagInventory' -and $hubTagInventory } { + $output = $hubTagInventory; break + } + { $_ -eq 'Get-CostByTag' -and $hubCostByTag } { + # Scalable Kusto path: cost-by-tag summarized in-engine. + $output = $hubCostByTag; break + } + { $_ -eq 'Get-CostByTag' -and $hubRaw } { + # Build cost-by-tag from Hub data — zero API calls + $existingTags = if ($results.ContainsKey('Get-TagInventory') -and $results['Get-TagInventory'].TagNames) { + $results['Get-TagInventory'].TagNames + } + elseif ($hubTagInventory) { $hubTagInventory.TagNames } + else { @{} } + $output = ConvertTo-CostByTagFromHub -HubData $hubRaw -ExistingTags $existingTags; break + } + 'Get-TagRecommendations' { + $tags = if ($results.ContainsKey('Get-TagInventory') -and $results['Get-TagInventory'].TagNames) { + $results['Get-TagInventory'].TagNames + } + elseif ($hubTagInventory) { $hubTagInventory.TagNames } + else { @{} } + $output = & $fn -ExistingTags $tags; break + } + 'Get-PolicyRecommendations' { + $assignments = if ($results.ContainsKey('Get-PolicyInventory') -and $results['Get-PolicyInventory'].Assignments) { + $results['Get-PolicyInventory'].Assignments + } + else { @() } + $output = & $fn -ExistingAssignments $assignments; break + } + 'Get-BudgetStatus' { + $costData = if ($results.ContainsKey('Get-CostData') -and $results['Get-CostData'] -is [hashtable]) { + $results['Get-CostData'] + } + elseif ($hubCostData -is [hashtable]) { $hubCostData } + else { @{} } + $output = & $fn -Subscriptions $Subscriptions -CostData $costData; break + } + 'Get-BudgetHistory' { + # Depends on Budget Status — reuse the budgets it already found + $budgetResult = if ($results.ContainsKey('Get-BudgetStatus')) { $results['Get-BudgetStatus'] } else { $null } + $budgetRows = if ($budgetResult -and $budgetResult.Budgets) { @($budgetResult.Budgets) } else { @() } + if ($budgetRows.Count -gt 0) { + # Reuse Cost Trend's already-fetched monthly spend so we don't + # re-hit the throttle-prone Cost Management Query API. + $trendResult = if ($results.ContainsKey('Get-CostTrend')) { $results['Get-CostTrend'] } else { $null } + $output = & $fn -Budgets $budgetRows -MonthsBack 6 -CostTrend $trendResult + } + else { + $output = @() + } + break + } + 'Get-MaccCommitment' { + # Pass the detected agreement type from Contract Info when available + $agreementType = '' + if ($results.ContainsKey('Get-ContractInfo') -and $results['Get-ContractInfo']) { + $agreementType = @($results['Get-ContractInfo'])[0].AgreementType + } + $output = & $fn -Subscriptions $Subscriptions -AgreementType $agreementType; break + } + { $_ -eq 'Get-CostByTag' -and -not $hubRaw } { + # No Hub data — fall back to API + $existingTags = if ($results.ContainsKey('Get-TagInventory') -and $results['Get-TagInventory'].TagNames) { + $results['Get-TagInventory'].TagNames + } + else { @{} } + $output = & $fn -TenantId $TenantId -ExistingTags $existingTags -Subscriptions $Subscriptions; break + } + 'Get-AIWorkloadMetrics' { + # AI scan runs its own cheap ARG footprint gate; when the + # Hub source is selected, hand it the pre-loaded export so + # spend + token volume come from the export, not the + # Monitor + Cost Management APIs. + $aiParams = @{ TenantId = $TenantId; Subscriptions = $Subscriptions } + if ($DataSource.Source -eq 'Hub' -and $hubRaw -and @($hubRaw).Count -gt 0) { + $aiParams['HubData'] = $hubRaw + } + $output = & $fn @aiParams; break + } + default { + # Build params — include TenantId if the function accepts it + $params = @{ Subscriptions = $Subscriptions } + $cmdInfo = Get-Command $fn -ErrorAction SilentlyContinue + if ($cmdInfo -and $cmdInfo.Parameters.ContainsKey('TenantId') -and $TenantId) { + $params['TenantId'] = $TenantId + } + $output = & $fn @params + } + } + + $sw.Stop() + $count = if ($output) { @($output).Count } else { 0 } + $results[$fn] = $output + + Write-Host "`r [$bar] $pct% ($current/$total) $($mod.Name) " -ForegroundColor Green -NoNewline + Write-Host " $count results ($([math]::Round($sw.Elapsed.TotalSeconds, 1))s)" -ForegroundColor DarkGray + } + catch { + $sw.Stop() + Write-Host "`r [$bar] $pct% ($current/$total) $($mod.Name) " -ForegroundColor Red -NoNewline + Write-Host " FAILED: $($_.Exception.Message)" -ForegroundColor Red + $results[$mod.Fn] = @() + $results["_error_$($mod.Fn)"] = $_.Exception.Message + } + } + + return $results + } + + # ===================================================================== + # RESULTS SUMMARY + # ===================================================================== + function Write-SectionHeader { + param([string]$Title, [string]$Color = 'Cyan') + $line = '═' * 55 + Write-Host "" + Write-Host " $line" -ForegroundColor $Color + Write-Host " $Title" -ForegroundColor $Color + Write-Host " $line" -ForegroundColor $Color + } + + # Write a line with dollar amounts ($1,234) highlighted in green + function Write-ColorizedLine { + param( + [string]$Text, + [string]$DefaultColor = 'White', + [string]$MoneyColor = 'Green' + ) + # Split on dollar-amount patterns, render them in green + $parts = [regex]::Split($Text, '(\$[\d,]+\.?\d*(?:/\w+)?)') + foreach ($part in $parts) { + if ($part -match '^\$[\d,]+\.?\d*') { + Write-Host $part -ForegroundColor $MoneyColor -NoNewline + } + else { + Write-Host $part -ForegroundColor $DefaultColor -NoNewline + } + } + Write-Host "" + } + function Show-PermissionReadout { + param( + [string]$Fn, + [hashtable]$PermissionInfo, + [string]$Activity + ) + $pInfo = if ($PermissionInfo -and $PermissionInfo.ContainsKey($Fn)) { $PermissionInfo[$Fn] } else { $null } + $what = if ($Activity) { " reading $Activity" } else { '' } + Write-Host " [!] ACCESS DENIED$what (the API returned access denied, not empty results)." -ForegroundColor Red + if ($pInfo) { + Write-Host " Required role: $($pInfo.Role)" -ForegroundColor Yellow + Write-Host " Scope: $($pInfo.Scope)" -ForegroundColor Yellow + Write-Host " API: $($pInfo.API)" -ForegroundColor DarkGray + Write-Host " $($pInfo.Reason)" -ForegroundColor DarkGray + Write-Host " Ask a billing or subscription admin to assign the matching role, then re-scan." -ForegroundColor DarkGray + } + } + + function Show-ResultsSummary { + param( + [hashtable]$Results, + [array]$Modules, + [string]$ExportPath, + [array]$Subscriptions + ) + + # Build sub ID → name lookup for display functions + $subNameLookup = @{} + if ($Subscriptions) { foreach ($s in $Subscriptions) { if ($s.Id -and $s.Name) { $subNameLookup[$s.Id] = $s.Name } } } + + Write-SectionHeader 'SCAN COMPLETE' + Write-Host "" + + $totalFindings = 0 + foreach ($mod in ($Modules | Where-Object { $_.Selected })) { + $data = $Results[$mod.Fn] + $errorKey = "_error_$($mod.Fn)" + $hasError = $Results.ContainsKey($errorKey) + $count = if ($data) { @($data).Count } else { 0 } + $totalFindings += $count + if ($hasError) { + $icon = '!' + $color = 'Red' + $suffix = 'error (see details below)' + } + elseif ($count -gt 0) { + $icon = '*' + $color = 'Yellow' + $suffix = "$count findings" + } + else { + $icon = '-' + $color = 'DarkGray' + $suffix = '0 findings' + } + Write-Host " $icon $($mod.Name.PadRight(30)) $suffix" -ForegroundColor $color + } + + Write-Host "" + Write-Host " Total findings: $totalFindings" -ForegroundColor White + Write-Host "" + + # -- Display results per module ------------------------------------ + foreach ($mod in ($Modules | Where-Object { $_.Selected })) { + $data = $Results[$mod.Fn] + if (-not $data -or @($data).Count -eq 0) { + # Show why data is missing — error or permissions + $errorKey = "_error_$($mod.Fn)" + $errorMsg = if ($Results.ContainsKey($errorKey)) { $Results[$errorKey] } else { $null } + $pInfo = if ($permissionInfo.ContainsKey($mod.Fn)) { $permissionInfo[$mod.Fn] } else { $null } + + Write-SectionHeader $mod.Name + if ($errorMsg) { + # Detect permission-related errors + $isPermError = $errorMsg -match '(?i)403|401|Forbidden|Unauthorized|AuthorizationFailed|does not have authorization|InsufficientPermissions|BillingAccountNotFound' + if ($isPermError -and $pInfo) { + Write-Host " [!] ACCESS DENIED" -ForegroundColor Red + Write-Host " $errorMsg" -ForegroundColor DarkGray + Write-Host "" + Write-Host " Required role: $($pInfo.Role)" -ForegroundColor Yellow + Write-Host " Scope: $($pInfo.Scope)" -ForegroundColor Yellow + Write-Host " API: $($pInfo.API)" -ForegroundColor DarkGray + Write-Host " $($pInfo.Reason)" -ForegroundColor DarkGray + } + else { + Write-Host " [!] ERROR: $errorMsg" -ForegroundColor Red + if ($pInfo) { + Write-Host " If this is a permissions issue:" -ForegroundColor DarkGray + Write-Host " Required role: $($pInfo.Role) at $($pInfo.Scope) scope" -ForegroundColor DarkGray + } + } + } + else { + # No error but no data — could be legitimately empty + Write-Host " No data returned." -ForegroundColor DarkGray + if ($pInfo) { + Write-Host " Possible reasons:" -ForegroundColor DarkGray + Write-Host " - $($pInfo.Reason)" -ForegroundColor DarkGray + Write-Host " - Required role: $($pInfo.Role) at $($pInfo.Scope) scope" -ForegroundColor DarkGray + } + } + Write-Host "" + continue + } + + Write-SectionHeader $mod.Name + + # Extract the displayable rows and columns per module + $rows = $null + $cols = $null + + switch ($mod.Fn) { + 'Get-OrphanedResources' { + $rows = $data.Orphans + $cols = @('Category', 'ResourceName', 'ResourceGroup', 'Detail') + } + 'Get-IdleVMs' { + $scanned = if ($data.ScannedVMs) { $data.ScannedVMs } else { 0 } + if ($data.IdleVMs -and @($data.IdleVMs).Count -gt 0) { + Write-Host " Scanned $scanned running VMs — $(@($data.IdleVMs).Count) idle/underutilized" -ForegroundColor White + $rows = $data.IdleVMs + $cols = @('VMName', 'ResourceGroup', 'VMSize', 'AvgCPU14d', 'Classification') + } + else { + Write-Host " Scanned $scanned running VMs — no idle or underutilized VMs detected" -ForegroundColor Green + } + } + 'Get-StorageTierAdvice' { + $hotCount = if ($data.TotalHotAccounts) { $data.TotalHotAccounts } else { 0 } + if ($data.Recommendations -and @($data.Recommendations).Count -gt 0) { + Write-Host " $hotCount Hot-tier accounts scanned — $(@($data.Recommendations).Count) can be optimized" -ForegroundColor White + $rows = $data.Recommendations + $cols = @('StorageAccount', 'ResourceGroup', 'CurrentTier', 'CapacityGB', 'Recommendation') + } + else { + Write-Host " $hotCount Hot-tier accounts scanned — all are appropriately tiered" -ForegroundColor Green + } + } + 'Get-AHBOpportunities' { + $rows = @() + if ($data.WindowsVMs) { + $rows += @($data.WindowsVMs) | ForEach-Object { + $est = if ($_.estMonthlySavings) { '$' + ('{0:N0}' -f $_.estMonthlySavings) + '/mo' } else { 'n/a' } + [PSCustomObject]@{ Type = 'Windows VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.vmSize; License = $_.currentLicense; 'Est Savings' = $est } + } + } + if ($data.SQLVMs) { + $rows += @($data.SQLVMs) | ForEach-Object { + [PSCustomObject]@{ Type = 'SQL VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sqlEdition; License = $_.currentLicense; 'Est Savings' = '-' } + } + } + if ($data.SQLDatabases) { + $rows += @($data.SQLDatabases) | ForEach-Object { + [PSCustomObject]@{ Type = 'SQL DB'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sku; License = $_.currentLicense; 'Est Savings' = '-' } + } + } + $cols = @('Type', 'Name', 'ResourceGroup', 'Size', 'License', 'Est Savings') + } + 'Get-ReservationAdvice' { + if ($data.AccessDenied) { + Show-PermissionReadout -Fn 'Get-ReservationAdvice' -PermissionInfo $permissionInfo -Activity 'Advisor / reservation recommendations' + } + else { + $rows = $data.AdvisorRecommendations | ForEach-Object { + $resLabel = if ($_.Subscription -and $_.Subscription -ne $_.SubscriptionId) { $_.Subscription } + elseif ($_.Solution) { $_.Solution.Substring(0, [math]::Min(50, $_.Solution.Length)) } + else { ($_.ResourceName -split '/')[-1] } + [PSCustomObject]@{ + Resource = $resLabel + Type = ($_.ResourceType -split '/')[-1] + Term = $_.Term + Savings = '{0:C0}' -f [double]$_.AnnualSavings + Impact = $_.Impact + } + } + $cols = @('Resource', 'Type', 'Term', 'Savings', 'Impact') + if ($data.EstimatedAnnualSavings) { + Write-ColorizedLine -Text " Est. annual savings: $($data.EstimatedAnnualSavings.ToString('C0'))" -DefaultColor 'White' + } + } + } + 'Get-CommitmentUtilization' { + if ($data.HasData) { + Write-Host " RIs: $($data.RICount) (avg $($data.RIAvgUtilization)% util) | Savings Plans: $($data.SPCount) (avg $($data.SPAvgUtilization)% util)" -ForegroundColor White + $rows = $data.UnderutilizedRIs | ForEach-Object { + [PSCustomObject]@{ SKU = $_.SkuName; Kind = $_.Kind; AvgUtil = "$($_.AvgUtilization)%"; MinUtil = "$($_.MinUtilization)%" } + } + $cols = @('SKU', 'Kind', 'AvgUtil', 'MinUtil') + } + elseif ($data.AccessDenied) { + Show-PermissionReadout -Fn 'Get-CommitmentUtilization' -PermissionInfo $permissionInfo -Activity 'reservation / savings plan utilization' + } + else { + Write-Host " No active reservations or savings plans found." -ForegroundColor DarkGray + } + } + 'Get-SavingsRealized' { + Write-Host " Monthly savings breakdown:" -ForegroundColor White + Write-ColorizedLine -Text " RI: $($data.RISavingsMonthly.ToString('C0')) SP: $($data.SPSavingsMonthly.ToString('C0')) AHB: $($data.AHBSavingsMonthly.ToString('C0'))" -DefaultColor 'Cyan' + Write-ColorizedLine -Text " Total monthly: $($data.TotalMonthly.ToString('C0')) Annual: $($data.TotalAnnual.ToString('C0'))" -DefaultColor 'White' + $rows = $null # summary only + } + 'Get-CostData' { + # CostData is a hashtable keyed by subscription ID + if ($data -is [hashtable]) { + $rows = $data.GetEnumerator() | ForEach-Object { + # Prefer the name carried in the cost data itself (hub + # FOCUS data), then the selected-subscription lookup, + # then a truncated ID as a last resort. + $subLabel = if ($_.Value.Name) { $_.Value.Name } + elseif ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] } + else { $_.Key.Substring(0, [Math]::Min(36, $_.Key.Length)) } + [PSCustomObject]@{ + Subscription = $subLabel + Actual = '{0:C0}' -f [double]$_.Value.Actual + Forecast = '{0:C0}' -f [double]$_.Value.Forecast + Currency = $_.Value.Currency + } + } + $cols = @('Subscription', 'Actual', 'Forecast', 'Currency') + } + } + 'Get-ResourceCosts' { + $rows = @($data) | Sort-Object { [double]$_.Actual } -Descending | Select-Object -First 50 | ForEach-Object { + $resName = if ($_.ResourcePath) { ($_.ResourcePath -split '/')[-1] } else { '-' } + [PSCustomObject]@{ + Resource = $resName + ResourceGroup = $_.ResourceGroup + ResourceType = ($_.ResourceType -split '/')[-1] + Cost = '{0:C2}' -f [double]$_.Actual + } + } + $cols = @('Resource', 'ResourceGroup', 'ResourceType', 'Cost') + if (@($data).Count -gt 50) { + Write-Host " (showing top 50 of $(@($data).Count) resources by cost)" -ForegroundColor DarkGray + } + } + 'Get-CostByTag' { + if ($data.CostByTag -and $data.CostByTag.Count -gt 0) { + if ($data.Source) { Write-Host " Source: $($data.Source)" -ForegroundColor DarkGray } + $rows = foreach ($tag in $data.CostByTag.GetEnumerator()) { + foreach ($v in $tag.Value) { + $displayVal = if ($v.TagValue.Length -gt 40) { $v.TagValue.Substring(0, 37) + '...' } else { $v.TagValue } + [PSCustomObject]@{ Tag = $tag.Key; Value = $displayVal; Cost = '{0:C0}' -f [double]$v.Cost } + } + } + $cols = @('Tag', 'Value', 'Cost') + } + elseif ($data.NoTagsFound) { + Write-Host " No tags found in environment to query cost against." -ForegroundColor DarkGray + } + else { + $tagCount = if ($data.TagsQueried) { $data.TagsQueried.Count } else { 0 } + $cbtCount = if ($data.CostByTag) { $data.CostByTag.Count } else { 0 } + Write-Host " Tags queried: $tagCount, results: $cbtCount — no cost data returned." -ForegroundColor DarkGray + if ($data.UsedTimeframe) { Write-Host " Timeframe: $($data.UsedTimeframe)" -ForegroundColor DarkGray } + } + } + 'Get-CostTrend' { + # Per-sub data only counts when a subscription actually has months + $nonEmptySubs = @() + if ($data.BySubscription -and $data.BySubscription.Count -gt 0) { + $nonEmptySubs = @($data.BySubscription.GetEnumerator() | Where-Object { $_.Value -and @($_.Value).Count -gt 0 }) + } + $hasMonths = ($data.Months -and @($data.Months).Count -gt 0) + + if ((-not $nonEmptySubs -or $nonEmptySubs.Count -eq 0) -and -not $hasMonths) { + Write-Host " No cost trend data returned. Requires Cost Management Reader at the subscription or MG scope, or there is no historical spend in the selected period." -ForegroundColor DarkGray + $rows = $null + $cols = $null + } + elseif ($nonEmptySubs -and $nonEmptySubs.Count -gt 0) { + foreach ($subEntry in $nonEmptySubs) { + $subName = if ($subNameLookup.ContainsKey($subEntry.Key)) { $subNameLookup[$subEntry.Key] } else { $subEntry.Key } + Write-Host " $subName" -ForegroundColor White + $subRows = $subEntry.Value | ForEach-Object { + [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency } + } + @($subRows) | Format-Table -AutoSize | Out-String | ForEach-Object { + $lines = $_.TrimEnd() -split "`n" | Where-Object { $_.Trim() } + $hdrDone = $false + foreach ($ln in $lines) { + if (-not $hdrDone) { + if ($ln -match '^[\s\-]+$') { Write-Host " $ln" -ForegroundColor DarkCyan; $hdrDone = $true } + else { Write-Host " $ln" -ForegroundColor Cyan } + } + else { Write-ColorizedLine -Text " $ln" -DefaultColor 'White' } + } + } + } + # Skip default table rendering + $rows = $null + $cols = $null + } + else { + # Fallback: aggregate months with sub name header + if ($Subscriptions -and $Subscriptions.Count -gt 0) { + $subNames = ($Subscriptions | ForEach-Object { if ($_.Name) { $_.Name } else { $_.Id } }) -join ', ' + Write-Host " $subNames" -ForegroundColor White + } + $rows = $data.Months | ForEach-Object { + [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency } + } + $cols = @('Month', 'Cost', 'Currency') + } + } + 'Get-TagInventory' { + Write-Host " Coverage: $($data.TagCoverage)% | $($data.TaggedCount) tagged / $($data.UntaggedCount) untagged | $($data.TagCount) unique tags" -ForegroundColor White + if ($data.TagNames -and $data.TagNames.Count -gt 0) { + $rows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | Select-Object -First 15 | ForEach-Object { + [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; UniqueValues = @($_.Value.Values).Count } + } + $cols = @('Tag', 'Resources', 'UniqueValues') + } + } + 'Get-TagRecommendations' { + $rows = $data.Analysis | ForEach-Object { + [PSCustomObject]@{ Tag = $_.TagName; Status = $_.Status; Priority = $_.Priority; Pillar = $_.Pillar; Example = $_.Example } + } + $cols = @('Tag', 'Status', 'Priority', 'Pillar', 'Example') + Write-Host " Compliance: $($data.CompliancePercent)%" -ForegroundColor White + } + 'Get-PolicyInventory' { + $hasComplianceData = if ($null -ne $data.HasComplianceData) { $data.HasComplianceData } else { (($data.TotalCompliant + $data.TotalNonCompliant) -gt 0) } + if ($hasComplianceData) { + Write-Host " Assignments: $($data.AssignmentCount) | Compliance: $($data.CompliancePct)% ($($data.TotalCompliant) compliant, $($data.TotalNonCompliant) non-compliant)" -ForegroundColor White + } + else { + Write-Host " Assignments: $($data.AssignmentCount) | Compliance: data unavailable (no evaluated policy states)" -ForegroundColor White + } + $rows = $data.Assignments | Select-Object -First 15 | ForEach-Object { + # Parse scope into a readable label + $scopeRaw = $_.Scope + $scopeLabel = if ($scopeRaw -match '/managementGroups/([^/]+)') { + $mgId = $Matches[1] + if ($mgId.Length -gt 20) { "MG: $($mgId.Substring(0,17))..." } else { "MG: $mgId" } + } + elseif ($scopeRaw -match '/resourceGroups/([^/]+)') { "RG: $($Matches[1])" } + elseif ($scopeRaw -match '/subscriptions/([^/]+)') { + $subId = $Matches[1] + $subName = if ($subNameLookup.ContainsKey($subId)) { $subNameLookup[$subId] } else { $subId.Substring(0, 8) + '...' } + "Sub: $subName" + } + else { $scopeRaw } + # Truncate long policy names (some embed subscription GUIDs) + $displayName = $_.AssignmentName + if ($displayName.Length -gt 60) { $displayName = $displayName.Substring(0, 57) + '...' } + [PSCustomObject]@{ Name = $displayName; Effect = $_.Effect; Enforcement = $_.EnforcementMode; Scope = $scopeLabel } + } + $cols = @('Name', 'Effect', 'Enforcement', 'Scope') + if ($data.AssignmentCount -gt 15) { + Write-Host " (showing 15 of $($data.AssignmentCount) assignments)" -ForegroundColor DarkGray + } + } + 'Get-PolicyRecommendations' { + $rows = $data.Analysis | ForEach-Object { + [PSCustomObject]@{ Policy = $_.DisplayName; Status = $_.Status; Category = $_.Category; Priority = $_.Priority; Effect = $_.DefaultEffect } + } + $cols = @('Policy', 'Status', 'Category', 'Priority', 'Effect') + Write-Host " Compliance: $($data.CompliancePct)%" -ForegroundColor White + } + 'Get-BudgetStatus' { + $bSumColor = if ($data.OverBudgetCount -gt 0) { 'Red' } elseif ($data.AtRiskCount -gt 0) { 'Yellow' } else { 'Green' } + Write-Host " Budgets: $($data.TotalBudgets) | " -ForegroundColor White -NoNewline + Write-Host "At risk: $($data.AtRiskCount)" -ForegroundColor $(if ($data.AtRiskCount -gt 0) { 'Yellow' } else { 'Green' }) -NoNewline + Write-Host " | " -ForegroundColor White -NoNewline + Write-Host "Over budget: $($data.OverBudgetCount)" -ForegroundColor $(if ($data.OverBudgetCount -gt 0) { 'Red' } else { 'Green' }) -NoNewline + Write-Host " | Coverage: $($data.BudgetCoverage)%" -ForegroundColor White + $rows = $data.Budgets | ForEach-Object { + [PSCustomObject]@{ + Budget = $_.BudgetName + Amount = '{0:C0}' -f [double]$_.Amount + Spent = '{0:C0}' -f [double]$_.ActualSpend + PctUsed = "$($_.PctUsed)%" + Risk = $_.Risk + } + } + $cols = @('Budget', 'Amount', 'Spent', 'PctUsed', 'Risk') + } + 'Get-BudgetHistory' { + if ($data -and @($data).Count -gt 0) { + $rows = @($data) | ForEach-Object { + [PSCustomObject]@{ + Subscription = $_.Subscription + Budget = $_.BudgetName + Month = $_.Month + Budgeted = '{0:C0}' -f [double]$_.BudgetAmount + Actual = '{0:C0}' -f [double]$_.ActualSpend + PctUsed = "$($_.PctUsed)%" + Status = $_.Status + } + } + $cols = @('Subscription', 'Budget', 'Month', 'Budgeted', 'Actual', 'PctUsed', 'Status') + } + else { + Write-Host " No budget history available (no budgets configured, or no cost data for the period)." -ForegroundColor DarkGray + } + } + 'Get-AnomalyAlerts' { + Write-Host " Alerts: $($data.TotalAlerts) | Anomaly: $($data.AnomalyAlertCount) | Active: $($data.ActiveAlertCount) | Rules: $($data.ConfiguredRuleCount)" -ForegroundColor White + $rows = $data.TriggeredAlerts | Select-Object -First 10 | ForEach-Object { + $label = if ($_.AlertLabel) { $_.AlertLabel } else { $_.AlertName } + if ($label.Length -gt 45) { $label = $label.Substring(0, 42) + '...' } + [PSCustomObject]@{ Alert = $label; Type = $_.AlertType; Status = $_.Status; Subscription = $_.Subscription } + } + $cols = @('Alert', 'Type', 'Status', 'Subscription') + } + 'Get-BillingStructure' { + $rows = $data.BillingAccounts | ForEach-Object { + [PSCustomObject]@{ Account = $_.DisplayName; Agreement = $_.AgreementType; Type = $_.AccountType; Status = $_.AccountStatus } + } + $cols = @('Account', 'Agreement', 'Type', 'Status') + } + 'Get-ContractInfo' { + $rows = @($data) | ForEach-Object { + [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Currency = $_.Currency; Status = $_.AccountStatus } + } + $cols = @('Account', 'Agreement', 'Type', 'Currency', 'Status') + } + 'Get-MaccCommitment' { + if (-not $data.Applicable) { + Write-Host " $($data.Reason)" -ForegroundColor DarkGray + } + elseif ($data.HasMacc -and @($data.Commitments).Count -gt 0) { + $rows = @($data.Commitments) | ForEach-Object { + [PSCustomObject]@{ + Account = $_.BillingAccount + Commitment = '{0:C0}' -f [double]$_.Commitment + Consumed = '{0:C0}' -f [double]$_.Consumed + Remaining = '{0:C0}' -f [double]$_.Remaining + PctUsed = "$($_.PctUsed)%" + Status = $_.Status + Expires = $_.ExpirationDate + } + } + $cols = @('Account', 'Commitment', 'Consumed', 'Remaining', 'PctUsed', 'Status', 'Expires') + } + else { + Write-Host " $($data.Reason)" -ForegroundColor DarkGray + } + } + 'Get-OptimizationAdvice' { + if ($data.EstimatedAnnualSavings) { + Write-ColorizedLine -Text " Est. annual savings: `$$($data.EstimatedAnnualSavings) | $($data.TotalCount) recommendations" -DefaultColor 'White' + } + $rows = $data.Recommendations | Sort-Object { if ($_.AnnualSavings) { [double]$_.AnnualSavings } else { 0 } } -Descending | Select-Object -First 15 | ForEach-Object { + [PSCustomObject]@{ + Category = $_.Category + Impact = $_.Impact + Resource = $_.ResourceName + Problem = ($_.Problem -replace '(.{60}).+', '$1...') + Savings = if ($_.AnnualSavings) { '{0:C0}/yr' -f [double]$_.AnnualSavings } else { '-' } + } + } + $cols = @('Category', 'Impact', 'Resource', 'Problem', 'Savings') + if ($data.TotalCount -gt 15) { + Write-Host " (showing top 15 of $($data.TotalCount) by savings)" -ForegroundColor DarkGray + } + } + 'Get-CarbonMetrics' { + $arrow = if ($data.ChangeValueKg -gt 0) { 'up' } elseif ($data.ChangeValueKg -lt 0) { 'down' } else { 'flat' } + Write-ColorizedLine -Text " Latest month ($($data.LatestMonth)): $($data.TotalEmissionsKg) $($data.Unit) | MoM $arrow $($data.ChangeRatio)%" -DefaultColor 'White' + $rows = $data.BySubscription | Select-Object -First 15 | ForEach-Object { + [PSCustomObject]@{ + Subscription = $_.Subscription + Emissions = "$($_.EmissionsKg) kg" + } + } + $cols = @('Subscription', 'Emissions') + } + 'Get-LegacyResources' { + Write-ColorizedLine -Text " $($data.TotalCount) legacy/retiring resources found" -DefaultColor 'White' + $rows = $data.LegacyResources | Select-Object -First 20 | ForEach-Object { + [PSCustomObject]@{ + Category = $_.Category + Resource = $_.ResourceName + Detail = ($_.Detail -replace '(.{55}).+', '$1...') + Impact = $_.Impact + } + } + $cols = @('Category', 'Resource', 'Detail', 'Impact') + } + 'Get-UnitEconomics' { + Write-ColorizedLine -Text " Compute: $($data.Currency) $($data.ComputeCost) ($($data.ComputeSharePct)%) over $($data.VmCount) VMs / $($data.TotalVCpu) vCPU / $($data.TotalMemoryGb) GB RAM" -DefaultColor 'White' + Write-ColorizedLine -Text " Storage: $($data.Currency) $($data.StorageCost) ($($data.StorageSharePct)%) over $($data.TotalStorageGb) GB ($($data.DiskGb) GB disk + $($data.BlobFileGb) GB blob/file)" -DefaultColor 'White' + if ($data.Note) { Write-Host " $($data.Note)" -ForegroundColor DarkGray } + $rows = @( + [PSCustomObject]@{ Metric = 'Cost per vCPU'; Value = "$($data.Currency) $($data.CostPerVCpu)" } + [PSCustomObject]@{ Metric = 'Cost per GB RAM'; Value = "$($data.Currency) $($data.CostPerGbRam)" } + [PSCustomObject]@{ Metric = 'Cost per VM'; Value = "$($data.Currency) $($data.CostPerVm)" } + [PSCustomObject]@{ Metric = 'Cost per GB stored'; Value = "$($data.Currency) $($data.CostPerGb)" } + ) + $cols = @('Metric', 'Value') + } + 'Get-AIWorkloadMetrics' { + if (-not $data.HasData) { + Write-Host " No AI workloads detected — AI KPIs skipped." -ForegroundColor Green + } + else { + $fp = $data.AIFootprint + Write-ColorizedLine -Text " AI footprint — OpenAI/AIServices: $($fp.OpenAIAccounts + $fp.AIServices) ML workspaces: $($fp.MLWorkspaces) AI Search: $($fp.SearchServices) GPU VMs: $($fp.GpuVmCount)" -DefaultColor 'White' + Write-ColorizedLine -Text " Tokens (MTD): $($data.TotalTokens) total ($($data.TotalPromptTokens) in / $($data.TotalGeneratedTokens) out) over $($data.TotalRequests) requests" -DefaultColor 'White' + Write-ColorizedLine -Text " AI spend (MTD): $($data.Currency) $($data.TotalAICost) | $($data.Currency) $($data.CostPer1KTokens)/1K tokens | $($data.Currency) $($data.CostPerRequest)/request" -DefaultColor 'White' + if ($data.Note) { Write-Host " $($data.Note)" -ForegroundColor DarkGray } + if ($data.ByModel -and @($data.ByModel).Count -gt 0) { + $rows = $data.ByModel + $cols = @('Deployment', 'PromptTokens', 'GeneratedTokens', 'TotalTokens', 'PctOfTokens') + } + elseif ($data.ByAccount -and @($data.ByAccount).Count -gt 0) { + $rows = $data.ByAccount + $cols = @('Name', 'Tokens', 'Requests', 'Cost', 'CostPer1KTokens') + } + } + } + default { + # Fallback: try to display as-is with first 4 properties + $items = @($data) + $sample = $items[0] + if ($sample.PSObject) { + $cols = $sample.PSObject.Properties.Name | Select-Object -First 4 + $rows = $items + } + } + } + + # Render the table + if ($rows -and @($rows).Count -gt 0) { + $validCols = $cols | Where-Object { $_ } + if ($validCols) { + # Budget Status: color each row by risk level + if ($mod.Fn -eq 'Get-BudgetStatus') { + $budgetRows = @($rows) + # Render header manually + $headerStr = @($budgetRows) | Select-Object $validCols | Format-Table -AutoSize | Out-String | + ForEach-Object { $_.TrimEnd() -split "`n" | Where-Object { $_.Trim() } } + if ($headerStr.Count -ge 2) { + Write-Host " $($headerStr[0])" -ForegroundColor Cyan + Write-Host " $($headerStr[1])" -ForegroundColor DarkCyan + } + # Render each data row with risk-based color + for ($ri = 2; $ri -lt $headerStr.Count; $ri++) { + $budgetLine = $headerStr[$ri] + $matchedBudget = $null + if ($ri - 2 -lt $budgetRows.Count) { $matchedBudget = $budgetRows[$ri - 2] } + $riskVal = if ($matchedBudget -and $matchedBudget.Risk) { $matchedBudget.Risk } else { '' } + $rowColor = switch ($riskVal) { + 'Over Budget' { 'Red' } + 'Forecast Over' { 'Yellow' } + 'At Risk' { 'Yellow' } + 'Watch' { 'DarkYellow' } + default { 'Green' } + } + Write-ColorizedLine -Text " $budgetLine" -DefaultColor $rowColor + } + } + else { + $tableLines = @($rows) | Select-Object $validCols | Format-Table -AutoSize | Out-String | + ForEach-Object { $_.TrimEnd() -split "`n" | Where-Object { $_.Trim() } } + $headerDone = $false + foreach ($line in $tableLines) { + if (-not $headerDone) { + # First two lines are header + separator + if ($line -match '^[\s\-]+$') { + Write-Host " $line" -ForegroundColor DarkCyan + $headerDone = $true + } + else { + Write-Host " $line" -ForegroundColor Cyan + } + } + else { + Write-ColorizedLine -Text " $line" -DefaultColor 'White' + } + } + } + } + } + elseif (-not $rows) { + # Module used inline Write-Host (like SavingsRealized) — no table needed + } + else { + Write-Host " (no findings)" -ForegroundColor DarkGray + } + + # -- FinOps KPI Insights --------------------------------------- + # Map this scan's result to the FinOps Foundation KPIs it informs, + # with a computed value where the data allows. Reuses the same + # catalog + compute path as the MCP server (parity). + if (Get-Command Get-KpiInsightsForResult -ErrorAction SilentlyContinue) { + $kpiInsights = @() + try { $kpiInsights = @(Get-KpiInsightsForResult -FunctionName $mod.Fn -Output $data) } catch { $kpiInsights = @() } + if ($kpiInsights.Count -gt 0) { + Write-Host "" + Write-Host " FinOps KPIs:" -ForegroundColor Cyan + foreach ($kpi in $kpiInsights) { + if ($kpi.status -eq 'computed' -and $kpi.yourValue) { + Write-Host " - $($kpi.kpiName): " -ForegroundColor White -NoNewline + Write-Host "$($kpi.yourValue)" -ForegroundColor Green -NoNewline + Write-Host " [$($kpi.domain)]" -ForegroundColor DarkGray + } + else { + Write-Host " - $($kpi.kpiName) " -ForegroundColor DarkGray -NoNewline + Write-Host "(informational, $($kpi.domain))" -ForegroundColor DarkGray + } + } + } + } + + # -- Contextual Guidance --------------------------------------- + # Severity: Red = address immediately, Yellow = needs attention, Green = doing well + $guidanceItems = @() + switch ($mod.Fn) { + 'Get-OrphanedResources' { + $orphanCount = if ($data.Orphans) { @($data.Orphans).Count } else { 0 } + if ($orphanCount -gt 10) { + $categories = @($data.Orphans | ForEach-Object { $_.Category } | Sort-Object -Unique) -join ', ' + $guidanceItems = @( + @{ Severity = 'Red'; Message = "$orphanCount orphaned resources found ($categories). These generate cost with zero value." } + @{ Severity = 'Red'; Message = "FinOps Principle: Eliminate waste before optimizing. Orphaned resources are the easiest wins." } + @{ Severity = 'Yellow'; Message = "Set up Azure Policy to audit unattached disks, NICs, and public IPs to prevent future orphans." } + @{ Severity = 'Yellow'; Message = "Build a monthly cleanup cadence — orphans accumulate fast as teams scale up and down."; Docs = 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations' } + ) + } + elseif ($orphanCount -gt 0) { + $categories = @($data.Orphans | ForEach-Object { $_.Category } | Sort-Object -Unique) -join ', ' + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$orphanCount orphaned resources found ($categories). Review and delete to reclaim spend." } + @{ Severity = 'Yellow'; Message = "Use Azure Policy to audit unattached disks and NICs going forward."; Docs = 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "No orphaned resources. Environment is clean — good operational hygiene." } + ) + } + } + 'Get-IdleVMs' { + $idleCount = if ($data.IdleVMs) { @($data.IdleVMs).Count } else { 0 } + $scanned = if ($data.ScannedVMs) { $data.ScannedVMs } else { 0 } + if ($idleCount -gt 5) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "$idleCount of $scanned VMs are idle or underutilized. This is likely significant wasted spend." } + @{ Severity = 'Red'; Message = "FinOps Action: Check Azure Advisor for right-size recommendations before deleting — some may just need a smaller SKU." } + @{ Severity = 'Yellow'; Message = "For dev/test workloads, implement auto-shutdown schedules (saves 50-70% on non-production VMs)." } + @{ Severity = 'Yellow'; Message = "Consider Azure Spot VMs for fault-tolerant workloads — up to 90% discount vs. pay-as-you-go."; Docs = 'https://learn.microsoft.com/azure/virtual-machines/spot-vms' } + ) + } + elseif ($idleCount -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$idleCount idle VMs detected. Right-size or deallocate to reduce spend." } + @{ Severity = 'Yellow'; Message = "Check Advisor for SKU recommendations. Auto-shutdown schedules help for dev/test."; Docs = 'https://learn.microsoft.com/azure/advisor/advisor-cost-recommendations#optimize-virtual-machine-spend' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "All $scanned running VMs are actively utilized. Compute spend looks healthy." } + ) + } + } + 'Get-StorageTierAdvice' { + $recoCount = if ($data.Recommendations) { @($data.Recommendations).Count } else { 0 } + if ($recoCount -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$recoCount storage accounts can be moved to a cheaper tier (Cool or Cold saves 50-75%)." } + @{ Severity = 'Yellow'; Message = "FinOps Principle: Match storage tier to access patterns. Most data is written once and rarely read." } + @{ Severity = 'Yellow'; Message = "Enable lifecycle management policies to auto-tier blobs based on last access time — set it and forget it."; Docs = 'https://learn.microsoft.com/azure/storage/blobs/lifecycle-management-overview' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "All storage accounts are appropriately tiered. Good data lifecycle management." } + ) + } + } + 'Get-AHBOpportunities' { + $ahbCount = if ($rows) { @($rows).Count } else { 0 } + if ($ahbCount -gt 5) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "$ahbCount resources eligible for Azure Hybrid Benefit — up to 40% savings on Windows, 55% on SQL licensing." } + @{ Severity = 'Red'; Message = "FinOps Action: AHB is one of the highest-impact, lowest-effort optimizations. Apply to all eligible VMs and SQL resources." } + @{ Severity = 'Yellow'; Message = "Requires Software Assurance or qualifying subscription licenses. Check with your licensing team."; Docs = 'https://learn.microsoft.com/azure/virtual-machines/windows/hybrid-use-benefit-licensing' } + ) + } + elseif ($ahbCount -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$ahbCount resources can use Azure Hybrid Benefit (up to 40% Windows / 55% SQL savings)." } + @{ Severity = 'Yellow'; Message = "Requires Software Assurance. Low effort to apply — high cost impact."; Docs = 'https://learn.microsoft.com/azure/virtual-machines/windows/hybrid-use-benefit-licensing' } + ) + } + } + 'Get-TagInventory' { + $coverage = if ($data.TagCoverage) { $data.TagCoverage } else { 0 } + if ($coverage -lt 30) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Tag coverage is critically low at $coverage%." } + @{ Severity = 'Red'; Message = "FinOps Foundation: Tags are the #1 requirement for cost allocation. Without tags, you cannot do chargeback, showback, or unit economics." } + @{ Severity = 'Red'; Message = "Start with these 5 essential tags: CostCenter, Environment, Owner, Application, Department." } + @{ Severity = 'Yellow'; Message = "Use Azure Policy 'Require a tag and its value' to enforce tagging at deployment time." } + @{ Severity = 'Yellow'; Message = "Use 'Inherit a tag from the resource group' policy to auto-tag existing resources."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' } + ) + } + elseif ($coverage -lt 50) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Tag coverage at $coverage% — below the minimum for reliable cost allocation." } + @{ Severity = 'Yellow'; Message = "FinOps requires 80%+ tag coverage for meaningful chargeback. Prioritize tagging high-cost resources first." } + @{ Severity = 'Yellow'; Message = "Essential tags: CostCenter, Environment, Owner, Application, Department." } + @{ Severity = 'Yellow'; Message = "Deploy tag inheritance policies to propagate subscription/RG tags to child resources."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' } + ) + } + elseif ($coverage -lt 80) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Tag coverage at $coverage% — good progress, but target 80%+ for reliable cost allocation." } + @{ Severity = 'Yellow'; Message = "Focus on the highest-cost untagged resources. Use Cost Management views to find them." } + @{ Severity = 'Yellow'; Message = "Enable tag inheritance policies to auto-apply subscription/RG tags to new resources." } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "Tag coverage at $coverage% — strong tagging discipline." } + @{ Severity = 'Green'; Message = "Enable tag-based cost allocation in Cost Management to leverage your tags for chargeback." } + @{ Severity = 'Green'; Message = "Consider adding a 'Criticality' tag for incident response prioritization."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' } + ) + } + } + 'Get-CostByTag' { + if ($data.CostByTag -and $data.CostByTag.Count -gt 0) { + # Only measure untagged spend against CAF allocation tags + # (CostCenter, Customer, Project, Environment, ...), not + # identity/marker tags like FinOps or cm-resource-parent + # that blanket resources and skew the figure. Same tag set + # the FinOps KPI uses, so the guidance and the KPI agree. + $allocTags = if (Get-Command Get-CafAllocationTag -ErrorAction SilentlyContinue) { + Get-CafAllocationTag + } + else { + @('CostCenter', 'Customer', 'Project', 'Environment', 'Application', + 'Owner', 'BusinessUnit', 'Department', 'Team', 'Service', 'WorkloadName') + } + # Pick the allocation tag with the largest untagged cost + # (the biggest allocation gap). Each resource appears as + # "(untagged)" under every tag it lacks, so taking the max + # across tags avoids summing the same resource repeatedly. + $maxUntaggedCost = 0 + $maxUntaggedTag = '' + foreach ($tag in $data.CostByTag.GetEnumerator()) { + if ($allocTags -notcontains $tag.Key) { continue } + foreach ($v in $tag.Value) { + if ($v.TagValue -eq '(untagged)' -and [double]$v.Cost -gt $maxUntaggedCost) { + $maxUntaggedCost = [double]$v.Cost + $maxUntaggedTag = $tag.Key + } + } + } + if (-not $maxUntaggedTag) { + # No CAF allocation tag present to measure against + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "No CAF allocation tag (CostCenter, Customer, Project, Environment, Owner, ...) is in use, so spend cannot be attributed. Add an allocation tag and deploy inheritance to make cost traceable." } + ) + } + elseif ($maxUntaggedCost -gt 1000) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Untagged spend: $("{0:C0}" -f $maxUntaggedCost) not allocated by '$maxUntaggedTag'. This cost cannot be attributed to any team, project, or budget." } + @{ Severity = 'Red'; Message = "FinOps Impact: Untagged spend creates 'shadow IT' — no one owns it, no one optimizes it." } + @{ Severity = 'Yellow'; Message = "Use Cost Management tag views to identify the highest-cost resources missing '$maxUntaggedTag' and tag them first." } + ) + } + elseif ($maxUntaggedCost -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Some untagged spend detected: $("{0:C0}" -f $maxUntaggedCost) not allocated by '$maxUntaggedTag'. Tag remaining resources for full cost traceability." } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "All scanned cost is tagged with allocation tags. Cost allocation is fully traceable — enables chargeback and showback." } + ) + } + } + elseif ($data.NoTagsFound) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "No tags exist to analyze cost against. Cost allocation is impossible without tags." } + @{ Severity = 'Red'; Message = "FinOps Foundation: Start with CostCenter, Environment, and Owner tags. These 3 enable basic chargeback/showback." } + @{ Severity = 'Yellow'; Message = "Run Tag Inventory first, then come back to Cost by Tag to see the financial impact."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' } + ) + } + } + 'Get-CostTrend' { + if ($data.Months -and @($data.Months).Count -ge 2) { + $sorted = @($data.Months) | Sort-Object Month -Descending | Select-Object -First 2 + $current = [double]$sorted[0].Cost + $previous = [double]$sorted[1].Cost + if ($previous -gt 0) { + $change = [math]::Round((($current - $previous) / $previous) * 100, 1) + if ($change -gt 20) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Cost spiked $change% month-over-month. Investigate immediately — this is abnormal growth." } + @{ Severity = 'Red'; Message = "FinOps Action: Check for new deployments, usage spikes, or runaway auto-scale." } + @{ Severity = 'Yellow'; Message = "Set up Cost Management budget alerts at 80%, 90%, 100% to catch spikes early."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/cost-mgt-alerts-monitor-usage-spending' } + ) + } + elseif ($change -gt 5) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Cost increased $change% MoM. Moderate growth — review new resources deployed this period." } + @{ Severity = 'Yellow'; Message = "FinOps Practice: Establish a monthly cost review cadence to catch trends before they become problems." } + ) + } + elseif ($change -lt -5) { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "Cost decreased $([math]::Abs($change))% MoM. Optimization efforts are working." } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "Cost trend is stable ($change% change). Good cost discipline and predictable spend." } + ) + } + } + } + } + 'Get-ReservationAdvice' { + if ($data.AdvisorRecommendations -and @($data.AdvisorRecommendations).Count -gt 0) { + $totalSavings = if ($data.EstimatedAnnualSavings) { $data.EstimatedAnnualSavings } else { 0 } + if ($totalSavings -gt 10000) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Significant reservation savings available: $("{0:C0}" -f $totalSavings)/year." } + @{ Severity = 'Red'; Message = "FinOps Principle: Commitment-based discounts (RIs, Savings Plans) are the single largest cost lever — typically 30-60% savings." } + @{ Severity = 'Yellow'; Message = "Start with 1-year terms for flexibility. Use shared scope to maximize utilization across subscriptions." } + @{ Severity = 'Yellow'; Message = "Review 14-day usage trends before purchasing to ensure steady-state workloads."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Reservation savings available: $("{0:C0}" -f $totalSavings)/year. Consider purchasing for steady-state workloads." } + @{ Severity = 'Yellow'; Message = "Start with 1-year terms. Use shared scope for best utilization."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' } + ) + } + } + else { + if ($data.AccessDenied) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Access denied reading Advisor / reservation recommendations — results are blocked, not empty." } + @{ Severity = 'Yellow'; Message = "Required role: $($permissionInfo['Get-ReservationAdvice'].Role) at $($permissionInfo['Get-ReservationAdvice'].Scope) scope. Ask a billing/subscription admin to assign it, then re-scan."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "No reservation recommendations. Current commitment coverage appears sufficient." } + ) + } + } + } + 'Get-CommitmentUtilization' { + if ($data.HasData) { + $riUtil = if ($data.RIAvgUtilization) { [double]$data.RIAvgUtilization } else { 100 } + $spUtil = if ($data.SPAvgUtilization) { [double]$data.SPAvgUtilization } else { 100 } + $lowUtil = ($riUtil -lt 80) -or ($spUtil -lt 80) + $medUtil = ($riUtil -lt 95) -or ($spUtil -lt 95) + if ($lowUtil) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Commitment utilization below 80%. You may be paying more than on-demand pricing." } + @{ Severity = 'Red'; Message = "FinOps Action: Review scope and SKU alignment. Exchange underused RIs for better-fitting ones." } + @{ Severity = 'Yellow'; Message = "Target 95%+ utilization. Consider switching unused RIs to Savings Plans for more flexibility."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/manage-reserved-vm-instance' } + ) + } + elseif ($medUtil) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Commitment utilization at RI: $($data.RIAvgUtilization)%, SP: $($data.SPAvgUtilization)%. Room to improve." } + @{ Severity = 'Yellow'; Message = "Review scope settings — broadening scope can improve utilization across subscriptions." } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "Commitment utilization is excellent (RI: $($data.RIAvgUtilization)%, SP: $($data.SPAvgUtilization)%). Maximum discount realized." } + ) + } + } + elseif ($data.AccessDenied) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Access denied reading reservation / savings plan utilization — results are blocked, not empty." } + @{ Severity = 'Yellow'; Message = "Required role: $($permissionInfo['Get-CommitmentUtilization'].Role) at $($permissionInfo['Get-CommitmentUtilization'].Scope) scope. Ask a billing/subscription admin to assign it, then re-scan."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/manage-reserved-vm-instance' } + ) + } + } + 'Get-SavingsRealized' { + if ($data.TotalMonthly -and $data.TotalMonthly -gt 0) { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "Realizing $($data.TotalMonthly.ToString('C0'))/month ($($data.TotalAnnual.ToString('C0'))/year) in commitment discounts." } + @{ Severity = 'Green'; Message = "FinOps Maturity: Active savings tracking shows Run-level FinOps maturity. Keep reviewing quarterly." } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "No savings from commitments detected. Evaluate RIs and Savings Plans for steady-state workloads." } + @{ Severity = 'Yellow'; Message = "FinOps Practice: Commitment discounts are the #1 cost optimization lever (30-60% savings)."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' } + ) + } + } + 'Get-BudgetStatus' { + $atRisk = if ($data.AtRiskCount) { $data.AtRiskCount } else { 0 } + $over = if ($data.OverBudgetCount) { $data.OverBudgetCount } else { 0 } + $bCoverage = if ($data.BudgetCoverage) { $data.BudgetCoverage } else { 0 } + if ($over -gt 0) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "$over budget(s) exceeded. Immediate review needed — spending is above approved levels." } + @{ Severity = 'Red'; Message = "FinOps Action: Identify the cause (new deployments, usage spike, missing commitment) and remediate." } + @{ Severity = 'Yellow'; Message = "Add action groups with alerts at 80%, 90%, 100% thresholds to catch overruns earlier next period."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' } + ) + } + elseif ($atRisk -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$atRisk budget(s) at risk of overrun. Review forecasted spend vs. remaining budget." } + @{ Severity = 'Yellow'; Message = "FinOps Practice: Proactive budget monitoring prevents end-of-period surprises. Consider cost reduction now." } + ) + } + elseif ($bCoverage -lt 50) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Budget coverage is only $bCoverage%. Most subscriptions have no budget — spending is untracked." } + @{ Severity = 'Red'; Message = "FinOps Foundation: Budgets are the starting point for cost accountability. Without them, there's no alerting, no forecasting, no governance." } + @{ Severity = 'Yellow'; Message = "Create a budget for every subscription. Start with last month's actual spend + 10% buffer."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "Budgets are healthy. All within thresholds with $bCoverage% coverage. Good financial governance." } + ) + } + } + 'Get-AnomalyAlerts' { + $activeAlerts = if ($data.ActiveAlertCount) { $data.ActiveAlertCount } else { 0 } + $rules = if ($data.ConfiguredRuleCount) { $data.ConfiguredRuleCount } else { 0 } + if ($activeAlerts -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$activeAlerts active anomaly alerts. Review to determine if they indicate unexpected spend patterns." } + @{ Severity = 'Yellow'; Message = "FinOps Practice: Cost anomaly detection is an early warning system. Investigate anomalies promptly." } + ) + } + elseif ($rules -eq 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "No anomaly detection rules configured. Set up Cost Management anomaly alerts for early spend warnings." } + @{ Severity = 'Yellow'; Message = "Anomaly detection is built into Azure Cost Management at no extra cost."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/understand/analyze-unexpected-charges' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "Anomaly detection is configured with $rules rule(s) and no active alerts. Monitoring is working." } + ) + } + } + 'Get-PolicyInventory' { + $compliance = if ($data.CompliancePct) { $data.CompliancePct } else { 0 } + $nonCompliant = if ($data.TotalNonCompliant) { $data.TotalNonCompliant } else { 0 } + $hasComplianceData = if ($null -ne $data.HasComplianceData) { $data.HasComplianceData } else { (($data.TotalCompliant + $data.TotalNonCompliant) -gt 0) } + if (-not $hasComplianceData) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "No policy compliance data available. Resource Graph 'policystates' returned no rows - Policy Insights may not have evaluated resources yet, or the identity lacks Policy Insights read access." } + @{ Severity = 'Yellow'; Message = "Assignments detected: $($data.AssignmentCount). Compliance percentages require evaluated policy states."; Docs = 'https://learn.microsoft.com/azure/governance/policy/how-to/get-compliance-data' } + ) + } + elseif ($nonCompliant -gt 20) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "$nonCompliant non-compliant resources ($compliance% compliance). Governance gaps are significant." } + @{ Severity = 'Yellow'; Message = "FinOps Governance: Use 'Deny' for critical policies (e.g., required tags). Use 'Audit' first during rollout." } + @{ Severity = 'Yellow'; Message = "Create remediation tasks for existing non-compliant resources."; Docs = 'https://learn.microsoft.com/azure/governance/policy/how-to/remediate-resources' } + ) + } + elseif ($nonCompliant -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$nonCompliant non-compliant resources ($compliance% compliance). Review and remediate or create exemptions." } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "Full policy compliance ($compliance%). Strong governance posture." } + ) + } + } + 'Get-PolicyRecommendations' { + if ($data.Analysis -and @($data.Analysis).Count -gt 0) { + $missing = @($data.Analysis | Where-Object { $_.Status -eq 'Missing' }) + if ($missing.Count -gt 5) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "$($missing.Count) recommended FinOps policies are not assigned. Governance foundation is incomplete." } + @{ Severity = 'Yellow'; Message = "Priority policies: tag enforcement, allowed VM SKUs, allowed locations, resource naming." } + @{ Severity = 'Yellow'; Message = "FinOps Practice: Policy-driven governance prevents cost waste at deployment time — cheaper than cleanup."; Docs = 'https://learn.microsoft.com/azure/governance/policy/samples/built-in-policies' } + ) + } + elseif ($missing.Count -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$($missing.Count) recommended policies not yet assigned. Review and deploy as needed." } + @{ Severity = 'Yellow'; Message = "Start with: tag enforcement, allowed locations, and allowed VM SKUs."; Docs = 'https://learn.microsoft.com/azure/governance/policy/samples/built-in-policies' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "All recommended FinOps policies are assigned. Strong governance foundation." } + ) + } + } + } + 'Get-OptimizationAdvice' { + if ($data.Recommendations -and @($data.Recommendations).Count -gt 0) { + $highImpact = @($data.Recommendations | Where-Object { $_.Impact -eq 'High' }) + if ($highImpact.Count -gt 5) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "$($highImpact.Count) high-impact Advisor recommendations. Significant savings available." } + @{ Severity = 'Red'; Message = "FinOps Action: Start with high-impact items — they offer the largest return for effort." } + @{ Severity = 'Yellow'; Message = "Dismiss recommendations you've evaluated to keep the list actionable. Review monthly." } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$(@($data.Recommendations).Count) Advisor recommendations ($($highImpact.Count) high-impact). Review and prioritize." } + @{ Severity = 'Yellow'; Message = "Dismiss evaluated items to keep the list clean. Azure Advisor refreshes daily." } + ) + } + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "No Advisor cost recommendations. Environment is well optimized." } + ) + } + } + 'Get-CostData' { + if ($data -is [hashtable] -and $data.Count -gt 0) { + $totalActual = 0 + foreach ($sub in $data.GetEnumerator()) { $totalActual += [double]$sub.Value.Actual } + $guidanceItems = @( + @{ Severity = 'Green'; Message = "Current period spend: $("{0:C0}" -f $totalActual) across $($data.Count) subscription(s)." } + @{ Severity = 'Green'; Message = "FinOps Practice: Review actual vs. forecast regularly. Pair this data with Budget Status to track variance." } + ) + } + } + 'Get-ResourceCosts' { + $topCount = if ($data) { @($data).Count } else { 0 } + if ($topCount -gt 0) { + $topCost = [double](@($data) | Sort-Object { [double]$_.Actual } -Descending | Select-Object -First 1).Actual + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Top resource costs $("{0:C2}" -f $topCost). Focus optimization on the largest cost drivers." } + @{ Severity = 'Yellow'; Message = "FinOps Practice: The top 20% of resources typically drive 80% of spend. Optimize these first." } + ) + } + } + 'Get-AIWorkloadMetrics' { + if (-not $data.HasData) { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "No AI/LLM workloads detected. No AI-specific cost optimization needed right now." } + ) + } + elseif ($data.CostPer1KTokens -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Effective AI rate: $($data.Currency) $($data.CostPer1KTokens) per 1K tokens across $($data.TotalTokens) tokens (MTD). Track this as your core AI unit-economics KPI." } + @{ Severity = 'Yellow'; Message = "Compare model deployments above — shift high-volume traffic to cheaper SKUs (e.g., gpt-4o-mini) and reserve premium models for tasks that need them." } + @{ Severity = 'Yellow'; Message = "For steady, predictable token volume, evaluate Provisioned Throughput Units (PTUs) — they can beat pay-as-you-go at scale."; Docs = 'https://learn.microsoft.com/azure/ai-services/openai/concepts/provisioned-throughput' } + ) + } + elseif ($data.TotalTokens -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Token usage detected ($($data.TotalTokens) MTD) but cost could not be mapped. Grant Cost Management Reader to compute cost per 1K tokens." } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "AI footprint present but no token-metered usage this month. Confirm whether idle AI resources can be deprovisioned to avoid baseline cost." } + ) + } + } + } + + # Render guidance with severity colors + if ($guidanceItems.Count -gt 0) { + Write-Host "" + # Determine overall severity for the header + $hasCritical = $guidanceItems | Where-Object { $_.Severity -eq 'Red' } + $hasWarning = $guidanceItems | Where-Object { $_.Severity -eq 'Yellow' } + $headerColor = if ($hasCritical) { 'Red' } elseif ($hasWarning) { 'Yellow' } else { 'Green' } + $headerIcon = switch ($headerColor) { 'Red' { '[!]' } 'Yellow' { '[~]' } 'Green' { '[+]' } } + Write-Host " $headerIcon GUIDANCE" -ForegroundColor $headerColor + + foreach ($item in $guidanceItems) { + $color = switch ($item.Severity) { 'Red' { 'Red' } 'Yellow' { 'DarkYellow' } 'Green' { 'Green' } default { 'Gray' } } + $icon = switch ($item.Severity) { 'Red' { '!' } 'Yellow' { '~' } 'Green' { '+' } default { '-' } } + Write-Host " $icon $($item.Message)" -ForegroundColor $color + if ($item.Docs) { + Write-Host " $($item.Docs)" -ForegroundColor DarkCyan + } + } + } + + Write-Host "" + } + + # -- Export option ------------------------------------------------- + if ($ExportPath) { + $exportDir = $ExportPath + } + else { + $defaultPath = Join-Path (Get-Location) 'FinOpsResults' + Write-Host " Export results? [E] Export [Enter] Skip" -ForegroundColor DarkGray + $eKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') + if ($eKey.Character -eq 'e' -or $eKey.Character -eq 'E') { + Write-Host "" + Write-Host " Path [$defaultPath]: " -ForegroundColor White -NoNewline + $exportDir = Read-Host + if (-not $exportDir -or $exportDir.Trim() -eq '') { + $exportDir = $defaultPath + } + } + else { + $exportDir = $null + } + } + + if ($exportDir -and $exportDir.Trim() -ne '') { + if (-not (Test-Path $exportDir)) { + New-Item -ItemType Directory -Path $exportDir -Force | Out-Null + } + + # -- CSV exports per module -- + foreach ($mod in ($Modules | Where-Object { $_.Selected })) { + $data = $Results[$mod.Fn] + if ($data -and @($data).Count -gt 0) { + $safeName = $mod.Fn -replace '[^a-zA-Z0-9\-]', '' + $csvPath = Join-Path $exportDir "$safeName.csv" + $data | Export-Csv -Path $csvPath -NoTypeInformation + } + } + + # -- HTML report -- + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $subList = if ($Subscriptions) { ($Subscriptions | ForEach-Object { if ($_.Name) { $_.Name } else { $_.Id } }) -join ', ' } else { 'N/A' } + $htmlSb = [System.Text.StringBuilder]::new() + [void]$htmlSb.Append(@" + + + + +FinOps Multitool Report — $timestamp + + + +

FinOps Multitool Report

+
Generated: $timestamp  |  Subscriptions: $([System.Net.WebUtility]::HtmlEncode($subList))
+"@) + + # Summary cards + $errorCount = ($Modules | Where-Object { $_.Selected } | Where-Object { $Results.ContainsKey("_error_$($_.Fn)") }).Count + [void]$htmlSb.Append('
') + [void]$htmlSb.Append("
Total Findings
$totalFindings
") + [void]$htmlSb.Append("
Scans Run
$(($Modules | Where-Object { $_.Selected }).Count)
") + if ($errorCount -gt 0) { + [void]$htmlSb.Append("
Errors
$errorCount
") + } + [void]$htmlSb.Append('
') + + # Per-module sections + foreach ($mod in ($Modules | Where-Object { $_.Selected })) { + $fn = $mod.Fn + $data = $Results[$fn] + $eName = [System.Net.WebUtility]::HtmlEncode($mod.Name) + [void]$htmlSb.Append("

$eName

") + + $errorKey = "_error_$fn" + if ($Results.ContainsKey($errorKey)) { + $eMsg = [System.Net.WebUtility]::HtmlEncode($Results[$errorKey]) + [void]$htmlSb.Append("
Error: $eMsg") + if ($permissionInfo.ContainsKey($fn)) { + $pi = $permissionInfo[$fn] + [void]$htmlSb.Append("
Required: $([System.Net.WebUtility]::HtmlEncode($pi.Role)) at $([System.Net.WebUtility]::HtmlEncode($pi.Scope)) scope ($([System.Net.WebUtility]::HtmlEncode($pi.API)))") + } + [void]$htmlSb.Append('
') + continue + } + + if (-not $data -or @($data).Count -eq 0) { + $noDataMsg = 'No data returned.' + if ($permissionInfo.ContainsKey($fn)) { + $noDataMsg += " $($permissionInfo[$fn].Reason)" + } + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode($noDataMsg))
") + continue + } + + # Render module-specific summaries + table + $htmlRows = $null + $htmlCols = $null + switch ($fn) { + 'Get-OrphanedResources' { + $htmlRows = $data.Orphans + $htmlCols = @('Category', 'ResourceName', 'ResourceGroup', 'Detail') + } + 'Get-IdleVMs' { + [void]$htmlSb.Append("

Scanned $($data.ScannedVMs) running VMs

") + $htmlRows = $data.IdleVMs + $htmlCols = @('VMName', 'ResourceGroup', 'VMSize', 'AvgCPU14d', 'Classification') + } + 'Get-StorageTierAdvice' { + [void]$htmlSb.Append("

$($data.TotalHotAccounts) Hot-tier accounts scanned

") + $htmlRows = $data.Recommendations + $htmlCols = @('StorageAccount', 'ResourceGroup', 'CurrentTier', 'CapacityGB', 'Recommendation') + } + 'Get-AHBOpportunities' { + $ahbRows = @() + if ($data.WindowsVMs) { $ahbRows += @($data.WindowsVMs) | ForEach-Object { $est = if ($_.estMonthlySavings) { '$' + ('{0:N0}' -f $_.estMonthlySavings) + '/mo' } else { 'n/a' }; [PSCustomObject]@{ Type = 'Windows VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.vmSize; License = $_.currentLicense; 'Est Savings' = $est } } } + if ($data.SQLVMs) { $ahbRows += @($data.SQLVMs) | ForEach-Object { [PSCustomObject]@{ Type = 'SQL VM'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sqlEdition; License = $_.currentLicense; 'Est Savings' = '-' } } } + if ($data.SQLDatabases) { $ahbRows += @($data.SQLDatabases) | ForEach-Object { [PSCustomObject]@{ Type = 'SQL DB'; Name = $_.name; ResourceGroup = $_.resourceGroup; Size = $_.sku; License = $_.currentLicense; 'Est Savings' = '-' } } } + $htmlRows = $ahbRows + $htmlCols = @('Type', 'Name', 'ResourceGroup', 'Size', 'License', 'Est Savings') + } + 'Get-TagInventory' { + [void]$htmlSb.Append("

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

") + if ($data.TagNames) { + $htmlRows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | Select-Object -First 15 | ForEach-Object { + [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; UniqueValues = @($_.Value.Values).Count } + } + $htmlCols = @('Tag', 'Resources', 'UniqueValues') + } + } + 'Get-CostData' { + if ($data -is [hashtable]) { + $htmlRows = $data.GetEnumerator() | ForEach-Object { + $sl = if ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] } else { $_.Key } + [PSCustomObject]@{ Subscription = $sl; Actual = '{0:C0}' -f [double]$_.Value.Actual; Forecast = '{0:C0}' -f [double]$_.Value.Forecast; Currency = $_.Value.Currency } + } + $htmlCols = @('Subscription', 'Actual', 'Forecast', 'Currency') + } + } + 'Get-ResourceCosts' { + $htmlRows = @($data) | Sort-Object { $_.Actual } -Descending | Select-Object -First 50 | ForEach-Object { + [PSCustomObject]@{ ResourceGroup = $_.ResourceGroup; ResourceType = ($_.ResourceType -split '/')[-1]; Cost = '{0:C2}' -f [double]$_.Actual } + } + $htmlCols = @('ResourceGroup', 'ResourceType', 'Cost') + } + 'Get-CostByTag' { + if ($data.CostByTag) { + $htmlRows = foreach ($tag in $data.CostByTag.GetEnumerator()) { + foreach ($v in $tag.Value) { + [PSCustomObject]@{ Tag = $tag.Key; Value = $v.TagValue; Cost = '{0:C0}' -f [double]$v.Cost } + } + } + $htmlCols = @('Tag', 'Value', 'Cost') + } + } + 'Get-CostTrend' { + if ($data.Months) { + $htmlRows = $data.Months | ForEach-Object { [PSCustomObject]@{ Month = $_.Month; Cost = '{0:C0}' -f [double]$_.Cost; Currency = $_.Currency } } + $htmlCols = @('Month', 'Cost', 'Currency') + } + } + 'Get-ReservationAdvice' { + if ($data.EstimatedAnnualSavings) { [void]$htmlSb.Append("

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

") } + $htmlRows = $data.AdvisorRecommendations | ForEach-Object { + [PSCustomObject]@{ Resource = ($_.ResourceName -split '/')[-1]; Type = ($_.ResourceType -split '/')[-1]; Term = $_.Term; Savings = '{0:C0}' -f [double]$_.AnnualSavings; Impact = $_.Impact } + } + $htmlCols = @('Resource', 'Type', 'Term', 'Savings', 'Impact') + } + 'Get-CommitmentUtilization' { + if ($data.HasData) { + [void]$htmlSb.Append("

RIs: $($data.RICount) (avg $($data.RIAvgUtilization)%)  |  Savings Plans: $($data.SPCount) (avg $($data.SPAvgUtilization)%)

") + $htmlRows = $data.UnderutilizedRIs | ForEach-Object { [PSCustomObject]@{ SKU = $_.SkuName; Kind = $_.Kind; AvgUtil = "$($_.AvgUtilization)%"; MinUtil = "$($_.MinUtilization)%" } } + $htmlCols = @('SKU', 'Kind', 'AvgUtil', 'MinUtil') + } + } + 'Get-SavingsRealized' { + [void]$htmlSb.Append("

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

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

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

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

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

") + $htmlRows = $data.TriggeredAlerts | Select-Object -First 10 | ForEach-Object { + $label = if ($_.AlertLabel) { $_.AlertLabel } else { $_.AlertName } + [PSCustomObject]@{ Alert = $label; Type = $_.AlertType; Status = $_.Status; Subscription = $_.Subscription } + } + $htmlCols = @('Alert', 'Type', 'Status', 'Subscription') + } + 'Get-OptimizationAdvice' { + if ($data.EstimatedAnnualSavings) { [void]$htmlSb.Append("

Est. annual savings: `$$($data.EstimatedAnnualSavings)  |  $($data.TotalCount) recommendations

") } + $htmlRows = $data.Recommendations | Sort-Object { if ($_.AnnualSavings) { [double]$_.AnnualSavings } else { 0 } } -Descending | Select-Object -First 25 | ForEach-Object { + [PSCustomObject]@{ Category = $_.Category; Impact = $_.Impact; Resource = $_.ResourceName; Problem = ($_.Problem -replace '(.{80}).+', '$1...'); Savings = if ($_.AnnualSavings) { '{0:C0}/yr' -f [double]$_.AnnualSavings } else { '-' } } + } + $htmlCols = @('Category', 'Impact', 'Resource', 'Problem', 'Savings') + } + 'Get-TagRecommendations' { + $htmlRows = $data.Analysis | ForEach-Object { [PSCustomObject]@{ Tag = $_.TagName; Status = $_.Status; Priority = $_.Priority; Pillar = $_.Pillar; Example = $_.Example } } + $htmlCols = @('Tag', 'Status', 'Priority', 'Pillar', 'Example') + } + 'Get-PolicyInventory' { + $htmlRows = $data.Assignments | ForEach-Object { [PSCustomObject]@{ Name = $_.AssignmentName; Effect = $_.Effect; Enforcement = $_.EnforcementMode; Scope = $_.Scope } } + $htmlCols = @('Name', 'Effect', 'Enforcement', 'Scope') + } + 'Get-PolicyRecommendations' { + $htmlRows = $data.Analysis | ForEach-Object { [PSCustomObject]@{ Policy = $_.DisplayName; Status = $_.Status; Category = $_.Category; Priority = $_.Priority; Effect = $_.DefaultEffect } } + $htmlCols = @('Policy', 'Status', 'Category', 'Priority', 'Effect') + } + 'Get-BillingStructure' { + $htmlRows = $data.BillingAccounts | ForEach-Object { [PSCustomObject]@{ Account = $_.DisplayName; Agreement = $_.AgreementType; Type = $_.AccountType; Status = $_.AccountStatus } } + $htmlCols = @('Account', 'Agreement', 'Type', 'Status') + } + 'Get-ContractInfo' { + $htmlRows = @($data) | ForEach-Object { [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Currency = $_.Currency; Status = $_.AccountStatus } } + $htmlCols = @('Account', 'Agreement', 'Type', 'Currency', 'Status') + } + } + + # Render HTML table + if ($htmlRows -and $htmlCols) { + [void]$htmlSb.Append('') + foreach ($c in $htmlCols) { [void]$htmlSb.Append("") } + [void]$htmlSb.Append('') + foreach ($r in $htmlRows) { + [void]$htmlSb.Append('') + foreach ($c in $htmlCols) { + $val = $r.$c + $enc = [System.Net.WebUtility]::HtmlEncode([string]$val) + # Colorize money values and risk/severity + if ($enc -match '^\$') { $enc = "$enc" } + if ($c -eq 'Risk' -and $r.PSObject.Properties['_riskClass']) { $enc = "$enc" } + if ($c -eq 'Impact') { + $impClass = switch ($val) { 'High' { 'severity-red' } 'Medium' { 'severity-yellow' } default { 'severity-green' } } + $enc = "$enc" + } + [void]$htmlSb.Append("") + } + [void]$htmlSb.Append('') + } + [void]$htmlSb.Append('
$([System.Net.WebUtility]::HtmlEncode($c))
$enc
') + } + + # Render guidance + # (Re-evaluate guidance items for HTML — reuse the same logic) + } + + [void]$htmlSb.Append('
Generated by FinOps Multitool — part of the FinOps Toolkit
') + [void]$htmlSb.Append('') + + $htmlPath = Join-Path $exportDir 'FinOpsReport.html' + $htmlSb.ToString() | Out-File -FilePath $htmlPath -Encoding utf8 + + # Summary text file + $summaryPath = Join-Path $exportDir 'ScanSummary.txt' + $summaryLines = @( + "FinOps Multitool Scan Summary" + "Generated: $timestamp" + "Subscriptions: $subList" + "Total findings: $totalFindings" + "" + ) + foreach ($mod in ($Modules | Where-Object { $_.Selected })) { + $count = if ($Results[$mod.Fn]) { @($Results[$mod.Fn]).Count } else { 0 } + $errorKey = "_error_$($mod.Fn)" + $status = if ($Results.ContainsKey($errorKey)) { "ERROR: $($Results[$errorKey])" } elseif ($count -eq 0) { "No data" } else { "$count findings" } + $summaryLines += "$($mod.Name): $status" + } + $summaryLines | Out-File -FilePath $summaryPath -Encoding utf8 + + Write-Host "" + Write-Host " Exported to: $exportDir" -ForegroundColor Green + $csvCount = (Get-ChildItem $exportDir -Filter '*.csv').Count + Write-Host " Files: $csvCount CSVs + FinOpsReport.html + ScanSummary.txt" -ForegroundColor DarkGray + } + + # Interactive drill-down + Write-Host "" + Write-Host " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray + Write-Host " Results are stored in `$FinOpsResults. Examples:" -ForegroundColor DarkGray + Write-Host ' $FinOpsResults["Get-OrphanedResources"] | Format-Table' -ForegroundColor DarkGray + Write-Host ' $FinOpsResults["Get-IdleVMs"] | Where-Object Impact -eq "High"' -ForegroundColor DarkGray + Write-Host "" + + return $Results + } + + # ===================================================================== + # MAIN FLOW + # ===================================================================== + Show-Banner + + # Step 1: Connect & pick subscription + $subs = Select-Subscription -PreselectedId $SubscriptionId + if (-not $subs) { + Write-Host " Cancelled." -ForegroundColor Yellow + return + } + + # Capture tenant ID from current context + $tenantId = (Get-AzContext).Tenant.Id + + # Step 2: Pick data source + $dataSource = Select-DataSource -TenantId $tenantId -Subscriptions $subs + + # If "Resource Graph only", disable cost modules + $costModuleFns = @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-CostTrend', + 'Get-SavingsRealized', 'Get-CommitmentUtilization', 'Get-ReservationAdvice', + 'Get-BudgetStatus', 'Get-AnomalyAlerts', 'Get-BillingStructure', 'Get-ContractInfo') + if ($dataSource.Source -eq 'GraphOnly') { + foreach ($mod in $scanModules) { + if ($mod.Fn -in $costModuleFns) { $mod.Selected = $false } + } + } + + # Show active data source + $sourceLabel = switch ($dataSource.Source) { + 'Hub' { "FinOps Hub ($($dataSource.HubStorage.name))" } + 'API' { 'Cost Management API (real-time)' } + 'GraphOnly' { 'Resource Graph only (no cost data)' } + } + $sourceColor = switch ($dataSource.Source) { 'Hub' { 'Green' } 'API' { 'Yellow' } 'GraphOnly' { 'DarkGray' } } + Write-Host "" + Write-Host " Data source: $sourceLabel" -ForegroundColor $sourceColor + Write-Host "" + + # Step 3: Pick scans + $finalModules = Select-ScanModules -Modules $scanModules + if (-not $finalModules) { + Write-Host " Cancelled." -ForegroundColor Yellow + return + } + + # Auto-enable dependencies + $selected = $finalModules | Where-Object { $_.Selected } + $selectedFns = $selected.Fn + $deps = @{ + 'Get-CostByTag' = @('Get-CostData', 'Get-TagInventory') + 'Get-TagRecommendations' = @('Get-TagInventory') + 'Get-PolicyRecommendations' = @('Get-PolicyInventory') + 'Get-BudgetStatus' = @('Get-CostData') + 'Get-BudgetHistory' = @('Get-BudgetStatus', 'Get-CostTrend') + } + foreach ($depEntry in $deps.GetEnumerator()) { + if ($depEntry.Key -in $selectedFns) { + foreach ($req in $depEntry.Value) { + if ($req -notin $selectedFns) { + $mod = $finalModules | Where-Object { $_.Fn -eq $req } + if ($mod) { + $mod.Selected = $true + Write-Host " Auto-enabled: $($mod.Name) (required by $($depEntry.Key -replace 'Get-',''))" -ForegroundColor DarkGray + } + } + } + } + } + + # Step 4: Run + $results = Invoke-SelectedScans -Modules $finalModules -Subscriptions $subs -TenantId $tenantId -DataSource $dataSource + + # Step 5: Summary + export + $global:FinOpsResults = Show-ResultsSummary -Results $results -Modules $finalModules -ExportPath $OutputPath -Subscriptions $subs + + Write-Host " Done. Results available in `$FinOpsResults" -ForegroundColor Green + Write-Host "" +} + +# Auto-invoke when run directly (not dot-sourced or imported as module) +if ($MyInvocation.InvocationName -ne '.') { + Invoke-FinOpsMultitool @PSBoundParameters +} diff --git a/src/powershell/Private/FinOpsMultitool/LICENSE b/src/powershell/Private/FinOpsMultitool/LICENSE new file mode 100644 index 000000000..dd0ab5585 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zac Larsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/powershell/Private/FinOpsMultitool/README.md b/src/powershell/Private/FinOpsMultitool/README.md new file mode 100644 index 000000000..6fdb4a8c2 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -0,0 +1,522 @@ +# FinOps Multitool — Terminal UI (TUI) + +Interactive terminal interface for running FinOps scans against Azure subscriptions. No GUI dependencies — works in any terminal on Windows, macOS, and Linux. + +## Quick Start + +```powershell +# From the FinOpsMultitool directory +Import-Module .\FinOpsMultitool.psm1 +Invoke-FinOpsMultitool +``` + +Or target a specific subscription: + +```powershell +Invoke-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' +``` + +## Requirements + +| Requirement | Details | +| --------------------- | --------------------------------------------------------------- | +| PowerShell | 5.1+ (Windows) or 7+ (cross-platform) | +| Az modules | `Az.Accounts`, `Az.Resources`, `Az.ResourceGraph`, `Az.Storage` | +| Azure RBAC | Reader + Cost Management Reader on target scope | +| FinOps Hub (optional) | Storage Blob Data Reader on Hub storage account | + +Install Az modules if needed: + +```powershell +Install-Module Az.Accounts, Az.Resources, Az.ResourceGraph, Az.Storage -Scope CurrentUser +``` + +## How It Works + +### 1. Authentication + +On launch, the TUI checks for an existing `Az.Accounts` session. If you're not logged in, it prompts you to run `Connect-AzAccount`. If your account has access to multiple Azure AD tenants, a tenant picker appears so you can select which tenant to scan. It then discovers all accessible subscriptions and lets you select which ones to scan. + +### 2. Data Source Selection + +If a FinOps Hub is detected in any of your subscriptions, you'll be asked to choose a data source: + +| Source | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| **FinOps Hub** | Reads cost data from the FinOps Hub. Faster, no API throttling. Tag and cost-by-tag scans are instant. | +| **Cost Management API** | Queries the Cost Management REST API in real-time. Slower but always current. Hub tag data is still used when available. | +| **Resource Graph only** | Skips all cost APIs. Only runs scans that use Azure Resource Graph (orphaned resources, idle VMs, etc). | + +When the **FinOps Hub** source is chosen, the tool prefers the hub's **Kusto database** (Azure Data Explorer / Fabric, or a local ftklocal emulator) and pushes aggregation into the engine, returning only summarized results. This is the scalable path for large customer datasets — it never loads the raw cost rows into PowerShell. See [FinOps Hub data paths](#finops-hub-data-paths) below. The storage-export reader remains as a small-dataset fallback. + +### 3. Scan Selection + +Arrow-key driven menu to toggle individual scans on/off. All scans are selected by default except Billing Structure. + +| Key | Action | +| --------- | ------------------ | +| `↑` / `↓` | Navigate scan list | +| `Space` | Toggle scan on/off | +| `A` | Select all | +| `N` | Deselect all | +| `Enter` | Run selected scans | +| `Q` | Quit | + +### 4. Scan Execution + +Selected scans run sequentially with a progress bar. When a FinOps Hub is available, tag-related scans (Tag Inventory, Cost by Tag) use pre-loaded Hub data instead of API calls — completing in under a second. + +### 5. Results + +Results display inline with formatted tables, severity-colored guidance, and permission diagnostics. + +**Guidance system** — After each scan result, contextual FinOps guidance appears with severity-based coloring: + +| Icon | Color | Meaning | +| ----- | ------ | ---------------------------------- | +| `[!]` | Red | Critical finding — action required | +| `[~]` | Yellow | Warning — improvement recommended | +| `[+]` | Green | Healthy — good practices confirmed | + +Guidance includes FinOps Foundation best practices, actionable next steps, and links to Microsoft Learn documentation. + +**Dollar colorization** — All dollar amounts in results are highlighted in green for quick scanning. Budget rows are colored by risk severity (red for over budget, yellow for at risk, green for on track). + +**Permission diagnostics** — When a scan returns no data, the TUI explains why: + +- **Access denied** (403/401) — Shows the exact error, required RBAC role, scope, and API +- **No data** — Explains whether the module requires specific resources (e.g., "Returns empty if no budgets are configured") + +An optional CSV/JSON export saves to the output path. + +## Required Permissions + +Each scan module requires specific Azure RBAC roles. The TUI will tell you which role is needed if a scan fails due to missing permissions. + +| Category | Scans | Required Role | Scope | +| ------------ | ------------------------------------------------------------ | ------------------------ | ------------------- | +| Optimization | Orphaned Resources, Idle VMs, Storage Tier Advice, AHB | Reader | Subscription | +| Governance | Tag Inventory, Tag Recommendations, Policy Inventory/Recs | Reader | Subscription | +| Cost | Cost Data, Resource Costs, Cost by Tag, Cost Trend | Cost Management Reader | Subscription or MG | +| Commitments | Reservation Advice, Commitment Utilization, Savings Realized | Cost Management Reader | Subscription | +| Monitoring | Budget Status, Anomaly Alerts | Cost Management Reader | Subscription | +| Advisor | Optimization Advice | Reader | Subscription | +| Account | Billing Structure, Contract Info | Billing Reader | Billing Account | +| Hub (opt.) | All scans via Hub data | Storage Blob Data Reader | Hub Storage Account | + +## Available Scans + +### Optimization (Resource Graph) + +| Scan | What it finds | +| ------------------- | --------------------------------------------------------------------- | +| Orphaned Resources | Unattached disks, NICs, public IPs, NSGs | +| Idle VMs | VMs with <5% CPU over 30 days | +| Storage Tier Advice | Blob storage that could move to cooler tiers | +| AHB Opportunities | Windows/SQL VMs not using Azure Hybrid Benefit | +| Legacy Resources | Legacy/retiring SKUs (v1 VM families, unmanaged disks, Basic IPs/LBs) | + +### Governance + +| Scan | What it finds | +| ---------------------- | --------------------------------------------------------- | +| Tag Inventory | All tags across resources — names, values, coverage % | +| Tag Recommendations | Inconsistent casing, similar names, missing standard tags | +| Policy Inventory | Azure Policy assignments with scope and compliance | +| Policy Recommendations | Gaps in policy coverage for cost governance | + +### Cost Analysis + +| Scan | What it finds | +| -------------- | --------------------------------------------------------------------------------------------------- | +| Cost Data | Monthly spend per subscription | +| Resource Costs | Top resources by cost | +| Cost by Tag | Spend breakdown by tag key/value | +| Cost Trend | Month-over-month spend comparison | +| Unit Economics | Cost per vCPU, per GB RAM, per VM, and per GB stored (disk + blob/file, with compute/storage split) | + +### AI & ML + +| Scan | What it finds | +| ------------------- | -------------------------------------------------------------------------------------------------- | +| AI Workload Metrics | Detects AI workloads, then token consumption by model, AI spend, cost per 1K tokens, cost per call | + +### Commitments + +| Scan | What it finds | +| ---------------------- | ---------------------------------------- | +| Reservation Advice | RI purchase recommendations from Advisor | +| Commitment Utilization | RI and Savings Plan usage rates | +| Savings Realized | Actual savings from existing commitments | + +### Monitoring + +| Scan | What it finds | +| -------------- | --------------------------------- | +| Budget Status | Budget consumption vs. thresholds | +| Anomaly Alerts | Recent cost anomaly detections | + +### Sustainability + +| Scan | What it finds | +| -------------- | ------------------------------------------------------------------------------------------- | +| Carbon Metrics | Cloud carbon emissions, month-over-month change, 12-month trend, per-subscription breakdown | + +### Advisor & Account + +| Scan | What it finds | +| ------------------- | ---------------------------------------- | +| Optimization Advice | Azure Advisor cost recommendations | +| Billing Structure | Account hierarchy and enrollment details | +| Contract Info | Agreement type, offer, support plan | + +## FinOps KPI Coverage + +The scan modules map directly to [FinOps Foundation KPIs](https://www.finops.org/finops-kpis/). When using the MCP server, you ask in natural language and the agent calls the matching tool. A few examples of prompt → output: + +### Percentage of Legacy Resource → `scan_legacy_resources` + +> "Which of my resources are running on legacy or retiring SKUs?" + +``` +Legacy / Retiring Resources — 47 found across 156 subscriptions + +By category: + Legacy v1 VM families 18 (Basic_A / Standard_A0-A7 / D / DS / G) + Unmanaged VHD disks 9 (migrate to managed disks) + HDD Standard_LRS ≥128GB 11 (upgrade to Premium SSD) + Basic SKU Public IPs 6 (retiring Sep 2025 → Standard) + Basic SKU Load Balancers 3 (retiring Sep 2025 → Standard) +``` + +Legacy % = 47 ÷ total resources in scope. + +### Cost per Gigabyte Stored / Hourly Cost per CPU Core → `scan_unit_economics` + +> "What's my cost per vCPU and per GB of storage this month?" + +``` +Unit Economics — Month to Date (USD) + +Compute $128,400 (75.7%) 312 VMs / 1,840 vCPU / 7,360 GB RAM +Storage $ 41,200 (24.3%) 126,400 GB (84,600 GB disk + 41,800 GB blob/file) + + Cost per vCPU $69.78 / month + Cost per GB RAM $17.45 / month + Cost per VM $411.54 / month + Cost per GB stored $0.326 / month +``` + +vCPU and RAM are exact (read from Compute SKU capabilities). Storage GB combines provisioned managed disks with Storage-account used capacity (Azure Monitor `UsedCapacity`). Cost is scoped to the selected subscriptions and falls back to per-subscription queries when the management-group scope is not accessible, so the section is never silently $0. Directly produces `Cost per GB Stored`; feeds `Hourly Cost per CPU Core` (÷ 730) and `Effective Avg Compute Cost per Core`. + +### Token Consumption / Cost per 1K Tokens / Cost per API Call → `scan_ai_workloads` + +> "What are my AI/LLM workloads costing per token and per request this month?" + +This scan is self-gating: a single Resource Graph query detects whether any AI workloads (Azure OpenAI, AI Services, Machine Learning, AI Search, GPU VMs) exist. Non-AI tenants skip the deep scan entirely, so the scan stays fast. When AI is present, it joins Azure Monitor token metrics to Cost Management spend over the same month-to-date window. + +``` +AI footprint — OpenAI/AIServices: 3 ML workspaces: 1 AI Search: 2 GPU VMs: 0 +Tokens (MTD): 412,800,000 total (288,100,000 in / 124,700,000 out) over 1,240,500 requests +AI spend (MTD): USD 3,910.42 | USD 0.0095 /1K tokens | USD 0.00315 /request + +Deployment PromptTokens GeneratedTokens TotalTokens PctOfTokens +gpt-4o 210,400,000 98,200,000 308,600,000 74.8 +gpt-4o-mini 77,700,000 26,500,000 104,200,000 25.2 +``` + +Produces `Token Consumption`, `Cost per 1K Tokens` (effective blended rate), and `Cost per API Call`; the per-model breakdown highlights where to shift traffic to cheaper SKUs or evaluate Provisioned Throughput Units (PTUs). + +Like the cost scans, this honors `dataSource` (`auto` / `hub` / `api`). When a readable FinOps Hub export covers the scope, AI spend and billed token volume are read straight from the export — no Azure Monitor or Cost Management calls. Cost per request is only available on the live API path, since request counts are not billed line items. + +### Carbon per Unit of Spend / Carbon Efficiency → `scan_carbon` + +> "Show my cloud carbon footprint and how it changed month over month." + +``` +Carbon Emissions — latest available month: 2026-04 (data lags ~2 mo) + +Total emissions 18,420 kgCO2e +Previous month 20,110 kgCO2e +Change -1,690 kgCO2e (-8.4%) ↓ improving + +Top emitting subscriptions: + Production-East 00000000… 7,910 kgCO2e + Data-Platform a1b2c3d4… 4,330 kgCO2e +``` + +Combined with `scan_cost_data`, `Carbon per Unit of Spend` = total emissions ÷ monthly spend. + +### Commitment Utilization Score / % Discount Waste → `scan_commitment_utilization` + +> "How well are my reservations and savings plans being used?" + +``` +Commitment Utilization — trailing 30 days + +Reserved Instances 94.2% utilized ($3,120 unused) +Savings Plans 88.7% utilized ($1,540 unused) +Overall score 91.8% +``` + +`Commitment Utilization Score` = 91.8%; `% Commitment Discount Waste` = 100 − 91.8 = 8.2%. + +### % Costs from Untagged Resources → `scan_cost_by_tag` + +> "How much of my spend is on untagged resources?" + +``` +Cost by Tag — Month to Date + +Tagged spend $612,300 (87.4%) +Untagged spend $ 88,200 (12.6%) ← KPI +``` + +`% Costs from Untagged Resources` = 12.6%. + +## FinOps Hub Integration + +When a Hub is detected, the tool reads FinOps Hub cost data. This enables: + +- **Instant tag scans** — Tag Inventory and Cost by Tag are answered from Hub data instead of querying the Cost Management API +- **No API throttling** — avoids Cost Management API rate limits +- **Richer tag data** — Hub data contains the full Tags per cost record, enabling accurate per-resource tag parsing +- **Forecast enrichment** — Hub data contains actuals only, so the TUI calls the Cost Management Forecast API to project full-month costs and adds them to Hub actuals (storage path) +- **Accurate tag coverage** — Hub only sees resources with cost data. The TUI queries Azure Resource Graph for the true total/untagged resource count and overrides the Hub-derived coverage percentage + +### FinOps Hub data paths + +The cost-family scans (Cost Data, Resource Costs, Cost by Tag) read from a FinOps Hub three ways, in priority order. The first two push aggregation **into the engine** and bring back only summarized results — they never load the raw cost rows into PowerShell, so they scale to large customer datasets (tens of GB / hundreds of millions of rows): + +| Path | When | How | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Kusto — online** | A deployed hub with an Azure Data Explorer / Fabric cluster | The cluster is discovered via Azure Resource Graph (`microsoft.kusto/clusters` tagged `ftk-tool == 'FinOps hubs'`), queried with a bearer token. Aggregation runs in KQL against the `Costs` function. | +| **Kusto — offline (ftklocal)** | Your own hardware / air-gapped: an [ftklocal](https://github.com/microsoft/finops-toolkit) Kusto emulator with the exports loaded into the local **Hub** database | Set `FINOPS_HUB_KUSTO_URI` (and optionally `FINOPS_HUB_KUSTO_DB`, default `Hub`). The local emulator is queried anonymously — same KQL, no auth. | +| **Storage export reader** | Small datasets, or when no Kusto cluster is available | Reads the hub's `ingestion` parquet / `msexports` CSV and aggregates in PowerShell. A convenience fallback, **not** the scalable path. | + +Selection is automatic: `FINOPS_HUB_KUSTO_URI` (if set) wins, else a discovered cluster, else the storage reader. To force the live Cost Management API instead, choose the **Cost Management API** source (TUI) or pass `dataSource=api` (MCP). + +#### Environment variables + +| Variable | Effect | Default | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| `FINOPS_HUB_KUSTO_URI` | Kusto cluster query URI. An `https://...kusto.windows.net` cluster (token auth) or `http://localhost:` ftklocal emulator (anonymous). | unset (auto-discover) | +| `FINOPS_HUB_KUSTO_DB` | Hub database name. | `Hub` | + +Hub data is loaded once at startup and reused across all scans that need it. + +## Scripting (Non-Interactive) + +The scan modules can be called directly without the TUI: + +```powershell +Import-Module .\FinOpsMultitool.psm1 + +# Run a single scan +$tags = Get-TagInventory -Subscriptions $subs -TenantId $tid + +# Read Hub data and convert +$hubData = Read-FinOpsHubData -StorageAccountName 'myhub' -ResourceGroupName 'rg-hub' -Months 1 +$tagInventory = ConvertTo-TagInventoryFromHub -HubData $hubData +$costByTag = ConvertTo-CostByTagFromHub -HubData $hubData -ExistingTags $tagInventory.TagNames +``` + +## MCP Server (AI Integration) + +The FinOps Multitool includes an MCP (Model Context Protocol) server that exposes all 30 scan modules — plus a `run_full_scan` composite — as AI-callable tools, along with a set of **remediation (write) tools** that act on the findings. This lets Copilot, Claude, custom agents, and SRE automation call the same functions used by the TUI. + +Read scans are always safe. The write tools are gated by a configurable [write-safety policy](#write-safety-remediation-tools) so neither a person nor an autonomous agent can make a costly mistake — every write previews first (dry-run) and, in autonomous mode, requires a single-use confirmation token bound to the exact change. + +### Setup + +For a standalone `.vscode/mcp.json` (workspace or user level), use a top-level `servers` block: + +```json +{ + "servers": { + "finops-multitool": { + "type": "stdio", + "command": "pwsh", + "args": ["-NoProfile", "-File", "path/to/Start-McpServer.ps1"] + } + } +} +``` + +For VS Code `settings.json`, nest the same under an `mcp` key: + +```json +{ + "mcp": { + "servers": { + "finops-multitool": { + "type": "stdio", + "command": "pwsh", + "args": ["-NoProfile", "-File", "path/to/Start-McpServer.ps1"] + } + } + } +} +``` + +### Available Tools + +| Tool | Description | +| ----------------------------- | ----------------------------------------------------- | +| `scan_orphaned_resources` | Find unattached disks, NICs, public IPs, NSGs | +| `scan_idle_vms` | Find VMs with <5% CPU over 14-30 days | +| `scan_storage_tier_advice` | Storage accounts that could use cooler tiers | +| `scan_ahb_opportunities` | VMs/SQL not using Azure Hybrid Benefit | +| `scan_tag_inventory` | Tag coverage %, tag names, resource counts | +| `scan_tag_recommendations` | Inconsistent casing, missing standard tags | +| `scan_policy_inventory` | Policy assignments with compliance status | +| `scan_policy_recommendations` | Policy coverage gaps for cost governance | +| `scan_cost_data` | Actual + forecasted cost per subscription | +| `scan_resource_costs` | Top resources by cost (MTD) | +| `scan_cost_by_tag` | Spend breakdown by tag key/value | +| `scan_cost_trend` | Month-over-month spend comparison | +| `scan_reservation_advice` | RI purchase recommendations | +| `scan_commitment_utilization` | RI and Savings Plan usage rates | +| `scan_savings_realized` | Actual savings from commitments | +| `scan_budget_status` | Budget consumption vs thresholds | +| `scan_anomaly_alerts` | Recent cost anomaly detections | +| `scan_legacy_resources` | Legacy/retiring SKUs needing modernization | +| `scan_unit_economics` | Cost per vCPU, per VM, and per GB stored | +| `scan_ai_workloads` | AI token consumption, cost per 1K tokens, per call | +| `scan_carbon` | Cloud carbon emissions and month-over-month trend | +| `scan_optimization_advice` | Azure Advisor cost recommendations | +| `scan_billing_structure` | Billing account hierarchy | +| `scan_contract_info` | Agreement type, offer, support plan | +| `explore_finops_kpis` | Browse the FinOps Foundation KPIs this server informs | +| `run_full_scan` | Run all modules — comprehensive assessment | + +### Remediation Tools (write actions) + +These tools **change Azure resources**. They are dry-run by default and route through the [write-safety policy](#write-safety-remediation-tools) below. Each acts on a single resource ID returned by the matching read scan. + +| Tool | Action | Reversible | +| ------------------------------------ | ------------------------------------------------------------------------------------- | ---------------------- | +| `remediate_enable_hybrid_benefit` | Enable Azure Hybrid Benefit on a VM (from `scan_ahb_opportunities`) | Yes (set back to None) | +| `remediate_deallocate_vm` | Deallocate an idle VM (from `scan_idle_vms`) | Yes (start the VM) | +| `remediate_delete_orphaned_resource` | Delete an orphaned disk / NIC / public IP / snapshot (from `scan_orphaned_resources`) | No (delete) | + +Each write tool takes `resourceId` (required), `apply` (default `false` = preview), and `confirmationToken` (required only in Enforced mode). The delete tool only accepts an allow-list of safe-to-delete types and re-verifies the resource is still orphaned before acting. + +### FinOps KPI Insights + +Most users do not know the [FinOps Foundation KPI catalog](https://www.finops.org/finops-kpis/) by name, so the server surfaces it for them. Every scan automatically attaches a `kpiInsights` block that maps the result to the industry KPIs it informs, with a computed value where the data allows: + +```json +"kpiInsights": [ + { "kpiId": "cost-per-gb-stored", "kpiName": "Cost per Gigabyte Stored", + "domain": "Quantify", "status": "computed", "yourValue": "USD 0.0108 per GB / month", + "plainLanguage": "What you pay for each GB of stored data this month.", + "exploreHint": "Run scan_storage_tier_advice to see if cooler tiers lower this.", + "learnMore": "https://www.finops.org/finops-kpis/" } +] +``` + +`status` is `computed` when a real value was derived from the scan, or `informational` when the scan relates to the KPI but the value needs another scan or external input — the server never fabricates a number. Call `explore_finops_kpis` (no arguments) to list the informable KPIs grouped by FinOps domain, or pass a `kpiId` for a single KPI's definition and which tool produces it. This first release covers ~27 KPIs across Understand, Quantify, Optimize, and Manage. + +### Resources + +| URI | Description | +| ---------------------- | ----------------------------------- | +| `finops://permissions` | Required RBAC roles per scan module | +| `finops://modules` | List of all available scan modules | + +### Architecture + +``` +AI Agent (Copilot / Claude / SRE Agent) + │ MCP Protocol (stdio JSON-RPC) + ▼ +Start-McpServer.ps1 + │ Imports FinOpsMultitool.psm1 + ▼ +Get-CostData, Get-TagInventory, etc. ◄── read scans +Remove-OrphanedResource, Stop-IdleVm… ◄── write tools → Resolve-WriteDecision (safety gate) + │ Same functions used by the TUI + ▼ +Azure APIs (Cost Management, Resource Graph, Advisor, etc.) +``` + +## Write Safety (Remediation Tools) + +The remediation tools are designed for two audiences at once — a person chatting through an AI client, **and** an autonomous agent running unattended — without forcing the safety burden on the interactive experience. Behavior is controlled by environment variables, so the same server is friendly in a chat and locked-down in production. The server is **read-only by default** (`ReadOnly`); enabling writes is a deliberate opt-in via `FINOPS_WRITE_MODE`. + +### Modes — `FINOPS_WRITE_MODE` + +| Mode | Behavior | Use for | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `ReadOnly` | **Default.** All write tools are blocked; read scans still work. Set `FINOPS_WRITE_MODE` to `Interactive` or `Enforced` to enable writes. | Locked-down or audit-only deployments (and the default) | +| `Interactive` | `apply=true` runs the change directly. A preview/token is offered but not required. The client (human or AI) is the gate. | Platform-agnostic AI chat — low friction, any client | +| `Enforced` | `apply=true` is **rejected** unless it carries the exact single-use token from that change's own dry-run preview (bound to a SHA-256 fingerprint, expires in 5 min). | Autonomous / unattended agents — server is the gate | + +Every write previews first: call the tool without `apply` to get the exact REST call, the resource evidence, and (in Enforced mode) a `confirmationToken` to pass back with `apply=true`. + +### Guardrails (enforced in every mode) + +These never depend on a well-behaved client. Configure via environment variables: + +| Variable | Effect | Default | +| ----------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `FINOPS_PROTECTED_TAGS` | Resources carrying any of these tag keys are never written to | `do-not-delete`, `DoNotDelete`, `lock`, `protected` | +| `FINOPS_PROTECTED_RGS` | Resource groups (supports `*` wildcards) that are off-limits | none | +| `FINOPS_PROTECTED_SUBS` | Subscriptions that are off-limits | none | +| `FINOPS_WRITE_MAX_IMPACT` | Block any single write whose estimated monthly $ impact exceeds this cap (`0` = no cap) | `0` | +| `FINOPS_WRITE_MAX_PER_WINDOW` | Max writes allowed per rolling window (blast-radius limit) | unlimited | +| `FINOPS_WRITE_WINDOW_MIN` | Length of that window in minutes | `60` | +| `FINOPS_AUDIT_LOG` | Path for the append-only audit log (every preview / apply / block is recorded as JSON) | `%LOCALAPPDATA%\FinOpsMultitool\finops-multitool-audit.log` | + +### Example — autonomous, locked-down server + +```json +{ + "servers": { + "finops-multitool": { + "type": "stdio", + "command": "pwsh", + "args": ["-NoProfile", "-File", "path/to/Start-McpServer.ps1"], + "env": { + "FINOPS_WRITE_MODE": "Enforced", + "FINOPS_PROTECTED_RGS": "rg-prod-*,rg-shared", + "FINOPS_WRITE_MAX_PER_WINDOW": "5" + } + } + } +} +``` + +In this configuration an agent must preview each change, pass the matching token back, stay out of protected resource groups, and is capped at five writes per hour — all enforced by the server, not the client. + +## File Structure + +``` +FinOpsMultitool/ +├── README.md # This file +├── FinOpsMultitool.psm1 # Module loader (dot-sources all scan modules) +├── Invoke-FinOpsMultitool.ps1 # TUI entry point +├── Start-McpServer.ps1 # MCP server (AI integration, stdio JSON-RPC) +├── modules/ +│ ├── helpers/ +│ │ ├── Read-FinOpsHubData.ps1 # Hub storage reader + converters (small-dataset path) +│ │ ├── Invoke-FOHubKustoQuery.ps1 # Hub Kusto REST transport (ADX/Fabric/ftklocal) +│ │ ├── Get-FOHubProvider.ps1 # Scalable hub provider (discovery + engine-side cost intents) +│ │ ├── Get-PlainAccessToken.ps1 # Token helper +│ │ ├── Invoke-AzRestMethodWithRetry.ps1 # REST retry logic +│ │ ├── Search-AzGraphSafe.ps1 # ARG query wrapper +│ │ ├── Confirm-WriteAction.ps1 # Write-safety policy gate (modes, guardrails, audit) +│ │ └── MgCostScope.ps1 # Management group scope state +│ ├── Initialize-Scanner.ps1 +│ ├── Get-CostData.ps1 +│ ├── Get-ResourceCosts.ps1 +│ ├── Get-TagInventory.ps1 +│ ├── Get-CostByTag.ps1 +│ ├── Get-OrphanedResources.ps1 +│ ├── Remove-OrphanedResource.ps1 # Write: delete orphaned resource (gated) +│ ├── Enable-HybridBenefit.ps1 # Write: enable Azure Hybrid Benefit (gated) +│ ├── Stop-IdleVm.ps1 # Write: deallocate idle VM (gated) +│ ├── Get-IdleVMs.ps1 +│ └── ... # One file per scan module +``` diff --git a/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 b/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 new file mode 100644 index 000000000..a91d4b398 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/Start-McpServer.ps1 @@ -0,0 +1,1797 @@ +########################################################################### +# START-MCPSERVER.PS1 +# FINOPS MULTITOOL MCP SERVER (STDIO) +########################################################################### +# Purpose: Model Context Protocol server exposing FinOps scan modules +# as AI-callable tools over JSON-RPC via stdin/stdout. +# Author: Zac Larsen +# Date: Created for FinOps Toolkit integration +# +# Description: +# 1. Imports FinOpsMultitool.psm1 (same module used by TUI/GUI) +# 2. Listens for JSON-RPC messages on stdin +# 3. Dispatches tool calls to existing Get-* functions +# 4. Returns structured JSON results on stdout +# +# Prerequisites: +# - PowerShell 7+ (pwsh) +# - Az.Accounts, Az.Resources, Az.ResourceGraph modules +# - Active Azure session (Connect-AzAccount) +# +# Usage: +# MCP config (VS Code settings.json or mcp.json): +# { +# "mcp": { +# "servers": { +# "finops-multitool": { +# "command": "pwsh", +# "args": ["-NoProfile", "-File", "path/to/Start-McpServer.ps1"] +# } +# } +# } +# } +########################################################################### + +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------- +# Keep stdout clean for JSON-RPC. Scan modules use Write-Host for TUI +# status and Az cmdlets emit warnings/progress — in a redirected child +# process these land on stdout and corrupt the protocol stream. Suppress +# every non-JSON stream globally and shadow Write-Host so it can never +# reach stdout. Errors (stream 2) are left intact for try/catch. +# --------------------------------------------------------------------- +$WarningPreference = 'SilentlyContinue' +$VerbosePreference = 'SilentlyContinue' +$DebugPreference = 'SilentlyContinue' +$ProgressPreference = 'SilentlyContinue' +$InformationPreference = 'SilentlyContinue' + +# Write-Host bypasses preference variables and writes straight to the +# host (= stdout when redirected). Shadow it with a function that routes +# to stderr, so module TUI output is preserved for debugging but never +# pollutes the JSON-RPC stdout stream. +function Write-Host { + [CmdletBinding()] + param( + [Parameter(Position = 0, ValueFromPipeline, ValueFromRemainingArguments)] + [object[]]$Object, + [switch]$NoNewline, + [object]$Separator, + [object]$ForegroundColor, + [object]$BackgroundColor + ) + if ($null -ne $Object) { [Console]::Error.WriteLine(($Object -join ' ')) } +} + +# Import the module +$psm1Path = Join-Path $PSScriptRoot 'FinOpsMultitool.psm1' +if (-not (Test-Path $psm1Path)) { + [Console]::Error.WriteLine("ERROR: FinOpsMultitool.psm1 not found at $psm1Path") + exit 1 +} +Import-Module $psm1Path -Force -DisableNameChecking + +# ===================================================================== +# MCP PROTOCOL CONSTANTS +# ===================================================================== +$MCP_VERSION = '2024-11-05' +$SERVER_NAME = 'finops-multitool' +$SERVER_VERSION = '1.3.0' + +# ===================================================================== +# TOOL DEFINITIONS +# ===================================================================== +# Each tool maps to a Get-* function in the module. The MCP server +# handles subscription resolution and parameter binding. + +$toolDefinitions = @( + @{ + name = 'scan_orphaned_resources' + description = 'Find orphaned Azure resources (unattached disks, NICs, public IPs, NSGs) across subscriptions.' + fn = 'Get-OrphanedResources' + category = 'Optimization' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'remediate_delete_orphaned_resource' + description = "WRITE/MUTATING. Delete ONE orphaned resource found by scan_orphaned_resources. Only orphan-eligible types can ever be deleted: unattached managed disks (Microsoft.Compute/disks), dangling public IPs (Microsoft.Network/publicIPAddresses), unattached NICs (Microsoft.Network/networkInterfaces), and disk snapshots (Microsoft.Compute/snapshots) - any other type is refused. It re-reads the resource and re-verifies it is still orphaned before acting (it REFUSES if the resource is now in use). DRY-RUN by default: without apply=true it returns a preview (the exact DELETE URI plus orphan evidence) and deletes NOTHING. Deletion is IRREVERSIBLE. ALWAYS show the user the preview and obtain explicit confirmation, THEN call again with apply=true to actually delete. Requires a delete-capable role (e.g. Contributor) on the resource scope." + fn = 'Remove-OrphanedResource' + isWrite = $true + category = 'Optimization' + inputSchema = @{ + type = 'object' + properties = @{ + resourceId = @{ type = 'string'; description = 'Full ARM resource ID of the orphan to delete (e.g. /subscriptions/{guid}/resourceGroups/{rg}/providers/Microsoft.Compute/disks/{name}). Take this from scan_orphaned_resources output. Required.' } + apply = @{ type = 'boolean'; description = 'SAFETY GATE. Default false = dry-run preview (deletes nothing). Set true ONLY after the user has reviewed the preview and explicitly approved deleting this specific resource. Deletion is irreversible.' } + confirmationToken = @{ type = 'string'; description = 'The token returned by the dry-run preview. OPTIONAL in Interactive mode; REQUIRED in Enforced (autonomous-safe) mode, where apply=true is rejected without the exact token from the matching preview. Single-use and short-lived.' } + } + required = @('resourceId') + } + } + @{ + name = 'remediate_enable_hybrid_benefit' + description = "WRITE/MUTATING (REVERSIBLE, savings-only). Enable Azure Hybrid Benefit on ONE VM found by scan_ahb_opportunities, applying existing Windows Server / SQL licenses to cut compute licensing cost up to ~85%. This is reversible (set licenseType back to None) and only reduces cost. Windows VMs are auto-detected (licenseType Windows_Server); for Linux you must pass an explicit RHEL_BYOS/SLES_BYOS licenseType. No-ops if AHB is already on. DRY-RUN by default: without apply=true it previews the PATCH and changes nothing. In Interactive mode apply=true works directly; in Enforced mode it also requires the confirmationToken from the preview." + fn = 'Enable-HybridBenefit' + isWrite = $true + category = 'Optimization' + inputSchema = @{ + type = 'object' + properties = @{ + resourceId = @{ type = 'string'; description = 'Full ARM resource ID of the VM (Microsoft.Compute/virtualMachines). From scan_ahb_opportunities. Required.' } + licenseType = @{ type = 'string'; enum = @('Windows_Server', 'Windows_Client', 'RHEL_BYOS', 'SLES_BYOS'); description = 'Optional override. Auto-detected as Windows_Server for Windows VMs; required for Linux (RHEL_BYOS/SLES_BYOS).' } + apply = @{ type = 'boolean'; description = 'SAFETY GATE. Default false = dry-run preview. Set true to enable AHB. Reversible, savings-only.' } + confirmationToken = @{ type = 'string'; description = 'Token from the dry-run preview. Optional in Interactive mode; required in Enforced mode.' } + } + required = @('resourceId') + } + } + @{ + name = 'remediate_deallocate_vm' + description = "WRITE/MUTATING (REVERSIBLE). Deallocate (stop) ONE idle VM found by scan_idle_vms so it stops billing for compute. Deallocate is reversible - the VM can be started again and keeps its disks and configuration; it is NOT deleted. No-ops if the VM is already deallocated. DRY-RUN by default: without apply=true it previews the deallocate and changes nothing. In Interactive mode apply=true works directly; in Enforced mode it also requires the confirmationToken from the preview." + fn = 'Stop-IdleVm' + isWrite = $true + category = 'Optimization' + inputSchema = @{ + type = 'object' + properties = @{ + resourceId = @{ type = 'string'; description = 'Full ARM resource ID of the VM (Microsoft.Compute/virtualMachines). From scan_idle_vms. Required.' } + apply = @{ type = 'boolean'; description = 'SAFETY GATE. Default false = dry-run preview. Set true to deallocate. Reversible (start the VM to undo).' } + confirmationToken = @{ type = 'string'; description = 'Token from the dry-run preview. Optional in Interactive mode; required in Enforced mode.' } + } + required = @('resourceId') + } + } + @{ + name = 'scan_idle_vms' + description = 'Find idle or underutilized VMs (less than 5% average CPU over 14-30 days) with cost impact classification.' + fn = 'Get-IdleVMs' + category = 'Optimization' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_storage_tier_advice' + description = 'Analyze storage accounts for tier optimization opportunities (Hot to Cool/Cold/Archive).' + fn = 'Get-StorageTierAdvice' + category = 'Optimization' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_ahb_opportunities' + description = 'Find Windows/SQL VMs and SQL databases not using Azure Hybrid Benefit (up to 40-55% savings).' + fn = 'Get-AHBOpportunities' + category = 'Optimization' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_tag_inventory' + description = 'Inventory all resource tags across subscriptions. Returns tag coverage percentage, tag names, resource counts, and untagged resources.' + fn = 'Get-TagInventory' + category = 'Governance' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_tag_recommendations' + description = 'Analyze existing tags and recommend improvements: missing CAF standard tags, inconsistent casing, similar/duplicate names.' + fn = 'Get-TagRecommendations' + category = 'Governance' + requiresTagInventory = $true + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_policy_inventory' + description = 'List all Azure Policy assignments with scope, effect, enforcement mode, and compliance status.' + fn = 'Get-PolicyInventory' + category = 'Governance' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_policy_recommendations' + description = 'Evaluate policy coverage gaps and recommend cost governance policies (tagging, region, SKU restrictions).' + fn = 'Get-PolicyRecommendations' + category = 'Governance' + requiresPolicyInventory = $true + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_cost_data' + description = 'Get current month actual and forecasted cost per subscription. Returns spend, forecast, and currency. Uses FinOps Hub export data when available (fast); call detect_cost_data_source first to decide.' + fn = 'Get-CostData' + category = 'Cost Analysis' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + subscriptionIds = @{ type = 'array'; items = @{ type = 'string' }; description = 'Subset of subscription IDs to scan (for chunked progress). Overrides subscriptionId when provided.' } + dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read cost data. 'auto' (default) uses a FinOps Hub or Cost Management export when it covers scope, else the live API. 'hub' forces the export fast path. 'api' forces the live Cost Management API." } + } + } + } + @{ + name = 'scan_resource_costs' + description = 'Get top resources by cost (actual month-to-date spend) with resource group, type, and forecast. Uses FinOps Hub export data when available (fast); call detect_cost_data_source first to decide.' + fn = 'Get-ResourceCosts' + category = 'Cost Analysis' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + subscriptionIds = @{ type = 'array'; items = @{ type = 'string' }; description = 'Subset of subscription IDs to scan (for chunked progress). Overrides subscriptionId when provided.' } + dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read cost data. 'auto' (default) uses a FinOps Hub or Cost Management export when it covers scope, else the live API. 'hub' forces the export fast path. 'api' forces the live Cost Management API." } + } + } + } + @{ + name = 'scan_cost_by_tag' + description = 'Break down cost by tag key/value pairs. Shows spend per tag value and identifies untagged spend. Uses FinOps Hub export data when available (fast); call detect_cost_data_source first to decide.' + fn = 'Get-CostByTag' + category = 'Cost Analysis' + requiresTagInventory = $true + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + subscriptionIds = @{ type = 'array'; items = @{ type = 'string' }; description = 'Subset of subscription IDs to scan (for chunked progress). Overrides subscriptionId when provided.' } + dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read cost data. 'auto' (default) uses a FinOps Hub or Cost Management export when it covers scope, else the live API. 'hub' forces the export fast path. 'api' forces the live Cost Management API." } + } + } + } + @{ + name = 'scan_cost_trend' + description = 'Get month-over-month cost trend (last 3-6 months) per subscription to identify spending patterns.' + fn = 'Get-CostTrend' + category = 'Cost Analysis' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_reservation_advice' + description = 'Get Azure Advisor reservation purchase recommendations with estimated annual savings.' + fn = 'Get-ReservationAdvice' + category = 'Commitments' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_commitment_utilization' + description = 'Check utilization rates of existing reservations and savings plans. Identifies underutilized commitments.' + fn = 'Get-CommitmentUtilization' + category = 'Commitments' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_savings_realized' + description = 'Calculate actual savings from reservations, savings plans, and Azure Hybrid Benefit (monthly and annual).' + fn = 'Get-SavingsRealized' + category = 'Commitments' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_budget_status' + description = 'Check budget consumption vs thresholds. Returns budget amounts, actual spend, percentage used, and risk level.' + fn = 'Get-BudgetStatus' + category = 'Monitoring' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_budget_history' + description = 'Get monthly budget vs actual history (last N months) for each configured budget. Pro-rates quarterly/annual budgets to a monthly equivalent. Runs Budget Status first to discover budgets.' + fn = 'Get-BudgetHistory' + category = 'Monitoring' + requiresBudgets = $true + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + monthsBack = @{ type = 'integer'; description = 'Number of months of history to return. Default 6.' } + } + } + } + @{ + name = 'scan_anomaly_alerts' + description = 'Retrieve recent cost anomaly alerts and detection rules.' + fn = 'Get-AnomalyAlerts' + category = 'Monitoring' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_optimization_advice' + description = 'Get Azure Advisor cost optimization recommendations with estimated annual savings per resource.' + fn = 'Get-OptimizationAdvice' + category = 'Advisor' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_unit_economics' + description = 'Compute unit-economics KPIs: cost per vCPU, per GB RAM, per VM (compute) and per GB stored (storage), plus the compute/storage cost split, by dividing month-to-date amortized cost by provisioned capacity. vCPU and RAM are exact (from Compute SKU capabilities); storage GB combines managed disks with Storage-account used capacity (Azure Monitor); cost is scoped to the selected subscriptions with a per-subscription fallback.' + fn = 'Get-UnitEconomics' + category = 'Cost Analysis' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_vm_cost_breakdown' + description = "Decompose ONE virtual machine's full solution cost (month-to-date) into meter components - compute, OS/data disks, network egress, network infra (public IP/NIC), backup/recovery, security (Defender), monitoring/extension agents, and licensing - instead of a single rolled-up number. Resolves the VM and its associated billable resources from Azure Resource Graph, then attributes cost per meter. Uses the FinOps Hub export when available (exact egress GB); otherwise the live Cost Management API. Provide vmName (optionally resourceGroup) or a full resourceId." + fn = 'Get-VmCostBreakdown' + category = 'Cost Analysis' + inputSchema = @{ + type = 'object' + properties = @{ + vmName = @{ type = 'string'; description = 'Name of the virtual machine to decompose. Use with resourceGroup if the name is not unique.' } + resourceId = @{ type = 'string'; description = 'Full Azure resource ID of the VM. Takes precedence over vmName when supplied.' } + resourceGroup = @{ type = 'string'; description = 'Resource group of the VM (disambiguates a non-unique vmName).' } + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, searches all accessible subscriptions.' } + dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read cost. 'auto' (default) uses the FinOps Hub export when it covers scope (exact egress GB), else the live Cost Management API. 'hub' forces the export. 'api' forces the live API." } + } + } + } + @{ + name = 'scan_allocate_shared_cost' + description = "Allocate the billed cost of SHARED hub resources (ExpressRoute gateway/circuit, VPN gateway, Azure Firewall, shared bandwidth) across the spoke subscriptions that use them, and report each spoke's full solution cost (its own resources + its allocated share). Use this for hub-and-spoke showback. The cost math lives here; the GB/TB transfer split key is supplied as input - an agent can fetch per-spoke transfer from Azure Monitor / Traffic Analytics (e.g. via the Azure MCP server) and pass it as weightingValues, or fall back to a proxy (resourceCount/equal). Each shared pool is split into a fixed part (shared evenly) and a variable part (by transfer weight). Provide sharedResourceIds or sharedResourceGroup, plus the spoke subscription IDs." + fn = 'Get-SharedCostAllocation' + category = 'Cost Analysis' + inputSchema = @{ + type = 'object' + properties = @{ + sharedResourceIds = @{ type = 'array'; items = @{ type = 'string' }; description = 'Full resource IDs of the shared hub resources to allocate (e.g. the ExpressRoute gateway, firewall). Either this or sharedResourceGroup is required.' } + sharedResourceGroup = @{ type = 'string'; description = 'Resource group holding the shared resources. Used when sharedResourceIds is not supplied.' } + spokes = @{ type = 'array'; items = @{ type = 'string' }; description = 'Subscription IDs of the spokes that share the hub resources. Required.' } + weightingMethod = @{ type = 'string'; enum = @('inline', 'trafficAnalytics', 'equal', 'resourceCount'); description = "How to weight the variable split. 'inline' (default) uses weightingValues (exact GB/TB per spoke). 'trafficAnalytics' queries a Log Analytics workspace (workspaceId) for measured GB per spoke - exact, end-to-end, no manual feed. 'equal' splits evenly. 'resourceCount' uses billable resource count per spoke as a proxy estimate." } + weightingValues = @{ type = 'object'; description = "Map of spoke subscription id -> transfer amount (e.g. GB). Used when weightingMethod is 'inline'. Typically sourced from Traffic Analytics / Azure Monitor." } + workspaceId = @{ type = 'string'; description = "Log Analytics workspace GUID (customer id). Required when weightingMethod is 'trafficAnalytics'. The workspace must have Traffic Analytics / VNet flow logs." } + lookbackDays = @{ type = 'number'; description = "Days of transfer history to read for 'trafficAnalytics' weighting. Default 30." } + fixedRatio = @{ type = 'number'; description = 'Fraction of each shared pool treated as fixed (split evenly) vs variable (split by weight). 0 = all variable, 1 = all fixed. Default 0.5.' } + subscriptionId = @{ type = 'string'; description = 'Optional scope hint for resolving shared resources. If omitted, the spokes and shared resource scope are searched.' } + dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read cost. 'auto' (default) uses the FinOps Hub export when it covers scope, else the live Cost Management API. 'hub' forces the export. 'api' forces the live API." } + } + } + } + @{ + name = 'set_cost_allocation_rule' + description = "WRITE/MUTATING. Create or update a NATIVE Azure Cost Management cost allocation rule so chargeback reflects a shared-cost split (typically the output of scan_allocate_shared_cost). This CHANGES how cost is charged back across subscriptions and can affect internal billing. It is DRY-RUN by default: without apply=true it returns a preview (the exact PUT URI and request body) and writes NOTHING. ALWAYS show the user the preview and obtain explicit confirmation, THEN call again with apply=true to actually write. Requires an EA enrollment or MCA billing account id and Cost Management Contributor on it. Source is one hub resource group (sourceResourceGroup) or subscription (sourceSubscriptionId); targets are the spoke subscriptions with percentages (auto-normalized to sum 100)." + fn = 'Set-CostAllocationRule' + isWrite = $true + category = 'Cost Analysis' + inputSchema = @{ + type = 'object' + properties = @{ + billingAccountId = @{ type = 'string'; description = 'EA enrollment id or MCA billing account id that scopes the rule. Required.' } + ruleName = @{ type = 'string'; description = "Cost allocation rule name. Letters, digits, '_' and '-' only (max 260 chars). Required." } + sourceResourceGroup = @{ type = 'array'; items = @{ type = 'string' }; description = 'Resource group name(s) holding the shared cost to reallocate (the hub). Provide this OR sourceSubscriptionId.' } + sourceSubscriptionId = @{ type = 'array'; items = @{ type = 'string' }; description = 'Subscription id(s) holding the shared cost to reallocate (the hub). Provide this OR sourceResourceGroup.' } + targets = @{ type = 'array'; items = @{ type = 'object' }; description = "Spoke targets. Each item: { subscriptionId, percentage } or { spoke, allocatedShared }. You can pass the Allocations array from scan_allocate_shared_cost (it has Spoke + AllocatedShared) and percentages are derived and normalized to sum 100. Required." } + targetDimension = @{ type = 'string'; enum = @('SubscriptionId', 'ResourceGroupName'); description = "Dimension the targets are keyed by. Default 'SubscriptionId'." } + status = @{ type = 'string'; enum = @('Active', 'NotActive'); description = "Rule status. 'Active' (default) impacts cost allocation; 'NotActive' saves it without applying." } + description = @{ type = 'string'; description = 'Optional rule description.' } + apply = @{ type = 'boolean'; description = 'SAFETY GATE. Default false = dry-run preview (writes nothing). Set true ONLY after the user has reviewed the preview and explicitly approved, to create/update the rule in Azure.' } + confirmationToken = @{ type = 'string'; description = 'Token returned by the dry-run preview. OPTIONAL in Interactive mode; REQUIRED in Enforced (autonomous-safe) mode, where apply=true is rejected without the exact token from the matching preview. Single-use and short-lived.' } + } + required = @('billingAccountId', 'ruleName', 'targets') + } + } + @{ + name = 'scan_billing_account' + description = "READ-ONLY. List the Azure billing accounts your identity can see and report, for each, the billing account id (the value set_cost_allocation_rule needs), the agreement type (EA / MCA / CSP / pay-as-you-go), whether it is ELIGIBLE for cost allocation rules (only EA and MCA are), and - by default - whether you can reach the cost allocation rules endpoint there (a 403 means you lack Cost Management Contributor at that scope). Run this before set_cost_allocation_rule to find the right billingAccountId and confirm access. Never writes anything." + fn = 'Get-BillingAccount' + category = 'Cost Analysis' + inputSchema = @{ + type = 'object' + properties = @{ + billingAccountId = @{ type = 'string'; description = 'Optional. Return only this billing account id instead of all visible ones.' } + probeAccess = @{ type = 'boolean'; description = 'When true (default), probe the cost allocation rules endpoint per eligible account to report read access (a 403 signals missing Cost Management Contributor). Set false to skip the extra calls.' } + } + } + } + @{ + name = 'scan_usage_allocation' + description = "SHOWBACK. Split the billed cost of a SHARED PLATFORM across sub-resource consumers that have NO Azure billing dimension - Kubernetes namespaces (AKS), APIM products/subscriptions (token spend), or Azure OpenAI deployments - by a usage signal read from a Log Analytics workspace. This is the answer to 'we can't get to AKS/APIM token spend easily': there is no native billing line per namespace/token, so it must be telemetry-driven. Pick a preset (aksNamespace = Container Insights CPU/mem; apimTokens = App Insights token metrics; openAiTokens = Azure Monitor Cognitive Services token metrics) or pass a custom weightingQuery returning Consumer + Weight. SHOWBACK ONLY: results are Mode=Showback with empty RuleTargets and CANNOT be written via set_cost_allocation_rule (those consumers are not EA/MCA billing dimensions). Provide the pool via sharedResourceIds/sharedResourceGroup (cost resolved) or poolAmount, plus the workspaceId." + fn = 'Get-UsageProportionalAllocation' + category = 'Cost Analysis' + inputSchema = @{ + type = 'object' + properties = @{ + preset = @{ type = 'string'; enum = @('aksNamespace', 'apimTokens', 'openAiTokens', 'custom'); description = "Which shared-platform usage key to use. 'aksNamespace' splits by namespace CPU+memory (Container Insights). 'apimTokens' splits by tokens per APIM consumer (workspace-based App Insights). 'openAiTokens' splits by tokens per Azure OpenAI resource (Azure Monitor metrics). 'custom' requires weightingQuery." } + workspaceId = @{ type = 'string'; description = 'Log Analytics workspace GUID holding the telemetry (Container Insights workspace for AKS; the workspace backing Application Insights for APIM/OpenAI). Required.' } + sharedResourceIds = @{ type = 'array'; items = @{ type = 'string' }; description = 'Resource IDs whose billed cost forms the pool to split (e.g. the AKS cluster / its node pools, the APIM instance, the AOAI resource). Either this or sharedResourceGroup or poolAmount.' } + sharedResourceGroup = @{ type = 'string'; description = 'Resource group holding the shared platform; its billed cost forms the pool.' } + poolAmount = @{ type = 'number'; description = 'Explicit pool cost to split instead of resolving resources (e.g. a known PTU monthly cost).' } + lookbackDays = @{ type = 'number'; description = 'Telemetry window in days. Default 30.' } + dimensionName = @{ type = 'string'; description = "Override the consumer dimension. apimTokens defaults to 'Subscription Id'; set e.g. 'API ID' or 'Product Id' to charge by API or product." } + weightingQuery = @{ type = 'string'; description = 'Full KQL override. Must return two columns: Consumer (string) and Weight (number). Required when preset is custom.' } + subscriptionId = @{ type = 'string'; description = 'Optional scope hint for resolving the shared platform resources.' } + dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read the pool cost. 'auto' (default) uses the FinOps Hub export when it covers scope, else the live Cost Management API." } + } + required = @('preset', 'workspaceId') + } + } + @{ + name = 'scan_ai_workloads' + description = 'Detect AI/LLM workloads (Azure OpenAI, AI Services, Machine Learning, AI Search, GPU VMs) and, only when present, compute AI unit-economics KPIs: month-to-date token consumption (input/output) by model deployment, total Azure OpenAI requests, AI spend, effective cost per 1K tokens, and cost per request. Self-gating - returns quickly when no AI workloads exist. Reads AI spend and billed token volume from the FinOps Hub export when available (no Monitor/Cost Management calls); cost per request is only available on the live API path.' + fn = 'Get-AIWorkloadMetrics' + category = 'AI & ML' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = "Where to read AI spend and token volume. 'auto' (default) uses the FinOps Hub export when it fully covers scope, else the live Monitor + Cost Management APIs. 'hub' forces the export (no cost-per-request). 'api' forces the live APIs." } + } + } + } + @{ + name = 'scan_legacy_resources' + description = 'Find legacy and retiring resources to modernize: first-generation (v1) VM families, unmanaged (VHD) disks, HDD managed disks, and Basic-SKU public IPs / load balancers (retiring Sep 2025).' + fn = 'Get-LegacyResources' + category = 'Optimization' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_carbon' + description = 'Get Azure carbon emissions (kgCO2e) from the Carbon Optimization service: latest-month total, month-over-month change, a 12-month trend, and a per-subscription breakdown. Emissions publish ~2 months in arrears.' + fn = 'Get-CarbonMetrics' + category = 'Sustainability' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_billing_structure' + description = 'Get billing account hierarchy and enrollment details (EA, MCA, CSP).' + fn = 'Get-BillingStructure' + category = 'Account' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_contract_info' + description = 'Get agreement type, offer details, currency, and support plan information.' + fn = 'Get-ContractInfo' + category = 'Account' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + } + } + } + @{ + name = 'scan_macc_commitment' + description = 'Check Microsoft Azure Consumption Commitment (MACC) status for EA/MCA billing accounts: commitment amount, consumed, remaining, percent burned, and expiration. Returns not-applicable for PAYGO/CSP/MSDN. Requires a billing role.' + fn = 'Get-MaccCommitment' + category = 'Account' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, queries all accessible subscriptions.' } + } + } + } + @{ + name = 'get_azure_context' + description = 'Show the ACTIVE Azure sign-in context this server will scan: account, tenant, current subscription, and every tenant/subscription the account can reach. ALWAYS call this first in a session (and any time the user is unsure) so the user can confirm they are pointed at the intended tenant BEFORE running any scan or remediation. Scans only ever touch the active context shown here. If it is wrong, the user must switch context (Set-AzContext / Connect-AzAccount -TenantId) and RESTART this MCP server — the server caches its Azure session at startup.' + fn = '_get_context' + category = 'Context' + inputSchema = @{ + type = 'object' + properties = @{} + } + } + @{ + name = 'detect_cost_data_source' + description = 'Decide how cost scans should run BEFORE invoking any cost tool. Detects a FinOps Hub or any readable Cost Management (CSV) export in scope, checks whether it is readable (with a specific blocker reason if not), reports which subscriptions it covers and how fresh the data is, and estimates how long the live Cost Management API path would take. Call this first for any cost question so the fast export path can be used, or so the user can be warned and asked before a slow API scan.' + fn = '_detect_cost_source' + category = 'Cost Analysis' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, evaluates all accessible subscriptions.' } + } + } + } + @{ + name = 'run_full_scan' + description = 'Run all FinOps scan modules (optimization, governance, cost, commitments, monitoring, advisor) and return a comprehensive assessment. This is the most thorough scan — use individual tools for targeted queries.' + fn = '_full_scan' + category = 'Assessment' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions.' } + modules = @{ type = 'array'; items = @{ type = 'string' }; description = 'Optional list of module names to include. Omit to run all.' } + dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = 'Cost-family data source. auto (default) uses a FinOps Hub or Cost Management export when it covers the scope, else the live Cost Management API; hub forces the export fast path; api skips the export. Governance and optimization modules always use live APIs.' } + } + } + } + @{ + name = 'generate_powerbi_template' + description = 'Run a full FinOps scan and generate a self-contained Power BI template (.pbit) plus the supporting CSV files. The .pbit ships a curated 4-page report (Cost Overview, Subscriptions, Optimization, Governance) whose CsvFolderPath parameter is pre-pointed at the exported folder. Returns the path to FinOps-Report.pbit.' + fn = '_generate_powerbi' + category = 'Reporting' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, scans all accessible subscriptions in the current tenant.' } + outputDir = @{ type = 'string'; description = 'Folder to write the CSVs and FinOps-Report.pbit into. Defaults to a timestamped folder under the user TEMP directory.' } + dataSource = @{ type = 'string'; enum = @('auto', 'hub', 'api'); description = 'Cost-family data source for the scan. auto (default), hub, or api.' } + } + } + } + @{ + name = 'connect_powerbi_to_hub' + description = 'Detect the FinOps Hub in scope and emit the exact connection parameter values to plug into the official FinOps Toolkit Power BI reports (src/power-bi). Returns the storage account, blob endpoint, ingestion container, dataset path, and which report to open. Use this when the user already has a FinOps Hub and wants the toolkit reports instead of a generated .pbit.' + fn = '_connect_powerbi_hub' + category = 'Reporting' + inputSchema = @{ + type = 'object' + properties = @{ + subscriptionId = @{ type = 'string'; description = 'Target subscription ID. If omitted, evaluates all accessible subscriptions in the current tenant.' } + } + } + } + @{ + name = 'explore_finops_kpis' + description = 'Browse the FinOps Foundation KPIs (https://www.finops.org/finops-kpis/) that this server can inform, and learn which scan produces each one. Call with no arguments to list KPIs grouped by FinOps domain (Understand/Quantify/Optimize/Manage) with a Computable-now vs Informational flag. Call with a kpiId to get that KPI''s definition, the tool that informs it, and a plain-language explanation. Use this when a user wants to understand FinOps KPIs or map their tenant data to industry metrics. Most scan tools also attach a kpiInsights block to their own output automatically.' + fn = '_explore_kpis' + category = 'Guidance' + inputSchema = @{ + type = 'object' + properties = @{ + kpiId = @{ type = 'string'; description = 'Optional KPI id (from the list) to get full detail. Omit to list all informable KPIs grouped by domain.' } + } + } + } +) + +# Permission requirements per tool (same as TUI) +$permissionMap = @{ + 'Get-OrphanedResources' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' } + 'Remove-OrphanedResource' = @{ role = 'Contributor (delete)'; scope = 'Resource'; api = 'Azure Resource Manager (DELETE)' } + 'Enable-HybridBenefit' = @{ role = 'Virtual Machine Contributor'; scope = 'Resource'; api = 'Azure Resource Manager (PATCH)' } + 'Stop-IdleVm' = @{ role = 'Virtual Machine Contributor'; scope = 'Resource'; api = 'Azure Resource Manager (deallocate)' } + 'Get-IdleVMs' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph + Monitor Metrics' } + 'Get-StorageTierAdvice' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' } + 'Get-AHBOpportunities' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' } + 'Get-TagInventory' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' } + 'Get-TagRecommendations' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' } + 'Get-PolicyInventory' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Manager' } + 'Get-PolicyRecommendations' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Manager' } + 'Get-CostData' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' } + 'Get-ResourceCosts' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' } + 'Get-CostByTag' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' } + 'Get-CostTrend' = @{ role = 'Cost Management Reader'; scope = 'Subscription or Management Group'; api = 'Cost Management Query API' } + 'Get-ReservationAdvice' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Consumption Reservation Recommendations API' } + 'Get-CommitmentUtilization' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Consumption Reservation Summaries API' } + 'Get-SavingsRealized' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Cost Management Benefit Utilization API' } + 'Get-BudgetStatus' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Consumption Budgets API' } + 'Get-BudgetHistory' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Cost Management Query API' } + 'Get-AnomalyAlerts' = @{ role = 'Cost Management Reader'; scope = 'Subscription'; api = 'Cost Management Alerts API' } + 'Get-OptimizationAdvice' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Advisor API' } + 'Get-CarbonMetrics' = @{ role = 'Reader or Carbon Optimization Reader'; scope = 'Subscription'; api = 'Carbon Optimization API' } + 'Get-LegacyResources' = @{ role = 'Reader'; scope = 'Subscription'; api = 'Azure Resource Graph' } + 'Get-UnitEconomics' = @{ role = 'Cost Management Reader + Reader'; scope = 'Management Group'; api = 'Cost Management Query API + Azure Resource Graph + Azure Monitor metrics' } + 'Get-AIWorkloadMetrics' = @{ role = 'Cost Management Reader + Reader'; scope = 'Management Group'; api = 'Azure Resource Graph + Monitor Metrics + Cost Management Query API' } + 'Get-BillingStructure' = @{ role = 'Billing Reader'; scope = 'Billing Account'; api = 'Billing API' } + 'Get-ContractInfo' = @{ role = 'Billing Reader'; scope = 'Billing Account'; api = 'Billing API' } + 'Get-MaccCommitment' = @{ role = 'Billing Reader or EA Reader'; scope = 'Billing Account'; api = 'Consumption Lots API' } +} + +# ===================================================================== +# RESOURCE DEFINITIONS +# ===================================================================== +$resourceDefinitions = @( + @{ + uri = 'finops://permissions' + name = 'Permission Requirements' + description = 'Required Azure RBAC roles for each scan module.' + mimeType = 'application/json' + } + @{ + uri = 'finops://modules' + name = 'Available Scan Modules' + description = 'List of all FinOps scan modules with descriptions and categories.' + mimeType = 'application/json' + } +) + +# ===================================================================== +# HELPER: RESOLVE SUBSCRIPTIONS +# ===================================================================== +function Resolve-Subscriptions { + param([string]$SubscriptionId) + + if ($SubscriptionId) { + $sub = Get-AzSubscription -SubscriptionId $SubscriptionId -ErrorAction Stop + return @($sub) + } + + $subs = @(Get-AzSubscription -ErrorAction Stop | Where-Object { $_.State -eq 'Enabled' }) + if ($subs.Count -eq 0) { throw 'No enabled subscriptions found. Run Connect-AzAccount first.' } + return $subs +} + +# ===================================================================== +# HELPER: ACTIVE AZURE CONTEXT (TENANT SAFETY) +# ===================================================================== +# Every cost/governance scan runs against whatever Az session this MCP +# server process holds — which is NOT necessarily the tenant the user +# thinks they're in (the server caches its context at startup, and an +# account can span many tenants). To stop anyone acting on results from +# the wrong tenant, Get-AzContextSummary returns a cheap, no-network +# snapshot of the active account/tenant/subscription that is attached to +# EVERY tool result. Get-AzContextDetail does the fuller, enumerating +# version (accessible tenants + multi-tenant warning) for the dedicated +# get_azure_context tool. +$script:MultiTenantFlag = $null # lazily computed once, then reused cheaply + +function Get-AzContextSummary { + # No network calls — just the in-memory Az context. Safe to call on + # every single tool invocation. + $ctx = Get-AzContext -ErrorAction SilentlyContinue + if (-not $ctx) { + return [ordered]@{ + signedIn = $false + summary = 'NO ACTIVE AZURE SESSION. Results below may be empty. Run Connect-AzAccount, then restart this MCP server.' + verify = 'No tenant context is set. Do not trust scan results until signed in.' + } + } + $tenantId = $ctx.Tenant.Id + $multiHint = if ($null -ne $script:MultiTenantFlag -and $script:MultiTenantFlag) { + ' Your account can see MULTIPLE tenants — confirm this is the intended one.' + } + else { '' } + return [ordered]@{ + signedIn = $true + account = $ctx.Account.Id + tenantId = $tenantId + subscriptionName = $ctx.Subscription.Name + subscriptionId = $ctx.Subscription.Id + environment = $ctx.Environment.Name + summary = "This scan ran as $($ctx.Account.Id) against tenant $tenantId, subscription '$($ctx.Subscription.Name)' ($($ctx.Subscription.Id))." + verify = "Confirm this is the correct tenant/subscription BEFORE acting on these results.$multiHint Call get_azure_context for the full account/tenant picture." + } +} + +function Get-AzContextDetail { + # Fuller view used by the get_azure_context tool. Enumerates the + # subscriptions/tenants the signed-in account can reach so the caller + # can spot a cross-tenant situation. Caches the multi-tenant flag so + # the cheap per-result summary can warn without re-enumerating. + $ctx = Get-AzContext -ErrorAction SilentlyContinue + if (-not $ctx) { + $script:MultiTenantFlag = $false + return [ordered]@{ + signedIn = $false + message = 'No active Azure session. Run Connect-AzAccount and restart this MCP server, then re-run.' + } + } + + $allSubs = @() + try { $allSubs = @(Get-AzSubscription -ErrorAction SilentlyContinue) } catch { } + $tenants = @($allSubs | Select-Object -ExpandProperty TenantId -Unique | Where-Object { $_ }) + $script:MultiTenantFlag = ($tenants.Count -gt 1) + + $activeTenant = $ctx.Tenant.Id + $warning = if ($script:MultiTenantFlag) { + "Your account can access $($tenants.Count) tenants ($($tenants -join ', ')). " + + "Scans run ONLY against the ACTIVE context (tenant $activeTenant). If that is not the tenant you intend, " + + "switch with Set-AzContext (or Connect-AzAccount -TenantId ) and RESTART this MCP server before scanning." + } + else { $null } + + return [ordered]@{ + signedIn = $true + account = $ctx.Account.Id + activeTenantId = $activeTenant + activeSubscriptionName = $ctx.Subscription.Name + activeSubscriptionId = $ctx.Subscription.Id + environment = $ctx.Environment.Name + accessibleTenants = $tenants + accessibleSubscriptionCount = $allSubs.Count + multiTenant = $script:MultiTenantFlag + warning = $warning + summary = "Signed in as $($ctx.Account.Id). Active = tenant $activeTenant / subscription '$($ctx.Subscription.Name)' ($($ctx.Subscription.Id)). This is the ONLY scope scans will touch." + } +} + + +# ===================================================================== +# HELPER: COST DATA SOURCE (HUB) CACHE +# ===================================================================== +# The MCP server reads the FinOps Hub export at most once per +# subscription set, then serves spend-breakdown cost tools from that +# single read. This keeps cost scans fast and avoids repeated Cost +# Management API round-trips. Governance/optimization tools are NOT +# routed here — they always use the live Resource Graph / ARM path. +$script:McpHubCache = @{} + +function Get-McpHubData { + param( + [Parameter(Mandatory)] + [object[]]$Subs, + [string]$TenantId + ) + + $key = (@($Subs.Id) | Sort-Object) -join ',' + if ($script:McpHubCache.ContainsKey($key)) { return $script:McpHubCache[$key] } + + $decision = Resolve-CostDataSource -RequestedSubscriptionIds @($Subs.Id) -TenantId $TenantId + + # Scalable hub path: resolve a Kusto provider (ftklocal override, a + # discovered ADX/Fabric cluster, or none). When one is available we push + # aggregation into the engine and NEVER read raw rows into PowerShell - + # this is what lets the tool scale to large (tens of GB) hub datasets. + $provider = Resolve-FOHubProvider -Subscriptions @($Subs.Id) -Decision $decision + + $raw = $null + if (-not $provider.Found -and $decision.Readable -and $decision.Hub) { + try { + $raw = Read-FinOpsHubData -StorageAccountName $decision.Hub.Name -ResourceGroupName $decision.Hub.ResourceGroup -Months 1 + } + catch { + $raw = $null + } + } + + # No readable hub, but the resolver found a generic Cost Management + # export covering the scope — read its CSV data (newest run per sub, + # overlapping subs deduped) so the cost tools can serve from it. + $exportData = $null + if (-not $provider.Found -and + (-not $raw -or @($raw).Count -eq 0) -and + $decision.ExportFound -and + $decision.Recommendation -in @('UseExport', 'UseExportPartial') -and + (Get-Command Get-MergedCostExportData -ErrorAction SilentlyContinue)) { + try { + $exportData = Get-MergedCostExportData -Exports $decision.Exports + } + catch { + $exportData = $null + } + } + + $entry = @{ Decision = $decision; Provider = $provider; Raw = $raw; ExportData = $exportData } + $script:McpHubCache[$key] = $entry + return $entry +} + +# ===================================================================== +# HELPER: MAP FULL-SCAN RESULTS -> POWER BI SCAN-DATA SHAPE +# ===================================================================== +# New-PowerBITemplate consumes the same hashtable shape the GUI keeps in +# $script:scanData. The full-scan result stores each module's raw output +# under its tool name, so the mapping is a direct key lookup. +function ConvertTo-PbiScanData { + param([Parameter(Mandatory)][object]$FullScan) + + $r = $FullScan.results + if (-not $r) { $r = @{} } + + return @{ + Auth = @{ + TenantId = (Get-AzContext).Tenant.Id + Subscriptions = @($FullScan.subscriptions | ForEach-Object { @{ Name = $_.name; Id = $_.id } }) + } + Costs = $r['scan_cost_data'] + ResourceCosts = $r['scan_resource_costs'] + Tags = $r['scan_tag_inventory'] + TagRecs = $r['scan_tag_recommendations'] + PolicyInv = $r['scan_policy_inventory'] + PolicyRecs = $r['scan_policy_recommendations'] + Budgets = $r['scan_budget_status'] + Orphans = $r['scan_orphaned_resources'] + CostByTag = $r['scan_cost_by_tag'] + CostTrend = $r['scan_cost_trend'] + Commitments = $r['scan_commitment_utilization'] + AHB = $r['scan_ahb_opportunities'] + Optimization = $r['scan_optimization_advice'] + Reservations = $r['scan_reservation_advice'] + Savings = $r['scan_savings_realized'] + } +} + +# ===================================================================== +# HELPER: HUB -> OFFICIAL POWER BI REPORT CONNECTION PARAMETERS +# ===================================================================== +# Shapes a Resolve-CostDataSource decision into the parameter values a +# user plugs into the FinOps Toolkit's official Power BI reports +# (src/power-bi). The Storage reports read the hub's 'ingestion' +# container; the KQL reports read the Data Explorer cluster. The exact +# clusterUri / storageUrlForPowerBI values come from the hub deployment +# Outputs, so we surface what we can infer plus where to copy the rest. +function Get-HubPbiConnection { + param([Parameter(Mandatory)][object]$Decision) + + if (-not $Decision.HubFound -or -not $Decision.Hub) { + return @{ + hubFound = $false + recommendation = 'NoHub' + message = 'No FinOps Hub found in scope. Deploy a FinOps Hub (https://aka.ms/finops/hubs) to use the official Power BI reports, or use generate_powerbi_template for a live-scan report.' + } + } + + $acct = $Decision.Hub.Name + $storageUrl = "https://$acct.dfs.core.windows.net/ingestion" + + return @{ + hubFound = $true + readable = $Decision.Readable + recommendation = $Decision.Recommendation + coveragePct = $Decision.CoveragePct + asOf = $Decision.Freshness + hub = @{ + storageAccount = $acct + resourceGroup = $Decision.Hub.ResourceGroup + subscriptionId = $Decision.Hub.SubscriptionId + location = $Decision.Hub.Location + } + reportParameters = @{ + # Storage-based reports (Cost summary, Rate optimization, etc.) + storageUrlForPowerBI = $storageUrl + ingestionContainer = 'ingestion' + # KQL/ADX-based reports (Data ingestion, etc.) — copy from hub Outputs + clusterUri = '' + } + reports = @( + @{ name = 'Cost summary'; dataSource = 'Storage'; download = 'https://aka.ms/finops/toolkit/CostSummary.pbix' } + @{ name = 'Rate optimization'; dataSource = 'Storage'; download = 'https://aka.ms/finops/toolkit/RateOptimization.pbix' } + @{ name = 'Data ingestion'; dataSource = 'KQL'; download = 'https://aka.ms/finops/toolkit/DataIngestion.pbix' } + ) + instructions = @( + "Download the FinOps Toolkit Power BI reports: https://aka.ms/finops/toolkit/reports", + "Open a report in Power BI Desktop. When prompted, set 'Storage URL' to: $storageUrl", + "For KQL reports, set 'Cluster URI' to the clusterUri value from the hub resource group's deployment Outputs.", + "Authorize the storage source with an account that has Storage Blob Data Reader (or a SAS token).", + "Leave 'Number of Months' empty to load all data, then Apply." + ) + message = $Decision.Message + } +} + +# ===================================================================== +# HELPER: INVOKE TOOL +# ===================================================================== +function Invoke-McpTool { + param( + [string]$ToolName, + [hashtable]$Arguments + ) + + $toolDef = $toolDefinitions | Where-Object { $_.name -eq $ToolName } + if (-not $toolDef) { throw "Unknown tool: $ToolName" } + + # Targeted remediation acts on ONE resource id - it does not enumerate + # subscriptions. Safe-by-default: dry-run unless apply=true is explicit. + if ($toolDef.fn -eq 'Remove-OrphanedResource') { + $rid = [string]$Arguments.resourceId + if (-not $rid) { throw 'resourceId is required for remediate_delete_orphaned_resource.' } + $doApply = ($Arguments.apply -eq $true) + $confToken = if ($Arguments.confirmationToken) { [string]$Arguments.confirmationToken } else { $null } + $remResult = Remove-OrphanedResource -ResourceId $rid -Apply:$doApply -ConfirmationToken $confToken + return @{ + tool = $ToolName + module = 'Remove-OrphanedResource' + category = $toolDef.category + data = $remResult + source = 'LiveApi' + permission = if ($permissionMap.ContainsKey('Remove-OrphanedResource')) { $permissionMap['Remove-OrphanedResource'] } else { $null } + timestamp = (Get-Date -Format 'o') + } + } + + # Targeted reversible remediation (enable AHB / deallocate VM) - one + # resource id, no subscription enumeration, routed through the gate. + if ($toolDef.fn -in @('Enable-HybridBenefit', 'Stop-IdleVm')) { + $rid = [string]$Arguments.resourceId + if (-not $rid) { throw "resourceId is required for $ToolName." } + $doApply = ($Arguments.apply -eq $true) + $confToken = if ($Arguments.confirmationToken) { [string]$Arguments.confirmationToken } else { $null } + $remResult = if ($toolDef.fn -eq 'Enable-HybridBenefit') { + $lt = if ($Arguments.licenseType) { [string]$Arguments.licenseType } else { $null } + if ($lt) { Enable-HybridBenefit -ResourceId $rid -LicenseType $lt -Apply:$doApply -ConfirmationToken $confToken } + else { Enable-HybridBenefit -ResourceId $rid -Apply:$doApply -ConfirmationToken $confToken } + } + else { + Stop-IdleVm -ResourceId $rid -Apply:$doApply -ConfirmationToken $confToken + } + return @{ + tool = $ToolName + module = $toolDef.fn + category = $toolDef.category + data = $remResult + source = 'LiveApi' + permission = if ($permissionMap.ContainsKey($toolDef.fn)) { $permissionMap[$toolDef.fn] } else { $null } + timestamp = (Get-Date -Format 'o') + } + } + + $subId = if ($Arguments.subscriptionId) { $Arguments.subscriptionId } else { $null } + $subIdSubset = if ($Arguments.subscriptionIds) { @($Arguments.subscriptionIds) } else { $null } + + # Resolve the working subscription set, honouring an explicit subset + # (used by the agent to chunk large tenants for incremental progress). + $resolveSubs = { + if ($subIdSubset) { @($subIdSubset | ForEach-Object { Get-AzSubscription -SubscriptionId $_ -ErrorAction Stop }) } + else { Resolve-Subscriptions -SubscriptionId $subId } + } + + # Full scan is a composite tool + if ($toolDef.fn -eq '_full_scan') { + $ds = if ($Arguments.dataSource) { [string]$Arguments.dataSource } else { 'auto' } + return Invoke-FullScan -SubscriptionId $subId -ModuleFilter $Arguments.modules -DataSource $ds + } + + # KPI catalog exploration is a static guidance helper, not a scan + if ($toolDef.fn -eq '_explore_kpis') { + $kpiId = if ($Arguments.kpiId) { [string]$Arguments.kpiId } else { $null } + return @{ + tool = $ToolName + module = 'Get-KpiExploration' + category = $toolDef.category + data = (Get-KpiExploration -KpiId $kpiId) + timestamp = (Get-Date -Format 'o') + } + } + + # Active-context check is a tenant-safety helper, not a scan. It runs + # without resolving subscriptions so it works even when the wrong (or + # no) subscription is selected. + if ($toolDef.fn -eq '_get_context') { + return @{ + tool = $ToolName + module = 'Get-AzContextDetail' + category = $toolDef.category + data = (Get-AzContextDetail) + timestamp = (Get-Date -Format 'o') + } + } + + # Cost data source detection is a routing helper, not a scan + if ($toolDef.fn -eq '_detect_cost_source') { + $subs = & $resolveSubs + $tenantId = (Get-AzContext).Tenant.Id + $decision = Resolve-CostDataSource -RequestedSubscriptionIds @($subs.Id) -TenantId $tenantId + return @{ + tool = $ToolName + module = 'Resolve-CostDataSource' + category = $toolDef.category + data = $decision + timestamp = (Get-Date -Format 'o') + } + } + + # Power BI template generation: full scan -> CSVs + .pbit + if ($toolDef.fn -eq '_generate_powerbi') { + $ds = if ($Arguments.dataSource) { [string]$Arguments.dataSource } else { 'auto' } + $scan = Invoke-FullScan -SubscriptionId $subId -DataSource $ds + $pbiScanData = ConvertTo-PbiScanData -FullScan $scan + + $outDir = if ($Arguments.outputDir) { + [string]$Arguments.outputDir + } + else { + $stamp = Get-Date -Format 'yyyy-MM-dd_HHmmss' + Join-Path ([System.IO.Path]::GetTempPath()) "FinOps-PowerBI-$stamp" + } + + $skel = Join-Path (Join-Path $PSScriptRoot 'assets') 'skeleton.pbit' + $built = New-PowerBITemplate -ScanData $pbiScanData -OutputDir $outDir -SkeletonPath $skel + + return @{ + tool = $ToolName + module = 'New-PowerBITemplate' + category = $toolDef.category + data = @{ + pbitPath = $built.PbitPath + csvCount = $built.CsvCount + outputDir = $built.OutputDir + subscriptions = $scan.subscriptions + costDataSource = $scan.costDataSource + } + note = "Open $($built.PbitPath) in Power BI Desktop. The CsvFolderPath parameter is pre-set to the exported folder; move the folder and the .pbit together or update the parameter." + timestamp = (Get-Date -Format 'o') + } + } + + # Power BI hub connection: detect hub and emit toolkit-report params + if ($toolDef.fn -eq '_connect_powerbi_hub') { + $subs = & $resolveSubs + $tenantId = (Get-AzContext).Tenant.Id + $decision = Resolve-CostDataSource -RequestedSubscriptionIds @($subs.Id) -TenantId $tenantId + return @{ + tool = $ToolName + module = 'Resolve-CostDataSource' + category = $toolDef.category + data = (Get-HubPbiConnection -Decision $decision) + timestamp = (Get-Date -Format 'o') + } + } + + $fn = $toolDef.fn + $subs = & $resolveSubs + $tenantId = (Get-AzContext).Tenant.Id + + # ----------------------------------------------------------------- + # Export-first routing for spend-breakdown cost tools. When a + # readable FinOps Hub export covers the requested scope, serve these + # from the export (one read, cached) instead of the Cost Management + # API. dataSource: auto (default) | hub | api. + # auto -> hub only when the resolver recommends UseHub, else API + # hub -> force hub (error if unreadable/empty) + # api -> skip hub entirely + # Only Get-CostData / Get-ResourceCosts / Get-CostByTag have hub + # converters; all other cost-family tools stay on the live API. + # ----------------------------------------------------------------- + if ($fn -in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag')) { + $requested = if ($Arguments.dataSource) { [string]$Arguments.dataSource } else { 'auto' } + + $hubInfo = $null + if ($requested -ne 'api') { $hubInfo = Get-McpHubData -Subs $subs -TenantId $tenantId } + + # Scalable Kusto path (ADX / Fabric / ftklocal): aggregation is pushed + # into the engine and only summaries come back, so this is preferred + # over the row-reader whenever a cluster is available. On a provider + # error we fall through to the storage/export/API paths below. + if ($hubInfo -and $hubInfo.Provider -and $hubInfo.Provider.Found -and $requested -ne 'api') { + $prov = $hubInfo.Provider + # Match the storage path's scope: serve the whole hub (its coverage + # IS its scope). Per-subscription filtering is a follow-up. + $kustoResult = switch ($fn) { + 'Get-CostData' { Get-FOHubCostSummary -Provider $prov } + 'Get-ResourceCosts' { Get-FOHubResourceCosts -Provider $prov } + 'Get-CostByTag' { Get-FOHubCostByTag -Provider $prov } + } + if (-not ($kustoResult -is [System.Collections.IDictionary] -and $kustoResult.Contains('Error') -and $kustoResult.Error)) { + return @{ + tool = $ToolName + module = $fn + category = $toolDef.category + data = $kustoResult + source = 'FinOpsHubKusto' + asOf = $hubInfo.Decision.Freshness + coveragePct = $hubInfo.Decision.CoveragePct + note = "Served from the FinOps Hub Kusto database ($($prov.Mode); aggregation pushed into the engine, summaries only). Forecast is not included; call with dataSource=api for live forecast." + permission = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null } + timestamp = (Get-Date -Format 'o') + } + } + } + + $useHub = $false + if ($hubInfo -and $hubInfo.Raw -and @($hubInfo.Raw).Count -gt 0) { + if ($requested -eq 'hub') { $useHub = $true } + elseif ($requested -eq 'auto' -and $hubInfo.Decision.Recommendation -eq 'UseHub') { $useHub = $true } + } + + # Generic Cost Management export fast path: no hub, but a readable + # CSV export covers the scope. Same data contract as the hub path, + # so dataSource=hub also accepts it (it is the materialized fast path). + $useExport = $false + if (-not $useHub -and $hubInfo -and $hubInfo.ExportData -and @($hubInfo.ExportData.Rows).Count -gt 0) { + $rec = $hubInfo.Decision.Recommendation + if ($requested -eq 'hub') { $useExport = $true } + elseif ($requested -eq 'auto' -and $rec -in @('UseExport', 'UseExportPartial')) { $useExport = $true } + } + + if ($requested -eq 'hub' -and -not $useHub -and -not $useExport) { + $d = if ($hubInfo) { $hubInfo.Decision } else { $null } + if ($hubInfo -and $hubInfo.Provider -and $hubInfo.Provider.Found) { + # A Kusto cluster was available but the query did not return + # usable data (it errored above and we fell through here). + throw "Hub data requested but the FinOps Hub Kusto query ($($hubInfo.Provider.Mode), $($hubInfo.Provider.ClusterUri)) did not return data. Re-run with dataSource=api to use the live Cost Management API." + } + $reason = if ($d) { "$($d.ReadBlocker): $($d.ReadBlockerDetail)" } else { 'no hub or export found in scope' } + throw "Export data requested but unavailable ($reason). $($d.RemediationHint)" + } + + if ($useHub) { + $hubResult = switch ($fn) { + 'Get-CostData' { ConvertTo-CostDataFromHub -HubData $hubInfo.Raw } + 'Get-ResourceCosts' { ConvertTo-ResourceCostsFromHub -HubData $hubInfo.Raw } + 'Get-CostByTag' { + $tagInv = ConvertTo-TagInventoryFromHub -HubData $hubInfo.Raw + $existingTags = if ($tagInv.TagNames) { $tagInv.TagNames } else { @{} } + ConvertTo-CostByTagFromHub -HubData $hubInfo.Raw -ExistingTags $existingTags + } + } + return @{ + tool = $ToolName + module = $fn + category = $toolDef.category + data = $hubResult + source = 'FinOpsHub' + asOf = $hubInfo.Decision.Freshness + coveragePct = $hubInfo.Decision.CoveragePct + note = 'Served by the FinOps Hub storage reader (billed actuals, rows aggregated in PowerShell). This is the small-dataset convenience path. For large hubs, deploy/point at a Kusto database (Azure Data Explorer / Fabric, or set FINOPS_HUB_KUSTO_URI for a local ftklocal emulator) so aggregation runs in the engine. Forecast is not included; call with dataSource=api for live forecast.' + permission = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null } + timestamp = (Get-Date -Format 'o') + } + } + + if ($useExport) { + $exp = $hubInfo.ExportData + $exportResult = switch ($fn) { + 'Get-CostData' { ConvertTo-CostDataFromExport -ExportData $exp -Subscriptions $subs } + 'Get-ResourceCosts' { ConvertTo-ResourceCostsFromExport -ExportData $exp -Subscriptions $subs } + 'Get-CostByTag' { ConvertTo-CostByTagFromExport -ExportData $exp } + } + return @{ + tool = $ToolName + module = $fn + category = $toolDef.category + data = $exportResult + source = 'CostManagementExport' + asOf = $hubInfo.Decision.Freshness + coveragePct = $hubInfo.Decision.CoveragePct + note = 'Served by the Cost Management export reader (billed actuals, CSV aggregated in PowerShell). This is the small-dataset convenience path; for large datasets use a FinOps Hub Kusto database (engine-side aggregation). Forecast is a linear month-to-date projection; call with dataSource=api for live forecast.' + permission = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null } + timestamp = (Get-Date -Format 'o') + } + } + # otherwise fall through to the live Cost Management API path below + } + + # Build parameter set based on what the function accepts + $cmdInfo = Get-Command $fn -ErrorAction Stop + $params = @{} + + if ($cmdInfo.Parameters.ContainsKey('Subscriptions')) { + $params['Subscriptions'] = $subs + } + if ($cmdInfo.Parameters.ContainsKey('TenantId') -and $tenantId) { + $params['TenantId'] = $tenantId + } + + # VM cost breakdown target args (scan_vm_cost_breakdown) + if ($cmdInfo.Parameters.ContainsKey('VmName') -and $Arguments.vmName) { + $params['VmName'] = [string]$Arguments.vmName + } + if ($cmdInfo.Parameters.ContainsKey('ResourceId') -and $Arguments.resourceId) { + $params['ResourceId'] = [string]$Arguments.resourceId + } + if ($cmdInfo.Parameters.ContainsKey('ResourceGroup') -and $Arguments.resourceGroup) { + $params['ResourceGroup'] = [string]$Arguments.resourceGroup + } + + # Shared cost allocation args (scan_allocate_shared_cost) + if ($cmdInfo.Parameters.ContainsKey('SharedResourceIds') -and $Arguments.sharedResourceIds) { + $params['SharedResourceIds'] = @($Arguments.sharedResourceIds | ForEach-Object { [string]$_ }) + } + if ($cmdInfo.Parameters.ContainsKey('SharedResourceGroup') -and $Arguments.sharedResourceGroup) { + $params['SharedResourceGroup'] = [string]$Arguments.sharedResourceGroup + } + if ($cmdInfo.Parameters.ContainsKey('Spokes') -and $Arguments.spokes) { + $params['Spokes'] = @($Arguments.spokes | ForEach-Object { [string]$_ }) + } + if ($cmdInfo.Parameters.ContainsKey('WeightingMethod') -and $Arguments.weightingMethod) { + $params['WeightingMethod'] = [string]$Arguments.weightingMethod + } + if ($cmdInfo.Parameters.ContainsKey('WeightingValues') -and $null -ne $Arguments.weightingValues) { + $params['WeightingValues'] = $Arguments.weightingValues + } + if ($cmdInfo.Parameters.ContainsKey('WorkspaceId') -and $Arguments.workspaceId) { + $params['WorkspaceId'] = [string]$Arguments.workspaceId + } + if ($cmdInfo.Parameters.ContainsKey('LookbackDays') -and $null -ne $Arguments.lookbackDays) { + $params['LookbackDays'] = [int]$Arguments.lookbackDays + } + if ($cmdInfo.Parameters.ContainsKey('FixedRatio') -and $null -ne $Arguments.fixedRatio) { + $params['FixedRatio'] = [double]$Arguments.fixedRatio + } + + # Cost allocation rule write-back args (set_cost_allocation_rule). + # apply defaults to false (dry-run) - never write unless explicitly true. + if ($cmdInfo.Parameters.ContainsKey('BillingAccountId') -and $Arguments.billingAccountId) { + $params['BillingAccountId'] = [string]$Arguments.billingAccountId + } + if ($cmdInfo.Parameters.ContainsKey('RuleName') -and $Arguments.ruleName) { + $params['RuleName'] = [string]$Arguments.ruleName + } + if ($cmdInfo.Parameters.ContainsKey('SourceResourceGroup') -and $Arguments.sourceResourceGroup) { + $params['SourceResourceGroup'] = @($Arguments.sourceResourceGroup | ForEach-Object { [string]$_ }) + } + if ($cmdInfo.Parameters.ContainsKey('SourceSubscriptionId') -and $Arguments.sourceSubscriptionId) { + $params['SourceSubscriptionId'] = @($Arguments.sourceSubscriptionId | ForEach-Object { [string]$_ }) + } + if ($cmdInfo.Parameters.ContainsKey('Targets') -and $Arguments.targets) { + $params['Targets'] = @($Arguments.targets) + } + if ($cmdInfo.Parameters.ContainsKey('TargetDimension') -and $Arguments.targetDimension) { + $params['TargetDimension'] = [string]$Arguments.targetDimension + } + if ($cmdInfo.Parameters.ContainsKey('Status') -and $Arguments.status) { + $params['Status'] = [string]$Arguments.status + } + if ($cmdInfo.Parameters.ContainsKey('Description') -and $Arguments.description) { + $params['Description'] = [string]$Arguments.description + } + if ($cmdInfo.Parameters.ContainsKey('Apply') -and $Arguments.apply -eq $true) { + $params['Apply'] = $true + } + # Confirmation token for gated write tools that flow through the generic + # path (e.g. Set-CostAllocationRule). Required in Enforced mode; the + # write-safety gate validates it against the previewed change. + if ($cmdInfo.Parameters.ContainsKey('ConfirmationToken') -and $Arguments.confirmationToken) { + $params['ConfirmationToken'] = [string]$Arguments.confirmationToken + } + if ($cmdInfo.Parameters.ContainsKey('ProbeAccess') -and $null -ne $Arguments.probeAccess) { + $params['ProbeAccess'] = [bool]$Arguments.probeAccess + } + + # Usage-proportional showback args (scan_usage_allocation) + if ($cmdInfo.Parameters.ContainsKey('Preset') -and $Arguments.preset) { + $params['Preset'] = [string]$Arguments.preset + } + if ($cmdInfo.Parameters.ContainsKey('PoolAmount') -and $null -ne $Arguments.poolAmount) { + $params['PoolAmount'] = [double]$Arguments.poolAmount + } + if ($cmdInfo.Parameters.ContainsKey('DimensionName') -and $Arguments.dimensionName) { + $params['DimensionName'] = [string]$Arguments.dimensionName + } + if ($cmdInfo.Parameters.ContainsKey('WeightingQuery') -and $Arguments.weightingQuery) { + $params['WeightingQuery'] = [string]$Arguments.weightingQuery + } + + # Hand the FinOps Hub export to functions that accept -HubData (e.g. + # Get-AIWorkloadMetrics). These run their own ARG gate, so they flow + # through the normal call path with the export injected rather than the + # cost-family hub switch above. + if ($cmdInfo.Parameters.ContainsKey('HubData')) { + $aiRequested = if ($Arguments.dataSource) { [string]$Arguments.dataSource } else { 'auto' } + if ($aiRequested -ne 'api') { + $aiHub = Get-McpHubData -Subs $subs -TenantId $tenantId + if ($aiHub -and $aiHub.Raw -and @($aiHub.Raw).Count -gt 0) { + $useAiHub = ($aiRequested -eq 'hub') -or ($aiRequested -eq 'auto' -and $aiHub.Decision.Recommendation -eq 'UseHub') + if ($useAiHub) { $params['HubData'] = $aiHub.Raw } + } + elseif ($aiRequested -eq 'hub') { + $d = if ($aiHub) { $aiHub.Decision } else { $null } + $reason = if ($d) { "$($d.ReadBlocker): $($d.ReadBlockerDetail)" } else { 'no hub found in scope' } + throw "Hub data requested but unavailable ($reason). $($d.RemediationHint)" + } + } + } + + # Handle chained dependencies + if ($toolDef.requiresTagInventory) { + $tagData = Get-TagInventory -Subscriptions $subs + if ($fn -eq 'Get-TagRecommendations') { + $params = @{ ExistingTags = if ($tagData.TagNames) { $tagData.TagNames } else { @{} } } + if ($tagData.TagLocations) { $params['TagLocations'] = $tagData.TagLocations } + } + elseif ($fn -eq 'Get-CostByTag') { + $params['ExistingTags'] = if ($tagData.TagNames) { $tagData.TagNames } else { @{} } + } + } + if ($toolDef.requiresPolicyInventory) { + $policyData = Get-PolicyInventory -Subscriptions $subs -TenantId $tenantId + $params = @{ ExistingAssignments = if ($policyData.Assignments) { $policyData.Assignments } else { @() } } + } + if ($toolDef.requiresBudgets) { + # Budget history depends on Budget Status — discover budgets first + $budgetData = Get-BudgetStatus -Subscriptions $subs + $budgetRows = if ($budgetData.Budgets) { @($budgetData.Budgets) } else { @() } + if ($budgetRows.Count -eq 0) { + return @{ + tool = $ToolName + module = $fn + category = $toolDef.category + data = @() + source = 'LiveApi' + note = 'No budgets configured for the requested scope; no history to report.' + permission = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null } + timestamp = (Get-Date -Format 'o') + } + } + $params = @{ Budgets = $budgetRows } + if ($Arguments.monthsBack) { $params['MonthsBack'] = [int]$Arguments.monthsBack } + } + + # Budget status needs current spend to compute % used / risk. Without it + # every budget reports $0 actual and "On Track" regardless of real spend. + # Fetch per-subscription cost once and pass it in as -CostData so the + # percentages and risk levels are real. + if ($fn -eq 'Get-BudgetStatus') { + try { + $costParams = @{ Subscriptions = $subs } + if ($tenantId) { $costParams['TenantId'] = $tenantId } + $costMap = Get-CostData @costParams + if ($costMap -is [hashtable]) { $params['CostData'] = $costMap } + } + catch { + # Non-fatal: budgets still list. Get-BudgetStatus marks spend as + # Unknown rather than asserting On Track when CostData is absent. + } + } + + # Invoke + $result = & $fn @params + + # Add permission context to result + $permInfo = if ($permissionMap.ContainsKey($fn)) { $permissionMap[$fn] } else { $null } + + return @{ + tool = $ToolName + module = $fn + category = $toolDef.category + data = $result + source = 'LiveApi' + permission = $permInfo + timestamp = (Get-Date -Format 'o') + } +} + +# ===================================================================== +# FULL SCAN (composite tool) +# ===================================================================== +function Invoke-FullScan { + param( + [string]$SubscriptionId, + [string[]]$ModuleFilter, + [string]$DataSource = 'auto' + ) + + $subs = Resolve-Subscriptions -SubscriptionId $SubscriptionId + $tenantId = (Get-AzContext).Tenant.Id + + # Determine which modules to run. A full scan is a READ-ONLY assessment: + # exclude the write/remediation tools (isWrite) and the non-scan meta + # helpers (underscore-prefixed fns like _full_scan, _get_context, + # _generate_powerbi) so the loop only runs diagnostic scan modules and + # never invokes remediation or cost-allocation writes. + $modulesToRun = $toolDefinitions | Where-Object { $_.fn -notlike '_*' -and -not $_.isWrite } + if ($ModuleFilter -and $ModuleFilter.Count -gt 0) { + $modulesToRun = $modulesToRun | Where-Object { $_.name -in $ModuleFilter } + } + + $results = @{} + $errors = @{} + + # ----------------------------------------------------------------- + # Export-first routing for cost-family modules (mirrors single-tool + # routing in Invoke-McpTool). Resolve the hub once; the three modules + # with hub converters serve from the export when usable, else fall + # through to the live Cost Management API. Governance/optimization + # modules always use live APIs. + # auto -> hub only when the resolver recommends UseHub (full cover) + # hub -> force hub (per-module live-API fallback if a convert fails) + # api -> skip hub entirely + # ----------------------------------------------------------------- + $costFns = @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag') + $hubInfo = $null + $hubUsable = $false + if ($DataSource -ne 'api' -and ($modulesToRun | Where-Object { $_.fn -in $costFns })) { + $hubInfo = Get-McpHubData -Subs $subs -TenantId $tenantId + if ($hubInfo -and $hubInfo.Raw -and @($hubInfo.Raw).Count -gt 0) { + if ($DataSource -eq 'hub') { $hubUsable = $true } + elseif ($DataSource -eq 'auto' -and $hubInfo.Decision.Recommendation -eq 'UseHub') { $hubUsable = $true } + } + } + + # Scalable Kusto provider (ADX / Fabric / ftklocal). Preferred over the + # row-reader: cost-family modules push aggregation into the engine and + # return only summaries, so a large hub never loads rows into PowerShell. + $kustoProvider = $null + if ($DataSource -ne 'api' -and $hubInfo -and $hubInfo.Provider -and $hubInfo.Provider.Found) { + $kustoProvider = $hubInfo.Provider + } + + # Generic Cost Management export fast path (no hub, readable CSV export). + $exportUsable = $false + if (-not $hubUsable -and -not $kustoProvider -and $DataSource -ne 'api' -and $hubInfo -and $hubInfo.ExportData -and @($hubInfo.ExportData.Rows).Count -gt 0) { + $rec = $hubInfo.Decision.Recommendation + if ($DataSource -eq 'hub') { $exportUsable = $true } + elseif ($DataSource -eq 'auto' -and $rec -in @('UseExport', 'UseExportPartial')) { $exportUsable = $true } + } + $hubServed = @{} + + # Run Tag Inventory first (other modules depend on it) + $tagData = $null + $tagTool = $modulesToRun | Where-Object { $_.fn -eq 'Get-TagInventory' } + if ($tagTool) { + try { + $tagData = Get-TagInventory -Subscriptions $subs + $results['scan_tag_inventory'] = $tagData + } + catch { $errors['scan_tag_inventory'] = $_.Exception.Message } + } + + # Run Policy Inventory early (policy recommendations depend on it) + $policyData = $null + $policyTool = $modulesToRun | Where-Object { $_.fn -eq 'Get-PolicyInventory' } + if ($policyTool) { + try { + $params = @{ Subscriptions = $subs } + if ($tenantId) { $params['TenantId'] = $tenantId } + $policyData = Get-PolicyInventory @params + $results['scan_policy_inventory'] = $policyData + } + catch { $errors['scan_policy_inventory'] = $_.Exception.Message } + } + + # Run remaining modules + foreach ($tool in $modulesToRun) { + if ($tool.fn -in @('Get-TagInventory', 'Get-PolicyInventory')) { continue } + + try { + $fn = $tool.fn + + # Scalable Kusto path: serve cost-family modules from the engine + # (summaries only). Preferred over the row-reader; on a provider + # error, fall through to Raw / export / live API below. + if ($kustoProvider -and $fn -in $costFns) { + $kr = switch ($fn) { + 'Get-CostData' { Get-FOHubCostSummary -Provider $kustoProvider } + 'Get-ResourceCosts' { Get-FOHubResourceCosts -Provider $kustoProvider } + 'Get-CostByTag' { Get-FOHubCostByTag -Provider $kustoProvider } + } + if (-not ($kr -is [System.Collections.IDictionary] -and $kr.Contains('Error') -and $kr.Error)) { + $results[$tool.name] = $kr + $hubServed[$tool.name] = $true + continue + } + } + + # Export-first: serve cost-family modules from the hub when usable + if ($hubUsable -and $fn -in $costFns) { + $results[$tool.name] = switch ($fn) { + 'Get-CostData' { ConvertTo-CostDataFromHub -HubData $hubInfo.Raw } + 'Get-ResourceCosts' { ConvertTo-ResourceCostsFromHub -HubData $hubInfo.Raw } + 'Get-CostByTag' { + $tagInv = ConvertTo-TagInventoryFromHub -HubData $hubInfo.Raw + $existingTags = if ($tagInv.TagNames) { $tagInv.TagNames } else { @{} } + ConvertTo-CostByTagFromHub -HubData $hubInfo.Raw -ExistingTags $existingTags + } + } + $hubServed[$tool.name] = $true + continue + } + + # Export-first: serve cost-family modules from a generic CSV export + if ($exportUsable -and $fn -in $costFns) { + $exp = $hubInfo.ExportData + $results[$tool.name] = switch ($fn) { + 'Get-CostData' { ConvertTo-CostDataFromExport -ExportData $exp -Subscriptions $subs } + 'Get-ResourceCosts' { ConvertTo-ResourceCostsFromExport -ExportData $exp -Subscriptions $subs } + 'Get-CostByTag' { ConvertTo-CostByTagFromExport -ExportData $exp } + } + $hubServed[$tool.name] = $true + continue + } + + $cmdInfo = Get-Command $fn -ErrorAction Stop + $params = @{} + + if ($cmdInfo.Parameters.ContainsKey('Subscriptions')) { $params['Subscriptions'] = $subs } + if ($cmdInfo.Parameters.ContainsKey('TenantId') -and $tenantId) { $params['TenantId'] = $tenantId } + + # Inject dependencies + if ($tool.requiresTagInventory -and $tagData) { + if ($fn -eq 'Get-TagRecommendations') { + $params = @{ ExistingTags = if ($tagData.TagNames) { $tagData.TagNames } else { @{} } } + if ($tagData.TagLocations) { $params['TagLocations'] = $tagData.TagLocations } + } + elseif ($fn -eq 'Get-CostByTag') { + $params['ExistingTags'] = if ($tagData.TagNames) { $tagData.TagNames } else { @{} } + } + } + if ($tool.requiresPolicyInventory -and $policyData) { + $params = @{ ExistingAssignments = if ($policyData.Assignments) { $policyData.Assignments } else { @() } } + } + if ($tool.requiresBudgets) { + $budgetResult = $results['scan_budget_status'] + $budgetRows = if ($budgetResult -and $budgetResult.Budgets) { @($budgetResult.Budgets) } else { @() } + if ($budgetRows.Count -eq 0) { + $results[$tool.name] = @() + continue + } + $params = @{ Budgets = $budgetRows } + } + + $results[$tool.name] = & $fn @params + } + catch { + $errors[$tool.name] = $_.Exception.Message + } + } + + return @{ + tool = 'run_full_scan' + subscriptions = @($subs | ForEach-Object { @{ id = $_.Id; name = $_.Name } }) + results = $results + errors = $errors + modulesRun = $modulesToRun.Count + costDataSource = if ($kustoProvider) { 'FinOpsHubKusto' } elseif ($hubUsable) { 'FinOpsHub' } elseif ($exportUsable) { 'CostManagementExport' } else { 'LiveApi' } + hubAsOf = if ($hubUsable -or $exportUsable) { $hubInfo.Decision.Freshness } else { $null } + hubCoveragePct = if ($hubUsable -or $exportUsable) { $hubInfo.Decision.CoveragePct } else { $null } + hubServedModules = @($hubServed.Keys) + timestamp = (Get-Date -Format 'o') + } +} + +# ===================================================================== +# JSON-RPC MESSAGE HANDLING +# ===================================================================== +function Send-JsonRpc { + param([object]$Message) + $json = $Message | ConvertTo-Json -Depth 20 -Compress + [Console]::Out.WriteLine($json) + [Console]::Out.Flush() +} + +function Send-Result { + param([object]$Id, [object]$Result) + Send-JsonRpc @{ jsonrpc = '2.0'; id = $Id; result = $Result } +} + +function Send-Error { + param([object]$Id, [int]$Code, [string]$Message) + Send-JsonRpc @{ jsonrpc = '2.0'; id = $Id; error = @{ code = $Code; message = $Message } } +} + +function Handle-Initialize { + param([object]$Id) + Send-Result -Id $Id -Result @{ + protocolVersion = $MCP_VERSION + capabilities = @{ + tools = @{ listChanged = $false } + resources = @{ subscribe = $false; listChanged = $false } + } + serverInfo = @{ + name = $SERVER_NAME + version = $SERVER_VERSION + } + instructions = 'TENANT SAFETY: This server scans whatever Azure session it holds, which it caches at startup and which may differ from the tenant the user expects. ALWAYS call get_azure_context at the start of a session and surface the active account/tenant/subscription to the user before running any scan or remediation. Every tool result also carries an azureContext block — relay its tenant/subscription to the user. If the context is wrong, the user must switch (Set-AzContext / Connect-AzAccount -TenantId) and RESTART this server.' + } +} + +function Handle-ToolsList { + param([object]$Id) + $tools = $toolDefinitions | ForEach-Object { + @{ + name = $_.name + description = $_.description + inputSchema = $_.inputSchema + } + } + Send-Result -Id $Id -Result @{ tools = @($tools) } +} + +function Handle-ToolsCall { + param([object]$Id, [hashtable]$Params) + $toolName = $Params.name + $arguments = if ($Params.arguments) { $Params.arguments } else { @{} } + + try { + # Redirect non-error streams (warning/verbose/debug/information) to + # $null so nothing from module execution leaks onto stdout. The + # function's return value (stream 1) still flows into $result. + $result = Invoke-McpTool -ToolName $toolName -Arguments $arguments 3>$null 4>$null 5>$null 6>$null + # Attach FinOps KPI correlations (additive; no-op for tools with no mapping) + try { $result = Add-KpiInsights -Result $result } catch { } + # Attach the active Azure context to EVERY result so the caller can + # always see which tenant/subscription the scan actually ran against + # and never acts on data from the wrong tenant by accident. + try { + if ($result -is [System.Collections.IDictionary]) { + $result['azureContext'] = Get-AzContextSummary + } + } + catch { } + $json = $result | ConvertTo-Json -Depth 20 -Compress + Send-Result -Id $Id -Result @{ + content = @( + @{ type = 'text'; text = $json } + ) + } + } + catch { + $errMsg = $_.Exception.Message + # Include permission hint if available + $toolDef = $toolDefinitions | Where-Object { $_.name -eq $toolName } + if ($toolDef -and $permissionMap.ContainsKey($toolDef.fn)) { + $perm = $permissionMap[$toolDef.fn] + $errMsg += " | Required: $($perm.role) at $($perm.scope) scope ($($perm.api))" + } + Send-Result -Id $Id -Result @{ + content = @( + @{ type = 'text'; text = $errMsg } + ) + isError = $true + } + } +} + +function Handle-ResourcesList { + param([object]$Id) + $resources = $resourceDefinitions | ForEach-Object { + @{ + uri = $_.uri + name = $_.name + description = $_.description + mimeType = $_.mimeType + } + } + Send-Result -Id $Id -Result @{ resources = @($resources) } +} + +function Handle-ResourcesRead { + param([object]$Id, [hashtable]$Params) + $uri = $Params.uri + + switch ($uri) { + 'finops://permissions' { + $content = $permissionMap | ConvertTo-Json -Depth 5 + Send-Result -Id $Id -Result @{ + contents = @( + @{ uri = $uri; mimeType = 'application/json'; text = $content } + ) + } + } + 'finops://modules' { + $modules = $toolDefinitions | Where-Object { $_.fn -ne '_full_scan' } | ForEach-Object { + @{ name = $_.name; description = $_.description; category = $_.category; function = $_.fn } + } + $content = $modules | ConvertTo-Json -Depth 5 + Send-Result -Id $Id -Result @{ + contents = @( + @{ uri = $uri; mimeType = 'application/json'; text = $content } + ) + } + } + default { + Send-Error -Id $Id -Code -32602 -Message "Unknown resource URI: $uri" + } + } +} + +# ===================================================================== +# MAIN LOOP — Read JSON-RPC from stdin, dispatch, respond +# ===================================================================== +[Console]::Error.WriteLine("FinOps Multitool MCP Server v$SERVER_VERSION starting...") + +# Suppress Write-Host by redirecting the Information stream +$origInfoPref = $InformationPreference +$InformationPreference = 'SilentlyContinue' + +try { + while ($true) { + $line = [Console]::In.ReadLine() + if ($null -eq $line) { break } # stdin closed + $line = $line.Trim() + if ($line -eq '') { continue } + + try { + $msg = $line | ConvertFrom-Json -AsHashtable -ErrorAction Stop + } + catch { + # Skip malformed JSON + [Console]::Error.WriteLine("Malformed JSON-RPC: $line") + continue + } + + $method = $msg.method + $id = $msg.id + $params = if ($msg.params) { $msg.params } else { @{} } + + # Dispatch inside its own try/catch so a single bad request (e.g. a + # handler throwing) can NEVER abandon the read loop and exit the + # process. Requests (id present) get a JSON-RPC internal error; + # notifications (no id) are swallowed. + try { + switch ($method) { + 'initialize' { Handle-Initialize -Id $id } + 'initialized' { <# notification, no response #> } + 'tools/list' { Handle-ToolsList -Id $id } + 'tools/call' { Handle-ToolsCall -Id $id -Params $params } + 'resources/list' { Handle-ResourcesList -Id $id } + 'resources/read' { Handle-ResourcesRead -Id $id -Params $params } + 'notifications/initialized' { <# notification, no response #> } + 'ping' { Send-Result -Id $id -Result @{} } + default { + if ($null -ne $id) { + Send-Error -Id $id -Code -32601 -Message "Method not found: $method" + } + } + } + } + catch { + [Console]::Error.WriteLine("Handler error for method '$method': $($_.Exception.Message)") + if ($null -ne $id) { + try { Send-Error -Id $id -Code -32603 -Message "Internal error handling '$method': $($_.Exception.Message)" } + catch { [Console]::Error.WriteLine("Failed to send error response: $($_.Exception.Message)") } + } + } + } +} +finally { + $InformationPreference = $origInfoPref + [Console]::Error.WriteLine("FinOps Multitool MCP Server stopped.") +} diff --git a/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1 b/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1 new file mode 100644 index 000000000..30df77750 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/Test-McpServer.ps1 @@ -0,0 +1,226 @@ +########################################################################### +# TEST-MCPSERVER.PS1 +# FINOPS MULTITOOL MCP SERVER TEST HARNESS +########################################################################### +# Purpose: End-to-end smoke + integration test for Start-McpServer.ps1. +# Spawns the server as a child process, drives the JSON-RPC +# lifecycle over stdio, and asserts on protocol, tools, +# resources, a live Azure tool call, and error/edge paths. +# Author: Zac Larsen +# Date: Created for FinOps Toolkit MCP integration testing +# +# Description: +# 1. Starts Start-McpServer.ps1 with redirected stdin/stdout/stderr +# 2. Sends initialize / tools/list / resources/* requests and asserts +# 3. Runs a live tools/call against Azure (requires Connect-AzAccount) +# 4. Verifies error handling (unknown tool, bad resource, malformed JSON) +# 5. Prints a pass/fail summary and exits non-zero on any failure +# +# ── Parameters ────────────────────────────────────────────────── +# ServerPath Path to Start-McpServer.ps1 (default: sibling file) +# LiveTool Tool name to call live (default: scan_orphaned_resources) +# SubscriptionId Optional subscription to scope the live call +# SkipLive Skip the live Azure tool call (protocol-only run) +# TimeoutSeconds Per-request response timeout (default: 120) +# +# Prerequisites: +# - PowerShell 7+ (pwsh) +# - Active Azure session (Connect-AzAccount) unless -SkipLive +# +# Usage: .\Test-McpServer.ps1 +# .\Test-McpServer.ps1 -SkipLive +# .\Test-McpServer.ps1 -LiveTool scan_tag_inventory -SubscriptionId +########################################################################### + +[CmdletBinding()] +param( + [string]$ServerPath = (Join-Path $PSScriptRoot 'Start-McpServer.ps1'), + [string]$LiveTool = 'scan_orphaned_resources', + [string]$SubscriptionId, + [switch]$SkipLive, + [int]$TimeoutSeconds = 120 +) + +$ErrorActionPreference = 'Stop' + +$script:Pass = 0 +$script:Fail = 0 +$script:Failures = @() + +function Write-TestResult { + param([string]$Name, [bool]$Ok, [string]$Detail) + if ($Ok) { + $script:Pass++ + Write-Host " [PASS] $Name" -ForegroundColor Green + } + else { + $script:Fail++ + $script:Failures += $Name + Write-Host " [FAIL] $Name" -ForegroundColor Red + if ($Detail) { Write-Host " $Detail" -ForegroundColor DarkYellow } + } +} + +function Invoke-Rpc { + param( + [System.Diagnostics.Process]$Proc, + [string]$Json, + [int]$Timeout = $TimeoutSeconds, + [switch]$NoResponse + ) + $Proc.StandardInput.WriteLine($Json) + $Proc.StandardInput.Flush() + if ($NoResponse) { return $null } + + $task = $Proc.StandardOutput.ReadLineAsync() + if (-not $task.Wait([TimeSpan]::FromSeconds($Timeout))) { + throw "Timed out waiting for response after ${Timeout}s" + } + $line = $task.Result + if ($null -eq $line) { throw 'Server closed stdout unexpectedly' } + return ($line | ConvertFrom-Json) +} + +# ===================================================================== +# PRE-FLIGHT +# ===================================================================== +Write-Host "FinOps Multitool MCP Server — Test Harness" -ForegroundColor Cyan +Write-Host ("=" * 60) + +if (-not (Test-Path $ServerPath)) { + Write-Host "Server script not found: $ServerPath" -ForegroundColor Red + exit 2 +} + +if (-not $SkipLive) { + $ctx = Get-AzContext -ErrorAction SilentlyContinue + if (-not $ctx) { + Write-Host "No active Azure context — live tool call will be skipped. Run Connect-AzAccount or pass -SkipLive." -ForegroundColor Yellow + $SkipLive = $true + } + else { + Write-Host "Azure context: $($ctx.Account.Id) | $($ctx.Subscription.Name)" -ForegroundColor DarkGray + } +} + +# ===================================================================== +# START SERVER PROCESS +# ===================================================================== +$psi = [System.Diagnostics.ProcessStartInfo]::new() +$psi.FileName = 'pwsh' +$psi.ArgumentList.Add('-NoProfile') +$psi.ArgumentList.Add('-File') +$psi.ArgumentList.Add($ServerPath) +$psi.RedirectStandardInput = $true +$psi.RedirectStandardOutput = $true +$psi.RedirectStandardError = $true +$psi.UseShellExecute = $false + +$proc = [System.Diagnostics.Process]::Start($psi) +Start-Sleep -Milliseconds 500 + +try { + # ----------------------------------------------------------------- + # 1. initialize + # ----------------------------------------------------------------- + Write-Host "`nProtocol lifecycle" -ForegroundColor Cyan + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}' + Write-TestResult 'initialize returns serverInfo.name = finops-multitool' ($r.result.serverInfo.name -eq 'finops-multitool') "got: $($r.result.serverInfo.name)" + Write-TestResult 'initialize returns protocolVersion 2024-11-05' ($r.result.protocolVersion -eq '2024-11-05') "got: $($r.result.protocolVersion)" + + # initialized notification (no response expected) + Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","method":"notifications/initialized"}' -NoResponse | Out-Null + + # ----------------------------------------------------------------- + # 2. tools/list + # ----------------------------------------------------------------- + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' + $toolCount = @($r.result.tools).Count + Write-TestResult "tools/list returns 40 tools" ($toolCount -eq 40) "got: $toolCount" + $hasSchema = @($r.result.tools | Where-Object { $_.inputSchema.type -eq 'object' }).Count -eq $toolCount + Write-TestResult 'every tool has an object inputSchema' $hasSchema + + # ----------------------------------------------------------------- + # 3. resources/list + resources/read + # ----------------------------------------------------------------- + Write-Host "`nResources" -ForegroundColor Cyan + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":3,"method":"resources/list"}' + Write-TestResult 'resources/list returns 2 resources' (@($r.result.resources).Count -eq 2) "got: $(@($r.result.resources).Count)" + + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":4,"method":"resources/read","params":{"uri":"finops://permissions"}}' + $permOk = $false + try { $null = $r.result.contents[0].text | ConvertFrom-Json; $permOk = $true } catch {} + Write-TestResult 'resources/read finops://permissions returns valid JSON' $permOk + + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"finops://modules"}}' + $modOk = $false + try { $null = $r.result.contents[0].text | ConvertFrom-Json; $modOk = $true } catch {} + Write-TestResult 'resources/read finops://modules returns valid JSON' $modOk + + # ----------------------------------------------------------------- + # 4. error / edge paths + # ----------------------------------------------------------------- + Write-Host "`nError handling" -ForegroundColor Cyan + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":6,"method":"resources/read","params":{"uri":"finops://does-not-exist"}}' + Write-TestResult 'unknown resource URI returns JSON-RPC error -32602' ($r.error.code -eq -32602) "got: $($r.error.code)" + + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":7,"method":"no/such/method"}' + Write-TestResult 'unknown method returns JSON-RPC error -32601' ($r.error.code -eq -32601) "got: $($r.error.code)" + + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"definitely_not_a_tool","arguments":{}}}' + Write-TestResult 'unknown tool call returns isError result' ($r.result.isError -eq $true) + + # malformed JSON should be skipped; server must survive and answer the next ping + Invoke-Rpc -Proc $proc -Json '{ this is not valid json' -NoResponse | Out-Null + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":9,"method":"ping"}' + Write-TestResult 'server survives malformed JSON and answers ping' ($null -ne $r.result) + + # spec-legal STRING id must be echoed back verbatim (not coerced to int) and + # must not crash the read loop. Regression guard for the [int]$Id id bug. + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":"abc-123","method":"ping"}' + Write-TestResult 'string JSON-RPC id is echoed back verbatim' ($r.id -eq 'abc-123') "got: $($r.id)" + $r = Invoke-Rpc -Proc $proc -Json '{"jsonrpc":"2.0","id":11,"method":"ping"}' + Write-TestResult 'server survives a string-id request and answers the next ping' ($null -ne $r.result) + + # ----------------------------------------------------------------- + # 5. live tool call (requires Azure) + # ----------------------------------------------------------------- + Write-Host "`nLive Azure tool call" -ForegroundColor Cyan + if ($SkipLive) { + Write-Host " [SKIP] live tool call ($LiveTool) — no Azure context or -SkipLive set" -ForegroundColor Yellow + } + else { + $argJson = if ($SubscriptionId) { "{`"subscriptionId`":`"$SubscriptionId`"}" } else { '{}' } + $callJson = "{`"jsonrpc`":`"2.0`",`"id`":10,`"method`":`"tools/call`",`"params`":{`"name`":`"$LiveTool`",`"arguments`":$argJson}}" + Write-Host " calling $LiveTool ..." -ForegroundColor DarkGray + $r = Invoke-Rpc -Proc $proc -Json $callJson + $isError = $r.result.isError -eq $true + if ($isError) { + Write-TestResult "live $LiveTool executed without error" $false $r.result.content[0].text + } + else { + $payload = $null + try { $payload = $r.result.content[0].text | ConvertFrom-Json } catch {} + Write-TestResult "live $LiveTool returns parseable content" ($null -ne $payload) + Write-TestResult "live $LiveTool result echoes tool name" ($payload.tool -eq $LiveTool) "got: $($payload.tool)" + } + } +} +finally { + # Close stdin so the server's read loop exits cleanly + try { $proc.StandardInput.Close() } catch {} + if (-not $proc.WaitForExit(5000)) { try { $proc.Kill() } catch {} } + $proc.Dispose() +} + +# ===================================================================== +# SUMMARY +# ===================================================================== +Write-Host "`n$('=' * 60)" +Write-Host "Results: $script:Pass passed, $script:Fail failed" -ForegroundColor ($(if ($script:Fail -eq 0) { 'Green' } else { 'Red' })) +if ($script:Fail -gt 0) { + Write-Host "Failed tests:" -ForegroundColor Red + $script:Failures | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } + exit 1 +} +exit 0 diff --git a/src/powershell/Private/FinOpsMultitool/assets/skeleton.pbit b/src/powershell/Private/FinOpsMultitool/assets/skeleton.pbit new file mode 100644 index 000000000..c915de039 Binary files /dev/null and b/src/powershell/Private/FinOpsMultitool/assets/skeleton.pbit differ diff --git a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json new file mode 100644 index 000000000..5127d054d --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json @@ -0,0 +1,308 @@ +{ + "_comment": "FinOps Foundation KPI correlation catalog (Phase 1). Curated subset of https://www.finops.org/finops-kpis/ that this MCP server can inform from scan output. Each entry maps one or more source tools to a KPI. 'compute' = the server can calculate a value; 'informational' = the scan relates to the KPI but the value needs the field below or external input. Keep this honest: never claim a value we cannot derive.", + "version": "1.0.0", + "learnMoreBase": "https://www.finops.org/finops-kpis/", + "kpis": [ + { + "id": "cost-per-gb-stored", + "name": "Cost per Gigabyte Stored", + "domain": "Quantify", + "definition": "Average cost per GB stored, influenced by storage tiers and data life-cycle management.", + "sourceTool": "scan_unit_economics", + "compute": true, + "field": "CostPerGb", + "unit": "per GB / month", + "plainLanguage": "What you pay for each GB of stored data this month.", + "exploreHint": "Run scan_storage_tier_advice to see if moving cold data to Cool or Archive lowers this." + }, + { + "id": "hourly-cost-per-cpu-core", + "name": "Hourly Cost per CPU Core", + "domain": "Quantify", + "definition": "Average cost per CPU core, giving unit-cost insight for compute.", + "sourceTool": "scan_unit_economics", + "compute": true, + "field": "CostPerVCpu", + "divideBy": 730, + "unit": "per vCPU / hour", + "plainLanguage": "Roughly what one CPU core costs you per hour.", + "exploreHint": "Run scan_optimization_advice or scan_idle_vms to find rightsizing that lowers this." + }, + { + "id": "effective-avg-compute-cost-per-core", + "name": "Effective Average Compute Cost per Core", + "domain": "Quantify", + "definition": "Average cost per core per month after amortizing unused commitment discounts.", + "sourceTool": "scan_unit_economics", + "compute": true, + "field": "CostPerVCpu", + "unit": "per vCPU / month", + "plainLanguage": "Average monthly cost of one CPU core across your fleet.", + "exploreHint": "Pair with scan_commitment_utilization to amortize unused reservation/savings-plan cost." + }, + { + "id": "pct-costs-untagged", + "name": "Percentage of Costs Associated with Untagged Resources", + "domain": "Understand", + "definition": "Percentage of cloud cost on resources missing a required tag, per your tagging policy.", + "sourceTool": "scan_cost_by_tag", + "compute": true, + "unit": "%", + "plainLanguage": "How much of your spend lands on resources that are not tagged.", + "exploreHint": "Run scan_tag_recommendations to see which CAF tags to backfill and where." + }, + { + "id": "pct-costs-unallocated", + "name": "Percentage of Costs Associated with Unallocated Resources", + "domain": "Understand", + "definition": "Percentage of spend that cannot be attributed to a team, project, or application.", + "sourceTool": "scan_cost_by_tag", + "compute": true, + "unit": "%", + "plainLanguage": "Share of your bill you cannot yet assign to an owner.", + "exploreHint": "Backfill allocation tags (Customer/project/CostCenter), then re-run scan_cost_by_tag." + }, + { + "id": "tagging-policy-compliant", + "name": "Percentage of Costs that are Tagging Policy Compliant", + "domain": "Manage", + "definition": "Share of cost on resources that satisfy your organizational tagging policy.", + "sourceTool": "scan_cost_by_tag", + "compute": true, + "unit": "%", + "plainLanguage": "How much of your spend is on properly tagged resources.", + "exploreHint": "Run scan_policy_recommendations to deploy tag-enforcement policy." + }, + { + "id": "commitment-utilization-score", + "name": "Commitment Utilization Score", + "domain": "Optimize", + "definition": "Burndown of pre-purchased commitment capacity against actual consumption. ~100% is healthy; lower signals shelfware.", + "sourceTool": "scan_commitment_utilization", + "compute": true, + "unit": "%", + "plainLanguage": "How much of your reserved/committed capacity you are actually using.", + "exploreHint": "Run scan_reservation_advice to right-size future commitments." + }, + { + "id": "pct-commitment-discount-waste", + "name": "Percentage of Commitment Discount Waste", + "domain": "Optimize", + "definition": "Percentage of commitment capacity not applied to on-demand spend (wasted).", + "sourceTool": "scan_commitment_utilization", + "compute": true, + "unit": "%", + "plainLanguage": "The slice of your reservations/savings plans you paid for but did not use.", + "exploreHint": "100% minus your utilization. Investigate underused commitments for renewal changes." + }, + { + "id": "pct-compute-covered-by-commitment", + "name": "Percent of Compute Spend Covered by Commitment Discounts", + "domain": "Optimize", + "definition": "Percentage of compute cost (excluding Spot) covered by commitment discounts.", + "sourceTool": "scan_savings_realized", + "compute": true, + "unit": "%", + "plainLanguage": "How much of your eligible spend rides on a discounted commitment (reservation or savings plan) versus full on-demand. Computed from amortized cost by pricing model, excluding Spot from the eligible base.", + "exploreHint": "Raise coverage by buying reservations or savings plans for steady-state workloads." + }, + { + "id": "percent-unused-resources", + "name": "Percent of Unused Resources", + "domain": "Optimize", + "definition": "Measure of unused resources such as unattached disks, idle NICs, and orphaned public IPs.", + "sourceTool": "scan_orphaned_resources", + "compute": true, + "unit": "count / cost", + "plainLanguage": "Resources you are paying for that nothing is using. Shown as the orphaned-resource count found by the scan.", + "exploreHint": "Run remediate_delete_orphaned_resource (gated) to clean them up." + }, + { + "id": "computational-waste", + "name": "Computational Waste Percentage", + "domain": "Optimize", + "definition": "Spend that provided zero business utility, e.g. idle resources before suspension or over-provisioning.", + "sourceTool": "scan_idle_vms", + "compute": true, + "unit": "count / cost", + "plainLanguage": "Compute you are running and paying for but barely using. Shown as the share of running VMs flagged idle or underutilized.", + "exploreHint": "Run remediate_deallocate_vm (gated, reversible) on confirmed idle VMs." + }, + { + "id": "percent-storage-frequent-tier", + "name": "Percent Storage on Frequent Access Tier", + "domain": "Optimize", + "definition": "Percentage of object storage held on a frequent (Hot) access tier.", + "sourceTool": "scan_storage_tier_advice", + "compute": false, + "unit": "%", + "plainLanguage": "How much of your storage sits on the most expensive Hot tier.", + "exploreHint": "Flagged Hot accounts with low activity are candidates for Cool or Archive." + }, + { + "id": "storage-decay-ratio", + "name": "Storage Decay Ratio", + "domain": "Optimize", + "definition": "Storage cost attributed to data not accessed within a set window (dark data).", + "sourceTool": "scan_storage_tier_advice", + "compute": false, + "unit": "%", + "plainLanguage": "Share of storage spend on data nobody has touched recently.", + "exploreHint": "Low-activity accounts in this scan approximate dark data; apply lifecycle policy." + }, + { + "id": "budget-burn-rate", + "name": "CSP Cloud Budget Burn Rate", + "domain": "Manage", + "definition": "The rate at which an organization is consuming its allocated cloud budget.", + "sourceTool": "scan_budget_status", + "compute": true, + "unit": "%", + "plainLanguage": "How fast you are spending against your budget this period. Shown as the average percent of budget consumed across all budgets.", + "exploreHint": "Risk levels in the scan flag budgets trending over. Adjust thresholds or spend." + }, + { + "id": "variance-budget-vs-actual", + "name": "Percentage Variance of Budgeted vs. Actual Spend", + "domain": "Manage", + "definition": "Difference between budgeted cost and actual cost incurred.", + "sourceTool": "scan_budget_status", + "compute": true, + "unit": "%", + "plainLanguage": "How far your actual spend is from what you planned. Shown as total actual vs total budgeted across all budgets.", + "exploreHint": "Compare budget amount vs actual in the scan rows." + }, + { + "id": "anomaly-detection-rate", + "name": "Anomaly Detection Rate", + "domain": "Manage", + "definition": "Frequency and cost impact of detected spending anomalies.", + "sourceTool": "scan_anomaly_alerts", + "compute": true, + "unit": "count", + "plainLanguage": "How often unusual cost spikes are being caught. Shown as a proxy: anomaly alerts triggered plus the number of detection rules configured (a true rate needs the count of anomalies that occurred, which Azure does not expose).", + "exploreHint": "Configure anomaly alert rules so spikes are caught proactively." + }, + { + "id": "effective-savings-rate", + "name": "Effective Savings Rate Percentage", + "domain": "Quantify", + "definition": "Return-on-investment metric across all commitment discounts and optimizations.", + "sourceTool": "scan_savings_realized", + "compute": true, + "unit": "%", + "plainLanguage": "How much you are actually saving versus paying full on-demand rates. Shown as realized monthly savings from RIs, Savings Plans and AHB (a true rate also needs total on-demand-equivalent spend).", + "exploreHint": "Realized savings from RIs, Savings Plans and AHB appear in this scan." + }, + { + "id": "pct-legacy-resource", + "name": "Percentage of Legacy Resource", + "domain": "Optimize", + "definition": "Percentage of resources running on older generation types vs modern equivalents.", + "sourceTool": "scan_legacy_resources", + "compute": false, + "unit": "%", + "plainLanguage": "How much of your estate runs on older, less efficient resource types.", + "exploreHint": "Modernizing to newer SKUs usually improves price-performance." + }, + { + "id": "carbon-per-unit-spend", + "name": "Carbon per Unit of Cloud Spend", + "domain": "Quantify", + "definition": "Carbon emissions (CO2e) per unit of cloud spend, ideally drillable by service category.", + "sourceTool": "scan_carbon", + "compute": false, + "unit": "CO2e / spend", + "plainLanguage": "How much carbon each dollar of cloud spend produces.", + "exploreHint": "Carbon-efficient regions and modern SKUs reduce this." + }, + { + "id": "token-consumption-metrics", + "name": "Token Consumption Metrics", + "domain": "Quantify", + "definition": "Cost of token-based models based on input/output token usage.", + "sourceTool": "scan_ai_workloads", + "compute": true, + "unit": "tokens / cost", + "plainLanguage": "How many tokens your AI workloads burn and what they cost.", + "exploreHint": "Prompt engineering and model choice move this number." + }, + { + "id": "cost-per-api-call", + "name": "Cost per API Call", + "domain": "Quantify", + "definition": "Average cost for each API call made to AI services.", + "sourceTool": "scan_ai_workloads", + "compute": true, + "unit": "per call", + "plainLanguage": "What each AI service request costs you on average.", + "exploreHint": "High per-call cost can signal an inefficient model or call pattern." + }, + { + "id": "pct-unallocated-shared-cost", + "name": "Percentage of Unallocated Shared Cost", + "domain": "Understand", + "definition": "Shared expenses that cannot be directly attributed to a specific team or project.", + "sourceTool": "scan_allocate_shared_cost", + "compute": false, + "unit": "%", + "plainLanguage": "Shared platform cost not yet split across its consumers.", + "exploreHint": "Use scan_allocate_shared_cost or scan_usage_allocation to split it." + }, + { + "id": "allocation-accuracy-index", + "name": "Allocation Accuracy Index (AAI)", + "domain": "Understand", + "definition": "Percentage of total cost directly and accurately attributed to responsible owners.", + "sourceTool": "scan_usage_allocation", + "compute": false, + "unit": "%", + "plainLanguage": "How reliably your costs map to the right team or project.", + "exploreHint": "Improve tagging and shared-cost rules to raise this." + }, + { + "id": "tco-per-workload", + "name": "Total Cost of Ownership per Workload", + "domain": "Quantify", + "definition": "Average full-lifecycle cost of running an individual workload (compute, storage, network, etc.).", + "sourceTool": "scan_vm_cost_breakdown", + "compute": false, + "unit": "cost / workload", + "plainLanguage": "The true all-in cost of one workload, not just its compute line.", + "exploreHint": "scan_vm_cost_breakdown decomposes a VM into all its billed meters." + }, + { + "id": "frequency-of-data-updates", + "name": "Frequency of Data Updates", + "domain": "Manage", + "definition": "Time between updates of cost data (e.g. since the last export refresh).", + "sourceTool": "detect_cost_data_source", + "compute": false, + "unit": "freshness date", + "plainLanguage": "How current the cost data behind your answers is.", + "exploreHint": "The Freshness date in this tool is your data-update recency." + }, + { + "id": "cost-visibility-delay", + "name": "Cost Visibility Delay", + "domain": "Manage", + "definition": "Time between a cost occurring and it being ingested, normalized, and visible.", + "sourceTool": "detect_cost_data_source", + "compute": false, + "unit": "days", + "plainLanguage": "The lag between spending money and seeing it in reports.", + "exploreHint": "Export-based reads are typically a day behind; live API is near-real-time." + }, + { + "id": "unified-cost-usage-visibility", + "name": "Unified Cost & Usage Visibility", + "domain": "Understand", + "definition": "Extent to which all relevant cost and usage sources are integrated into one reporting system.", + "sourceTool": "detect_cost_data_source", + "compute": false, + "unit": "%", + "plainLanguage": "How much of your estate is covered by one unified cost view.", + "exploreHint": "CoveragePct in this tool reflects how much of scope the hub/export covers." + } + ] +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1 new file mode 100644 index 000000000..a3481d94d --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Deploy-PolicyAssignment.ps1 @@ -0,0 +1,212 @@ +########################################################################### +# DEPLOY-POLICYASSIGNMENT.PS1 +# AZURE FINOPS MULTITOOL - Deploy Azure Policy Assignments +########################################################################### +# Purpose: Create a policy assignment at a given scope (management group, +# subscription, or resource group) for a built-in policy +# definition with a user-selected effect. +# +# Uses ARM REST API PUT to create policy assignments. +########################################################################### + +function Deploy-PolicyAssignment { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Scope, # /subscriptions/xxx or /subscriptions/xxx/resourceGroups/yyy + + [Parameter(Mandatory)] + [string]$PolicyDefinitionId, # Full built-in policy def resource ID + + [Parameter(Mandatory)] + [string]$Effect, # Audit, Deny, Disabled, etc. + + [string]$DisplayName = '', + + [hashtable]$AdditionalParameters = @{} + ) + + # Input validation + if ($Scope -notmatch '^/subscriptions/[a-f0-9-]+') { + throw "Invalid scope format. Must start with /subscriptions/{guid}." + } + if ($Effect -notin @('Audit','Deny','Disabled','AuditIfNotExists','DeployIfNotExists','Modify','Append')) { + throw "Invalid effect: $Effect. Must be one of: Audit, Deny, Disabled, AuditIfNotExists, DeployIfNotExists, Modify, Append" + } + $isInitiative = $PolicyDefinitionId -match '/policySetDefinitions/' + if ($PolicyDefinitionId -notmatch '^/providers/Microsoft\.Authorization/policy(Set)?Definitions/') { + throw "Invalid policy definition ID format." + } + + # Generate a unique assignment name (max 128 chars, alphanumeric + hyphens) + $defGuid = ($PolicyDefinitionId -split '/')[-1] + $scopeHash = [System.BitConverter]::ToString( + [System.Security.Cryptography.SHA256]::Create().ComputeHash( + [System.Text.Encoding]::UTF8.GetBytes($Scope) + ) + ).Replace('-','').Substring(0,8).ToLower() + $assignName = "finops-$scopeHash-$defGuid" + if ($assignName.Length -gt 128) { $assignName = $assignName.Substring(0, 128) } + + $assignDisplayName = if ($DisplayName) { "FinOps: $DisplayName" } else { "FinOps Policy Assignment" } + + Write-Host " Deploying policy assignment '$assignDisplayName' to scope: $Scope" -ForegroundColor Cyan + Write-Host " Effect: $Effect | Definition: $defGuid" -ForegroundColor Cyan + + # Query the policy definition to discover which parameters it actually accepts + $validParamNames = @() + try { + $defPath = "$($PolicyDefinitionId)?api-version=2021-06-01" + $defResp = Invoke-AzRestMethodWithRetry -Path $defPath -Method GET + if ($defResp.StatusCode -eq 200) { + $defObj = $defResp.Content | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($defObj.properties.parameters) { + $validParamNames = @($defObj.properties.parameters.PSObject.Properties.Name) + Write-Host " Valid parameters: $($validParamNames -join ', ')" -ForegroundColor Gray + } + } + } catch { + Write-Host " Could not query policy definition parameters, sending all." -ForegroundColor Yellow + } + + # Build parameters - only include params the definition accepts + $policyParams = @{} + # Include effect only if the definition has an effect parameter + if ($validParamNames.Count -eq 0 -or $validParamNames -contains 'effect') { + $policyParams['effect'] = @{ value = $Effect } + } + foreach ($key in $AdditionalParameters.Keys) { + if ($validParamNames.Count -eq 0 -or $validParamNames -contains $key) { + $policyParams[$key] = @{ value = $AdditionalParameters[$key] } + } else { + Write-Host " Skipping parameter '$key' - not defined in policy definition." -ForegroundColor Yellow + } + } + + $body = @{ + properties = @{ + displayName = $assignDisplayName + description = "Deployed by Azure FinOps Multitool" + policyDefinitionId = $PolicyDefinitionId + parameters = $policyParams + enforcementMode = 'Default' + } + } | ConvertTo-Json -Depth 10 + + $assignPath = "$Scope/providers/Microsoft.Authorization/policyAssignments/$($assignName)?api-version=2022-06-01" + + try { + $response = Invoke-AzRestMethodWithRetry -Path $assignPath -Method PUT -Payload $body + if ($response.StatusCode -in @(200, 201)) { + Write-Host " Policy assignment created successfully." -ForegroundColor Green + return [PSCustomObject]@{ + Success = $true + Message = "Policy '$assignDisplayName' assigned with effect '$Effect' to $Scope" + StatusCode = $response.StatusCode + AssignmentName = $assignName + } + } else { + $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue) + $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" } + Write-Warning " Policy assignment failed: $errMsg" + return [PSCustomObject]@{ + Success = $false + Message = $errMsg + StatusCode = $response.StatusCode + } + } + } catch { + Write-Warning " Policy assignment error: $($_.Exception.Message)" + return [PSCustomObject]@{ + Success = $false + Message = $_.Exception.Message + StatusCode = 0 + } + } +} + +function Remove-PolicyAssignment { + <# + .SYNOPSIS + Deletes a policy assignment by its full ARM assignment ID. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$AssignmentId # Full ARM resource ID of the assignment + ) + + Write-Host " Removing policy assignment: $AssignmentId" -ForegroundColor Cyan + + $deletePath = "$($AssignmentId)?api-version=2022-06-01" + + try { + $response = Invoke-AzRestMethodWithRetry -Path $deletePath -Method DELETE + if ($response.StatusCode -in @(200, 204)) { + Write-Host " Policy assignment removed successfully." -ForegroundColor Green + return [PSCustomObject]@{ + Success = $true + Message = "Policy assignment removed" + StatusCode = $response.StatusCode + } + } else { + $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue) + $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" } + Write-Warning " Policy removal failed: $errMsg" + return [PSCustomObject]@{ + Success = $false + Message = $errMsg + StatusCode = $response.StatusCode + } + } + } catch { + Write-Warning " Policy removal error: $($_.Exception.Message)" + return [PSCustomObject]@{ + Success = $false + Message = $_.Exception.Message + StatusCode = 0 + } + } +} + +function Get-PolicyScopes { + <# + .SYNOPSIS + Returns available scopes (subscriptions + resource groups) for policy assignment. + Identical pattern to Get-TagScopes but for policy deployment. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $scopes = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($sub in $Subscriptions) { + [void]$scopes.Add([PSCustomObject]@{ + DisplayName = "[Sub] $($sub.Name)" + Scope = "/subscriptions/$($sub.Id)" + Type = 'Subscription' + }) + + try { + $rgPath = "/subscriptions/$($sub.Id)/resourcegroups?api-version=2021-04-01" + $resp = Invoke-AzRestMethodWithRetry -Path $rgPath -Method GET + if ($resp.StatusCode -eq 200) { + $rgs = ($resp.Content | ConvertFrom-Json).value + foreach ($rg in $rgs) { + [void]$scopes.Add([PSCustomObject]@{ + DisplayName = " [RG] $($sub.Name) / $($rg.name)" + Scope = "/subscriptions/$($sub.Id)/resourceGroups/$($rg.name)" + Type = 'ResourceGroup' + }) + } + } + } catch { + Write-Warning " Could not list RGs for $($sub.Name): $($_.Exception.Message)" + } + } + + return $scopes +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1 new file mode 100644 index 000000000..df5d9aa6d --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Deploy-ResourceTag.ps1 @@ -0,0 +1,217 @@ +########################################################################### +# DEPLOY-RESOURCETAG.PS1 +# AZURE FINOPS MULTITOOL - Deploy Tags to Azure Resources +########################################################################### +# Purpose: Apply a tag (name + value) to a subscription, resource group, +# or individual resource via ARM REST API (PATCH merge). +# Preserves existing tags -- only adds or updates the target tag. +########################################################################### + +function Deploy-ResourceTag { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Scope, # Full ARM resource ID (/subscriptions/xxx or /subscriptions/xxx/resourceGroups/yyy or full resource ID) + + [Parameter(Mandatory)] + [string]$TagName, + + [Parameter(Mandatory)] + [string]$TagValue + ) + + # Input validation + if ($Scope -notmatch '^/subscriptions/[a-f0-9-]+') { + throw "Invalid scope format. Must start with /subscriptions/{guid}." + } + if ($TagName -match '[<>&''"\\]') { + throw "Tag name contains invalid characters." + } + if ($TagValue -match '[<>&''"]' -and $TagValue.Length -gt 256) { + throw "Tag value exceeds 256 characters." + } + + Write-Host " Deploying tag '$TagName=$TagValue' to scope: $Scope" -ForegroundColor Cyan + + # Use the Tags API to merge (preserves existing tags) + $uri = "https://management.azure.com$Scope/providers/Microsoft.Resources/tags/default?api-version=2021-04-01" + + $body = @{ + operation = 'Merge' + properties = @{ + tags = @{ + $TagName = $TagValue + } + } + } | ConvertTo-Json -Depth 5 + + # Use Invoke-WebRequest with timeout to prevent indefinite hanging + # (Invoke-AzRestMethod has no timeout parameter) + $token = Get-PlainAccessToken + $headers = @{ + 'Authorization' = "Bearer $token" + 'Content-Type' = 'application/json' + } + + try { + $response = Invoke-WebRequest -Uri $uri -Method PATCH -Body $body -Headers $headers ` + -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop + if ([int]$response.StatusCode -in @(200, 201)) { + Write-Host " Tag deployed successfully." -ForegroundColor Green + return [PSCustomObject]@{ + Success = $true + Message = "Tag '$TagName=$TagValue' applied to $Scope" + StatusCode = [int]$response.StatusCode + } + } else { + $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue) + $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" } + Write-Warning " Tag deployment failed: $errMsg" + return [PSCustomObject]@{ + Success = $false + Message = $errMsg + StatusCode = [int]$response.StatusCode + } + } + } catch { + $errMsg = $_.Exception.Message + $statusCode = 0 + # Extract error details from HTTP error responses + if ($_.Exception -is [System.Net.WebException] -and $_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + try { + $sr = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream()) + $errContent = $sr.ReadToEnd(); $sr.Close() + $errBody = $errContent | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($errBody.error) { $errMsg = $errBody.error.message } + } catch {} + } + $safeMsg = $errMsg -replace 'Bearer [^\s]+', 'Bearer ***REDACTED***' + Write-Warning " Tag deployment failed: $safeMsg" + return [PSCustomObject]@{ + Success = $false + Message = $safeMsg + StatusCode = $statusCode + } + } +} + +function Remove-ResourceTag { + <# + .SYNOPSIS + Removes a tag from a subscription or resource group via ARM Tags API (DELETE operation). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Scope, + + [Parameter(Mandatory)] + [string]$TagName + ) + + # Input validation + if ($Scope -notmatch '^/subscriptions/[a-f0-9-]+') { + throw "Invalid scope format. Must start with /subscriptions/{guid}." + } + + Write-Host " Removing tag '$TagName' from scope: $Scope" -ForegroundColor Cyan + + $uri = "https://management.azure.com$Scope/providers/Microsoft.Resources/tags/default?api-version=2021-04-01" + + $body = @{ + operation = 'Delete' + properties = @{ + tags = @{ + $TagName = '' + } + } + } | ConvertTo-Json -Depth 5 + + $token = Get-PlainAccessToken + $headers = @{ + 'Authorization' = "Bearer $token" + 'Content-Type' = 'application/json' + } + + try { + $response = Invoke-WebRequest -Uri $uri -Method PATCH -Body $body -Headers $headers ` + -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop + if ([int]$response.StatusCode -in @(200, 201)) { + Write-Host " Tag removed successfully." -ForegroundColor Green + return [PSCustomObject]@{ + Success = $true + Message = "Tag '$TagName' removed from $Scope" + StatusCode = [int]$response.StatusCode + } + } else { + $errBody = ($response.Content | ConvertFrom-Json -ErrorAction SilentlyContinue) + $errMsg = if ($errBody.error) { $errBody.error.message } else { "HTTP $($response.StatusCode)" } + return [PSCustomObject]@{ + Success = $false + Message = $errMsg + StatusCode = [int]$response.StatusCode + } + } + } catch { + $errMsg = $_.Exception.Message + $statusCode = 0 + if ($_.Exception -is [System.Net.WebException] -and $_.Exception.Response) { + $statusCode = [int]$_.Exception.Response.StatusCode + try { + $sr = [System.IO.StreamReader]::new($_.Exception.Response.GetResponseStream()) + $errContent = $sr.ReadToEnd(); $sr.Close() + $errBody = $errContent | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($errBody.error) { $errMsg = $errBody.error.message } + } catch {} + } + return [PSCustomObject]@{ + Success = $false + Message = $errMsg + StatusCode = $statusCode + } + } +} + +function Get-TagScopes { + <# + .SYNOPSIS + Returns available scopes (subscriptions + resource groups) for tag deployment. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $scopes = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($sub in $Subscriptions) { + # Add subscription itself + [void]$scopes.Add([PSCustomObject]@{ + DisplayName = "[Sub] $($sub.Name)" + Scope = "/subscriptions/$($sub.Id)" + Type = 'Subscription' + }) + + # Get resource groups + try { + $rgPath = "/subscriptions/$($sub.Id)/resourcegroups?api-version=2021-04-01" + $resp = Invoke-AzRestMethodWithRetry -Path $rgPath -Method GET + if ($resp.StatusCode -eq 200) { + $rgs = ($resp.Content | ConvertFrom-Json).value + foreach ($rg in $rgs) { + [void]$scopes.Add([PSCustomObject]@{ + DisplayName = " [RG] $($sub.Name) / $($rg.name)" + Scope = "/subscriptions/$($sub.Id)/resourceGroups/$($rg.name)" + Type = 'ResourceGroup' + }) + } + } + } catch { + Write-Warning " Could not list RGs for $($sub.Name): $($_.Exception.Message)" + } + } + + return $scopes +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 new file mode 100644 index 000000000..0f3f44287 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 @@ -0,0 +1,176 @@ +########################################################################### +# ENABLE-HYBRIDBENEFIT.PS1 +# AZURE FINOPS MULTITOOL - Enable Azure Hybrid Benefit on a VM +########################################################################### +# Purpose: Turn on Azure Hybrid Benefit (AHB) for a single VM found by +# scan_ahb_opportunities. AHB applies existing Windows Server / +# SQL licenses to cut compute licensing cost up to ~85%. +# +# Description: +# REVERSIBLE, savings-only write (you can set the license back to None). +# 1. Validates the resource id (Microsoft.Compute/virtualMachines only). +# 2. Reads the VM, detects OS, and picks the right licenseType +# (Windows -> Windows_Server) unless one is passed explicitly. +# 3. No-ops if AHB is already enabled. +# 4. Routes through the configurable write-safety gate (dry-run by +# default; token required only in Enforced mode). +# +# ── Parameters ────────────────────────────────────────────────────── +# ResourceId Full ARM resource ID of the VM +# LicenseType Override: Windows_Server | Windows_Client | +# RHEL_BYOS | SLES_BYOS (auto-detected if omitted) +# Apply Omitted = dry-run preview. Present = PATCH the VM. +# ConfirmationToken Required only in Enforced mode (from the preview) +# +# Usage: Enable-HybridBenefit -ResourceId [-Apply] +########################################################################### + +function Enable-HybridBenefit { + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes are gated by the explicit -Apply switch and routed through Resolve-WriteDecision (dry-run by default, mode/guardrail/confirmation-token enforcement, and audit logging).')] + param( + [Parameter(Mandatory)] + [string]$ResourceId, + + [Parameter()] + [ValidateSet('Windows_Server', 'Windows_Client', 'RHEL_BYOS', 'SLES_BYOS')] + [string]$LicenseType, + + [Parameter()] + [switch]$Apply, + + [Parameter()] + [string]$ConfirmationToken + ) + + $apiVersion = '2024-07-01' + + if ($ResourceId -notmatch '/providers/Microsoft\.Compute/virtualMachines/(?[^/]+)$') { + return [PSCustomObject]@{ + HasData = $false + Error = 'This tool only enables AHB on Microsoft.Compute/virtualMachines. Pass a VM resource ID.' + } + } + $vmName = $Matches.name + $path = "$ResourceId`?api-version=$apiVersion" + + # ---- Read the VM ---- + $getResp = Invoke-AzRestMethodWithRetry -Path $path -Method 'GET' + $getStatus = if ($getResp) { [int]$getResp.StatusCode } else { 0 } + if ($getStatus -eq 404) { + return [PSCustomObject]@{ HasData = $true; Applied = $false; ResourceId = $ResourceId; Note = 'VM not found.' } + } + if ($getStatus -lt 200 -or $getStatus -ge 300) { + $gErr = $null + if ($getResp -and $getResp.Content) { try { $gErr = ($getResp.Content | ConvertFrom-Json).error.message } catch { $gErr = $getResp.Content } } + return [PSCustomObject]@{ HasData = $false; Error = "Could not read the VM (HTTP $getStatus). $gErr"; ResourceId = $ResourceId } + } + + $vm = $null + try { $vm = $getResp.Content | ConvertFrom-Json -ErrorAction Stop } catch {} + $props = if ($vm) { $vm.properties } else { $null } + $osType = if ($props -and $props.storageProfile -and $props.storageProfile.osDisk) { [string]$props.storageProfile.osDisk.osType } else { '' } + $currentLicense = if ($props -and $props.licenseType) { [string]$props.licenseType } else { 'None' } + $location = if ($vm) { $vm.location } else { $null } + + # ---- Decide the target license type ---- + if (-not $LicenseType) { + if ($osType -ieq 'Windows') { $LicenseType = 'Windows_Server' } + elseif ($osType -ieq 'Linux') { + return [PSCustomObject]@{ + HasData = $false + Error = "VM '$vmName' is Linux. AHB for Linux requires the exact distro license (RHEL_BYOS or SLES_BYOS). Re-run with an explicit licenseType only if this VM is RHEL/SLES BYOS-eligible." + ResourceId = $ResourceId + CurrentLicense = $currentLicense + } + } + else { + return [PSCustomObject]@{ HasData = $false; Error = "Could not determine OS type for '$vmName'. Pass licenseType explicitly."; ResourceId = $ResourceId } + } + } + + # ---- Already enabled? No-op. ---- + if ($currentLicense -ieq $LicenseType) { + return [PSCustomObject]@{ + HasData = $true + Mode = 'NoOp' + Applied = $false + Note = "AHB already enabled on '$vmName' (licenseType=$currentLicense). Nothing to do." + ResourceId = $ResourceId + ResourceName = $vmName + } + } + + $subId = if ($ResourceId -match '/subscriptions/([^/]+)/') { $Matches[1] } else { $null } + $rg = if ($ResourceId -match '/resourceGroups/([^/]+)/') { $Matches[1] } else { $null } + $tagHash = @{} + if ($vm -and $vm.tags) { foreach ($t in $vm.tags.PSObject.Properties) { $tagHash[$t.Name] = $t.Value } } + + # ---- Write-safety gate (reversible, savings-only -> impact 0) ---- + $decision = Resolve-WriteDecision -ToolName 'remediate_enable_hybrid_benefit' -Operation 'EnableAHB' ` + -ResourceId $ResourceId -SubscriptionId $subId -ResourceGroup $rg -Tags $tagHash ` + -EstimatedMonthlyImpact 0 -Reversible $true -Apply:$Apply -ConfirmationToken $ConfirmationToken ` + -FingerprintExtra @{ licenseType = $LicenseType } + + if ($decision.Decision -eq 'Blocked') { + return [PSCustomObject]@{ + HasData = $false; Mode = 'Blocked'; Applied = $false; Error = $decision.Reason + GuardrailViolations = @($decision.GuardrailViolations); WriteMode = $decision.Mode + ResourceId = $ResourceId; ResourceName = $vmName + } + } + + $bodyJson = @{ properties = @{ licenseType = $LicenseType } } | ConvertTo-Json -Depth 5 + + if ($decision.Decision -eq 'Preview') { + return [PSCustomObject]@{ + HasData = $true + Mode = 'DryRun' + Applied = $false + WriteMode = $decision.Mode + Warning = "PREVIEW ONLY - the VM was not changed. This is REVERSIBLE and reduces licensing cost. $($decision.Reason)" + Method = 'PATCH' + Uri = "https://management.azure.com$path" + ResourceId = $ResourceId + ResourceName = $vmName + Location = $location + OsType = $osType + CurrentLicense = $currentLicense + NewLicense = $LicenseType + RequestBody = ($bodyJson | ConvertFrom-Json) + ConfirmationToken = $decision.ConfirmationToken + RequiresToken = $decision.RequiresToken + NextStep = if ($decision.RequiresToken) { + 'Enforced mode: re-run with apply=true AND confirmationToken=.' + } + else { 'Re-run remediate_enable_hybrid_benefit with apply=true to enable AHB.' } + } + } + + # ---- Proceed: PATCH the VM ---- + Write-Host " Enabling Azure Hybrid Benefit ($LicenseType) on '$vmName'..." -ForegroundColor Yellow + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method 'PATCH' -Payload $bodyJson + $status = if ($resp) { [int]$resp.StatusCode } else { 0 } + $ok = $status -in @(200, 201, 202) + $errMsg = $null + if (-not $ok) { + $errMsg = "PATCH returned $status." + if ($resp -and $resp.Content) { try { $eb = ($resp.Content | ConvertFrom-Json); if ($eb.error.message) { $errMsg += " $($eb.error.message)" } } catch {} } + } + + return [PSCustomObject]@{ + HasData = $true + Mode = 'Apply' + Applied = $ok + WriteMode = $decision.Mode + StatusCode = $status + Warning = if ($ok) { "AHB enabled ($LicenseType). Reversible - set licenseType back to None to undo." } else { $null } + Error = $errMsg + Method = 'PATCH' + Uri = "https://management.azure.com$path" + ResourceId = $ResourceId + ResourceName = $vmName + CurrentLicense = $currentLicense + NewLicense = $LicenseType + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 new file mode 100644 index 000000000..ca615e327 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 @@ -0,0 +1,110 @@ +########################################################################### +# GET-AHBOPPORTUNITIES.PS1 +# AZURE FINOPS MULTITOOL - Azure Hybrid Benefit Gap Detection +########################################################################### +# Purpose: Use Resource Graph to find VMs and SQL resources that are NOT +# using Azure Hybrid Benefit (AHB) but could be. AHB saves up +# to 85% on Windows Server and SQL Server licensing costs. +# +# Eligible resources: +# - Windows VMs without licenseType = 'Windows_Server' +# - SQL Server VMs without licenseType = 'AHUB' +# - SQL Databases/Managed Instances without licenseType = 'BasePrice' +# +# Reference: https://learn.microsoft.com/en-us/azure/azure-sql/azure-hybrid-benefit +########################################################################### + +function Get-AHBOpportunities { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + + # -- Windows VMs without AHB ---------------------------------------- + $windowsVMs = @() + try { + Write-Host " Scanning Windows VMs for AHB eligibility..." -ForegroundColor Cyan + $vmQuery = @" +resources +| where type == 'microsoft.compute/virtualmachines' +| where properties.storageProfile.osDisk.osType =~ 'Windows' +| where isempty(properties.licenseType) or (properties.licenseType !~ 'Windows_Server' and properties.licenseType !~ 'Windows_Client') +| project name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + currentLicense = coalesce(tostring(properties.licenseType), 'None'), + osType = tostring(properties.storageProfile.imageReference.offer) +| order by subscriptionId asc, name asc +"@ + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $windowsVMs = if ($result) { @($result.Data) } else { @() } + } catch { + Write-Warning "Windows VM AHB scan failed: $($_.Exception.Message)" + } + + # -- Estimate per-VM AHB monthly savings (per-SKU Windows license premium) -- + if ($windowsVMs.Count -gt 0 -and (Get-Command Get-AhbVmRates -ErrorAction SilentlyContinue)) { + foreach ($vm in $windowsVMs) { + $est = $null + $rates = Get-AhbVmRates -VmSize $vm.vmSize -Region $vm.location + if ($rates) { $est = [math]::Round($rates.HourlyPremium * 730, 2) } + $vm | Add-Member -NotePropertyName estMonthlySavings -NotePropertyValue $est -Force + } + } + + # -- SQL Server VMs without AHB ------------------------------------- + $sqlVMs = @() + try { + Write-Host " Scanning SQL Server VMs for AHB eligibility..." -ForegroundColor Cyan + $sqlVMQuery = @" +resources +| where type == 'microsoft.sqlvirtualmachine/sqlvirtualmachines' +| where isempty(properties.sqlServerLicenseType) or properties.sqlServerLicenseType !~ 'AHUB' +| project name, resourceGroup, subscriptionId, location, + currentLicense = coalesce(tostring(properties.sqlServerLicenseType), 'None'), + sqlEdition = tostring(properties.sqlImageSku) +| order by subscriptionId asc, name asc +"@ + $result = Search-AzGraphSafe -Query $sqlVMQuery -Subscription $subIds -First 1000 + $sqlVMs = if ($result) { @($result.Data) } else { @() } + } catch { + Write-Warning "SQL VM AHB scan failed: $($_.Exception.Message)" + } + + # -- SQL Databases without AHB -------------------------------------- + $sqlDBs = @() + try { + Write-Host " Scanning SQL Databases for AHB eligibility..." -ForegroundColor Cyan + $sqlDBQuery = @" +resources +| where type == 'microsoft.sql/servers/databases' +| where sku.tier != 'Free' and name != 'master' +| where isempty(properties.licenseType) or properties.licenseType !~ 'BasePrice' +| project name, resourceGroup, subscriptionId, location, + currentLicense = coalesce(tostring(properties.licenseType), 'LicenseIncluded'), + sku = strcat(tostring(sku.tier), ' / ', tostring(sku.name)), + maxSizeGB = tolong(properties.maxSizeBytes) / 1073741824 +| order by subscriptionId asc, name asc +"@ + $result = Search-AzGraphSafe -Query $sqlDBQuery -Subscription $subIds -First 1000 + $sqlDBs = if ($result) { @($result.Data) } else { @() } + } catch { + Write-Warning "SQL Database AHB scan failed: $($_.Exception.Message)" + } + + # -- Summary -------------------------------------------------------- + $totalOpportunities = $windowsVMs.Count + $sqlVMs.Count + $sqlDBs.Count + $ahbVMSavings = ($windowsVMs | Where-Object { $_.estMonthlySavings } | Measure-Object -Property estMonthlySavings -Sum).Sum + if (-not $ahbVMSavings) { $ahbVMSavings = 0 } + + return [PSCustomObject]@{ + WindowsVMs = $windowsVMs + SQLVMs = $sqlVMs + SQLDatabases = $sqlDBs + TotalOpportunities = $totalOpportunities + EstMonthlyVMSavings = [math]::Round($ahbVMSavings, 2) + Summary = "Found $($windowsVMs.Count) Windows VMs, $($sqlVMs.Count) SQL VMs, $($sqlDBs.Count) SQL DBs eligible for AHB" + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 new file mode 100644 index 000000000..5b843ca3f --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -0,0 +1,361 @@ +########################################################################### +# GET-AIWORKLOADMETRICS.PS1 +# AZURE FINOPS MULTITOOL - AI/LLM Workload KPIs (token economics) +########################################################################### +# Purpose: Detect AI workloads up front with one cheap ARG query, and - +# only when AI is present - pull historical token usage from +# Azure Monitor metrics and join it to Cost Management spend to +# compute AI/LLM unit-economics KPIs (cost per 1K tokens, cost +# per request, token volume by model). +########################################################################### +# Notes: +# - Gate-first: if no AI footprint exists, the module returns after a +# single ARG call (~50ms) so non-AI tenants pay almost nothing. +# - Token metrics: ProcessedPromptTokens (input), GeneratedTokens +# (output), TokenTransaction (total inference), AzureOpenAIRequests +# (call count), split by ModelDeploymentName. Window is month-to-date +# so it aligns 1:1 with the MonthToDate amortized cost query. +# - RBAC: Reader (ARG + Azure Monitor metrics) + Cost Management Reader +# (billing/MG scope) to map tokens to spend. +# - KPIs covered: Token Consumption, Cost per API Call, Cost per 1K +# tokens (effective rate). Cost per Inference is a request-count proxy. +# - Export path: when -HubData (FOCUS rows from a FinOps Hub export) is +# supplied, AI spend and billed token volume are read from the export +# instead of the Monitor + Cost Management APIs - zero live cost calls. +# Request counts are not billed line items, so cost-per-request is only +# available on the live API path. +########################################################################### + +function Get-AIWorkloadMetrics { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [object[]]$HubData + ) + + Write-Host " Detecting AI workloads..." -ForegroundColor Cyan + + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + + # -- 0: Cheap presence gate (one ARG call) ---------------------------- + # Count AI resources by type/kind. If nothing is found we bail out + # immediately so non-AI tenants never run the expensive metrics + cost + # loop below. + $openAiAccounts = [System.Collections.Generic.List[PSCustomObject]]::new() + $aiServiceCount = 0 + $mlWorkspaceCount = 0 + $searchCount = 0 + $gpuVmCount = 0 + + try { + $gateQuery = @" +resources +| extend lkind = tolower(tostring(kind)) +| where type =~ 'microsoft.cognitiveservices/accounts' + or type =~ 'microsoft.machinelearningservices/workspaces' + or type =~ 'microsoft.search/searchservices' + or (type =~ 'microsoft.compute/virtualmachines' + and tostring(properties.hardwareProfile.vmSize) matches regex @'(?i)^Standard_N') +| project id, name, type, lkind, subscriptionId, location +"@ + $result = Search-AzGraphSafe -Query $gateQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + + foreach ($r in $rows) { + switch -Wildcard ([string]$r.type) { + 'microsoft.cognitiveservices/accounts' { + # OpenAI + AIServices accounts expose token metrics. + if ($r.lkind -match 'openai' -or $r.lkind -match 'aiservices') { + [void]$openAiAccounts.Add([PSCustomObject]@{ + Id = [string]$r.id + Name = [string]$r.name + SubscriptionId = [string]$r.subscriptionId + Location = [string]$r.location + }) + } + else { $aiServiceCount++ } + } + 'microsoft.machinelearningservices/workspaces' { $mlWorkspaceCount++ } + 'microsoft.search/searchservices' { $searchCount++ } + 'microsoft.compute/virtualmachines' { $gpuVmCount++ } + } + } + } + catch { + Write-Warning " AI detection query failed: $($_.Exception.Message)" + } + + $footprint = [PSCustomObject]@{ + OpenAIAccounts = $openAiAccounts.Count + AIServices = $aiServiceCount + MLWorkspaces = $mlWorkspaceCount + SearchServices = $searchCount + GpuVmCount = $gpuVmCount + } + + $anyAI = ($openAiAccounts.Count + $aiServiceCount + $mlWorkspaceCount + $searchCount + $gpuVmCount) -gt 0 + if (-not $anyAI) { + Write-Host " No AI workloads detected - skipping AI KPIs." -ForegroundColor Gray + return [PSCustomObject]@{ + HasData = $false + AIFootprint = $footprint + ScannedSubs = $Subscriptions.Count + Note = 'No AI workloads detected in the scanned subscriptions.' + } + } + + Write-Host (" AI footprint - OpenAI/AIServices: {0} ML: {1} Search: {2} GPU VMs: {3}" -f ` + ($openAiAccounts.Count + $aiServiceCount), $mlWorkspaceCount, $searchCount, $gpuVmCount) -ForegroundColor Gray + + # -- 1: Historical token usage (Azure Monitor metrics) ---------------- + # Month-to-date so it lines up with the MonthToDate cost query. Daily + # interval keeps the payload to ~30 datapoints per account. + $now = (Get-Date).ToUniversalTime() + $monthStart = (Get-Date -Year $now.Year -Month $now.Month -Day 1 -Hour 0 -Minute 0 -Second 0).ToUniversalTime() + $fromStr = $monthStart.ToString('yyyy-MM-ddTHH:mm:ssZ') + $toStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ') + + $modelTokens = @{} # deployment -> @{ Prompt; Generated; Total } + $acctTokens = @{} # resourceId(lower) -> @{ Tokens; Requests } + $totalPrompt = 0.0 + $totalGen = 0.0 + $totalTokens = 0.0 + $totalReq = 0.0 + $metricsOk = $false + + $aiCost = 0.0 + $currency = 'USD' + $costByAcct = @{} # resourceId(lower) -> cost + $costOk = $false + + # -- Export path: derive tokens + cost from a FinOps Hub export ------- + # When FOCUS rows are supplied we read AI spend and billed token volume + # straight from the export - no Azure Monitor and no Cost Management API + # calls. Request counts are not billed line items, so cost-per-request + # is left unavailable on this path. + $fromHub = $false + $hubApprox = $false + if ($HubData -and @($HubData).Count -gt 0) { + $agg = ConvertTo-AIHubAggregates -HubData $HubData + if ($agg -and ($agg.HasCost -or $agg.HasTokens)) { + $fromHub = $true + $hubApprox = $agg.Approximate + $modelTokens = $agg.ModelTokens + $acctTokens = $agg.AcctTokens + $costByAcct = $agg.CostByAcct + $totalPrompt = $agg.TotalPrompt + $totalGen = $agg.TotalGen + $totalTokens = $agg.TotalTokens + $aiCost = $agg.AICost + $currency = $agg.Currency + $metricsOk = $agg.HasTokens + $costOk = $agg.HasCost + Write-Host (" Derived from FinOps Hub export: {0} token rows, {1} {2} AI spend" -f ` + $agg.RowCount, [math]::Round($agg.AICost, 2), $agg.Currency) -ForegroundColor Gray + } + } + + if (-not $fromHub -and $openAiAccounts.Count -gt 0) { + $token = $null + try { $token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com').Token } catch { } + + if ($token) { + $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } + $metricNames = 'ProcessedPromptTokens,GeneratedTokens,TokenTransaction,AzureOpenAIRequests' + $idx = 0 + $acctCount = $openAiAccounts.Count + + foreach ($acct in $openAiAccounts) { + $idx++ + if ($acctCount -gt 5 -and ($idx -eq 1 -or $idx % [math]::Max(1, [int]($acctCount / 5)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Reading AI token metrics ($idx/$acctCount accounts)..." + } + } + + $acctKey = $acct.Id.ToLowerInvariant() + if (-not $acctTokens.ContainsKey($acctKey)) { + $acctTokens[$acctKey] = @{ Name = $acct.Name; Tokens = 0.0; Requests = 0.0 } + } + + # Split by deployment so per-model KPIs are available. The + # filter literal needs a single-quoted segment to keep the + # '$filter' token and the '*' from being touched by PowerShell. + $filterSeg = '&$filter=' + [uri]::EscapeDataString("ModelDeploymentName eq '*'") + $metricUri = "https://management.azure.com$($acct.Id)/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=$metricNames×pan=$fromStr/$toStr&aggregation=Total&interval=P1D$filterSeg" + + try { + $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 20 -ErrorAction Stop + $data = $resp.Content | ConvertFrom-Json + $metricsOk = $true + + foreach ($metric in $data.value) { + $mName = $metric.name.value + foreach ($ts in $metric.timeseries) { + # Deployment name comes from the splitting dimension. + $deployment = 'unknown' + if ($ts.metadatavalues) { + foreach ($mv in $ts.metadatavalues) { + if ($mv.name.value -match '(?i)ModelDeploymentName') { $deployment = [string]$mv.value } + } + } + $sum = 0.0 + foreach ($dp in $ts.data) { if ($null -ne $dp.total) { $sum += [double]$dp.total } } + + if (-not $modelTokens.ContainsKey($deployment)) { + $modelTokens[$deployment] = @{ Prompt = 0.0; Generated = 0.0; Total = 0.0 } + } + switch ($mName) { + 'ProcessedPromptTokens' { $modelTokens[$deployment].Prompt += $sum; $totalPrompt += $sum; $acctTokens[$acctKey].Tokens += $sum } + 'GeneratedTokens' { $modelTokens[$deployment].Generated += $sum; $totalGen += $sum; $acctTokens[$acctKey].Tokens += $sum } + 'TokenTransaction' { $modelTokens[$deployment].Total += $sum; $totalTokens += $sum } + 'AzureOpenAIRequests' { $totalReq += $sum; $acctTokens[$acctKey].Requests += $sum } + } + } + } + } + catch { + # Metrics unavailable for this account (no usage yet, or + # not an OpenAI-capable kind) - skip it. + } + } + } + } + + # When TokenTransaction is sparse, fall back to prompt + generated. + if ($totalTokens -le 0) { $totalTokens = $totalPrompt + $totalGen } + + # -- 2: Map tokens to AI spend (Cost Management) ---------------------- + # Skipped entirely on the export path - spend already came from the Hub. + if (-not $fromHub) { + try { + $mgScopeId = Resolve-CostMgId -TenantId $TenantId + if ($mgScopeId) { + $rtFilter = @{ dimensions = @{ name = 'ResourceType'; operator = 'In'; values = @('microsoft.cognitiveservices/accounts') } } + $subFilter = Get-CostSubscriptionFilter -Subscriptions $Subscriptions + $filterNode = if ($subFilter) { @{ and = @($subFilter, $rtFilter) } } else { $rtFilter } + + $body = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'ResourceId' }) + filter = $filterNode + } + } | ConvertTo-Json -Depth 12 + + $path = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $cdata = $resp.Content | ConvertFrom-Json + if ($cdata.properties.rows) { + $costOk = $true + # Column order follows properties.columns; resolve indices. + $cols = @($cdata.properties.columns.name) + $iCost = [array]::IndexOf($cols, 'Cost') + $iRes = [array]::IndexOf($cols, 'ResourceId') + $iCur = [array]::IndexOf($cols, 'Currency') + foreach ($row in $cdata.properties.rows) { + $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { [double]$row[0] } + $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } + if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] } + $aiCost += $amount + if ($rid) { $costByAcct[$rid.ToLowerInvariant()] = $amount } + } + } + } + } + } + catch { + Write-Warning " AI cost query failed: $($_.Exception.Message)" + } + } + + # -- 3: KPIs ---------------------------------------------------------- + $costPer1kTokens = if ($totalTokens -gt 0) { [math]::Round(($aiCost / $totalTokens) * 1000, 4) } else { 0 } + $costPerRequest = if ($totalReq -gt 0) { [math]::Round($aiCost / $totalReq, 4) } else { 0 } + + $byModel = @( + $modelTokens.GetEnumerator() | ForEach-Object { + $mt = if ($_.Value.Total -gt 0) { $_.Value.Total } else { $_.Value.Prompt + $_.Value.Generated } + [PSCustomObject]@{ + Deployment = $_.Key + PromptTokens = [long]$_.Value.Prompt + GeneratedTokens = [long]$_.Value.Generated + TotalTokens = [long]$mt + PctOfTokens = if ($totalTokens -gt 0) { [math]::Round(($mt / $totalTokens) * 100, 1) } else { 0 } + } + } | Sort-Object TotalTokens -Descending + ) + + $byAccount = @( + $acctTokens.GetEnumerator() | ForEach-Object { + $c = if ($costByAcct.ContainsKey($_.Key)) { $costByAcct[$_.Key] } else { 0.0 } + $tk = $_.Value.Tokens + [PSCustomObject]@{ + Name = $_.Value.Name + ResourceId = $_.Key + Tokens = [long]$tk + Requests = [long]$_.Value.Requests + Cost = [math]::Round($c, 2) + CostPer1KTokens = if ($tk -gt 0) { [math]::Round(($c / $tk) * 1000, 4) } else { 0 } + } + } | Sort-Object Cost -Descending + ) + + $hasData = $anyAI + + if ($fromHub) { + $approxNote = if ($hubApprox) { ' Token volume is approximate where the billing unit could not be parsed.' } else { '' } + if ($metricsOk -and $costOk) { + $note = "AI spend and billed token volume read from the FinOps Hub export (no live API calls). Cost per request is unavailable on the export path - request counts are not billed line items.$approxNote" + } + elseif ($costOk) { + $note = "AI spend read from the FinOps Hub export, but the export had no token-metered Azure OpenAI usage to price.$approxNote" + } + else { + $note = "AI footprint detected; the FinOps Hub export had no Cognitive Services spend in the period.$approxNote" + } + } + elseif ($metricsOk -and $costOk) { + $note = 'Token volume is month-to-date from Azure Monitor; cost is month-to-date amortized.' + } + elseif ($metricsOk) { + $note = 'Token volume available, but AI cost could not be read (Cost Management Reader needed to compute cost per token).' + } + elseif ($openAiAccounts.Count -gt 0) { + $note = 'AI accounts detected but no token usage in the current month (or metrics access unavailable).' + } + else { + $note = 'AI footprint detected (ML/Search/GPU); no token-metered Azure OpenAI usage to price.' + } + + return [PSCustomObject]@{ + HasData = $hasData + AIFootprint = $footprint + TotalPromptTokens = [long]$totalPrompt + TotalGeneratedTokens = [long]$totalGen + TotalTokens = [long]$totalTokens + TotalRequests = [long]$totalReq + TotalAICost = [math]::Round($aiCost, 2) + Currency = $currency + CostPer1KTokens = $costPer1kTokens + CostPerRequest = $costPerRequest + ByModel = $byModel + ByAccount = $byAccount + Period = 'MonthToDate' + Source = if ($fromHub) { 'FinOpsHub' } else { 'API' } + ScannedSubs = $Subscriptions.Count + Note = $note + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 new file mode 100644 index 000000000..f122055fb --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 @@ -0,0 +1,86 @@ +########################################################################### +# GET-AHBVMSAVINGSRATIO.PS1 +# AZURE FINOPS MULTITOOL - Per-SKU Azure Hybrid Benefit Savings Ratio +########################################################################### +# Purpose: Compute the real Azure Hybrid Benefit savings for a VM size by +# comparing the Windows and Linux pay-as-you-go rates from the +# Azure Retail Prices API. AHB removes the Windows Server license +# premium, so the post-AHB cost is the Linux-equivalent rate. +# +# Description: +# 1. Queries the public Retail Prices API for the SKU + region +# 2. Picks the Windows and Linux Consumption meters (skips Spot / Low Priority) +# 3. Get-AhbVmRates returns Windows/Linux rates, the remaining-cost ratio, +# and the hourly Windows license premium (Windows rate - Linux rate) +# 4. Get-AhbVmSavingsRatio returns just the ratio (falls back to 0.6 / ~40% off) +# 5. Caches per SKU+region +# +# The values are size-specific: small / burstable SKUs carry a different Windows +# license premium than a flat 40% assumption. +# +# -- Parameters ---------------------------------------------------- +# VmSize ARM SKU name, e.g. Standard_D4as_v6 +# Region ARM region name, e.g. eastus +# +# Prerequisites: +# - Outbound HTTPS to prices.azure.com (no auth required) +# +# Usage: Get-AhbVmRates -VmSize 'Standard_D4as_v6' -Region 'eastus' +########################################################################### + +function Get-AhbVmRates { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$VmSize, + [Parameter(Mandatory)][string]$Region + ) + + if (-not $script:AhbRateCache) { $script:AhbRateCache = @{} } + if ([string]::IsNullOrWhiteSpace($VmSize) -or [string]::IsNullOrWhiteSpace($Region)) { return $null } + + $key = "$VmSize|$Region".ToLowerInvariant() + if ($script:AhbRateCache.ContainsKey($key)) { return $script:AhbRateCache[$key] } + + $result = $null + try { + $filter = "armRegionName eq '$Region' and armSkuName eq '$VmSize' and priceType eq 'Consumption' and serviceName eq 'Virtual Machines'" + $url = "https://prices.azure.com/api/retail/prices?`$filter=$([uri]::EscapeDataString($filter))" + $resp = Invoke-RestMethod -Uri $url -Method GET -TimeoutSec 20 + $items = @($resp.Items) | Where-Object { + $_.unitPrice -gt 0 -and + $_.skuName -notmatch 'Spot|Low Priority' -and + $_.meterName -notmatch 'Spot|Low Priority' + } + $win = $items | Where-Object { $_.productName -match 'Windows' } | Select-Object -First 1 + $lin = $items | Where-Object { $_.productName -notmatch 'Windows' } | Select-Object -First 1 + if ($win -and $lin -and [double]$win.unitPrice -gt 0) { + $w = [double]$win.unitPrice + $l = [double]$lin.unitPrice + if ($l -gt 0 -and $l -lt $w) { + $result = [PSCustomObject]@{ + WindowsRate = $w + LinuxRate = $l + Ratio = [math]::Round($l / $w, 4) + HourlyPremium = [math]::Round($w - $l, 5) + } + } + } + } catch { + Write-Warning " AHB rate lookup failed for ${VmSize}/${Region}: $($_.Exception.Message)" + } + + $script:AhbRateCache[$key] = $result + return $result +} + +function Get-AhbVmSavingsRatio { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$VmSize, + [Parameter(Mandatory)][string]$Region + ) + + $rates = Get-AhbVmRates -VmSize $VmSize -Region $Region + if ($rates) { return $rates.Ratio } + return 0.6 # ~40% off fallback when live rates are unavailable +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 new file mode 100644 index 000000000..3c2504fbf --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 @@ -0,0 +1,156 @@ +########################################################################### +# GET-ANOMALYALERTS.PS1 +# AZURE FINOPS MULTITOOL - Cost Management Anomaly & Budget Alerts +########################################################################### +# Purpose: Query Azure Cost Management for triggered alerts (anomaly, +# budget, forecast) and configured anomaly alert rules +# (InsightAlert scheduled actions) across all subscriptions. +########################################################################### + +function Get-AnomalyAlerts { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $subCount = $Subscriptions.Count + Write-Host " Querying anomaly & budget alerts ($subCount subs)..." -ForegroundColor Cyan + + $triggeredAlerts = [System.Collections.Generic.List[PSCustomObject]]::new() + $configuredRules = [System.Collections.Generic.List[PSCustomObject]]::new() + + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying anomaly alerts ($i/$subCount subs)..." + } + } + + # -- Triggered Cost Management alerts -- + try { + $alertPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/alerts?api-version=2023-09-01" + $resp = Invoke-AzRestMethodWithRetry -Path $alertPath -Method GET + + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + if ($data.value) { + foreach ($alert in $data.value) { + $p = $alert.properties + $def = if ($p.definition) { $p.definition } else { @{} } + $det = if ($p.details) { $p.details } else { @{} } + + $alertType = if ($def.type) { $def.type } else { 'Unknown' } + $category = if ($def.category) { $def.category } else { '' } + $criteria = if ($def.criteria) { $def.criteria } else { '' } + $status = if ($p.status) { $p.status } else { 'Unknown' } + + $amount = if ($det.amount) { [math]::Round([double]$det.amount, 2) } else { 0 } + $currentSpend = if ($det.currentSpend) { [math]::Round([double]$det.currentSpend, 2) } else { 0 } + $unit = if ($det.unit) { $det.unit } else { 'USD' } + + # Cost Management names alerts with a GUID. Derive a human + # label: prefer the alert description, then the related + # budget/scope leaf (costEntityId), then a Category/Type + # composite, and only fall back to the GUID as a last resort. + $description = if ($p.description) { [string]$p.description } else { '' } + $costEntityId = if ($p.costEntityId) { [string]$p.costEntityId } else { '' } + $relatedTo = if ($costEntityId) { ($costEntityId -split '/')[-1] } else { '' } + $alertLabel = + if ($description) { $description } + elseif ($relatedTo) { "$relatedTo ($alertType)" } + else { + $composite = (@($category, $alertType) | Where-Object { $_ -and $_ -ne 'Unknown' }) -join ' ' + if ($composite) { $composite } else { $alert.name } + } + + $contacts = @() + if ($det.contactEmails) { $contacts += @($det.contactEmails) } + if ($det.contactRoles) { $contacts += @($det.contactRoles) } + + $createdAt = '' + if ($p.creationTime) { + try { $createdAt = ([datetime]$p.creationTime).ToString('yyyy-MM-dd') } catch { $createdAt = $p.creationTime } + } + + [void]$triggeredAlerts.Add([PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + AlertName = $alert.name + AlertLabel = $alertLabel + RelatedTo = $relatedTo + Description = $description + AlertType = $alertType + Category = $category + Criteria = $criteria + Status = $status + Amount = $amount + CurrentSpend = $currentSpend + Unit = $unit + Contacts = (($contacts | Select-Object -Unique) -join ', ') + CreatedAt = $createdAt + }) + } + } + } + } catch { + Write-Warning " Alert query failed for $($sub.Name): $($_.Exception.Message)" + } + + # -- Configured anomaly alert rules (InsightAlert scheduled actions) -- + try { + $saPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/scheduledActions?api-version=2023-03-01" + $resp = Invoke-AzRestMethodWithRetry -Path $saPath -Method GET + + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + if ($data.value) { + $insightActions = @($data.value | Where-Object { $_.kind -eq 'InsightAlert' }) + foreach ($sa in $insightActions) { + $p = if ($sa.properties) { $sa.properties } else { @{} } + + $toEmails = '' + if ($p.notification -and $p.notification.to) { + $toEmails = ($p.notification.to -join ', ') + } + + $nextRun = '' + if ($p.nextRunTime) { + try { $nextRun = ([datetime]$p.nextRunTime).ToString('yyyy-MM-dd') } catch { $nextRun = $p.nextRunTime } + } + + [void]$configuredRules.Add([PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + RuleName = $sa.name + DisplayName = if ($p.displayName) { $p.displayName } else { $sa.name } + Status = if ($p.status) { $p.status } else { 'Unknown' } + Scope = if ($p.scope) { $p.scope } else { "/subscriptions/$($sub.Id)" } + ToEmails = $toEmails + NextRunTime = $nextRun + }) + } + } + } + } catch { + Write-Warning " Scheduled action query failed for $($sub.Name): $($_.Exception.Message)" + } + } + + $anomalyCount = @($triggeredAlerts | Where-Object { $_.AlertType -match 'Anomaly' }).Count + $activeCount = @($triggeredAlerts | Where-Object { $_.Status -eq 'Active' }).Count + $budgetCount = @($triggeredAlerts | Where-Object { $_.AlertType -match 'Budget' }).Count + + return [PSCustomObject]@{ + TriggeredAlerts = @($triggeredAlerts) + ConfiguredRules = @($configuredRules) + TotalAlerts = $triggeredAlerts.Count + AnomalyAlertCount = $anomalyCount + ActiveAlertCount = $activeCount + BudgetAlertCount = $budgetCount + ConfiguredRuleCount = $configuredRules.Count + HasData = ($triggeredAlerts.Count -gt 0 -or $configuredRules.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 new file mode 100644 index 000000000..cd88a0ba9 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 @@ -0,0 +1,128 @@ +########################################################################### +# GET-BILLINGACCOUNT.PS1 +# DISCOVER BILLING ACCOUNTS + COST ALLOCATION ELIGIBILITY +########################################################################### +# Purpose: Read-only lookup of billing accounts you can see, their agreement +# type, whether they support cost allocation rules, and whether you +# can reach the rules endpoint (Cost Management Contributor signal). +# Author: Zac Larsen +# Date: Created for FinOps Multitool shared-cost allocation +# +# Description: +# Lists Microsoft.Billing billing accounts visible to the signed-in identity +# and, for each, reports: +# 1. Billing account id (the path segment set_cost_allocation_rule needs) +# 2. Agreement type (EA / MCA / MPA / MOSA) +# 3. Eligibility - cost allocation rules require EA or MCA +# 4. Optional access probe - GETs the costAllocationRules endpoint so you +# learn before an apply whether you have read access there (a 403 means +# you lack Cost Management Contributor at that scope) +# +# This tool is READ-ONLY. It never writes or changes anything in Azure. +# +# ── Parameters ────────────────────────────────────────────────── +# BillingAccountId Optional filter - return only this billing account +# ProbeAccess When true (default), probe the rules endpoint per account +# +# Prerequisites: +# - Az.Accounts connected (Connect-AzAccount). Billing Reader is enough to list. +# +# Usage: Get-BillingAccount +########################################################################### + +function Get-BillingAccount { + param( + [Parameter()] + [string]$BillingAccountId, + + [Parameter()] + [bool]$ProbeAccess = $true + ) + + $billingApi = '2020-05-01' + $ruleApi = '2025-03-01' + + Write-Host ' Listing billing accounts...' -ForegroundColor Cyan + $listPath = "/providers/Microsoft.Billing/billingAccounts?api-version=$billingApi" + $resp = Invoke-AzRestMethodWithRetry -Path $listPath -Method 'GET' + + $status = if ($resp) { [int]$resp.StatusCode } else { 0 } + if ($status -ne 200) { + $msg = "Could not list billing accounts (HTTP $status)." + if ($resp -and $resp.Content) { + try { $e = $resp.Content | ConvertFrom-Json -ErrorAction Stop; if ($e.error.message) { $msg += " $($e.error.message)" } } catch { } + } + return [PSCustomObject]@{ + HasData = $false + Accounts = @() + Note = "$msg Ensure you are signed in (Connect-AzAccount) and have at least Billing Reader." + } + } + + $payload = $null + try { $payload = $resp.Content | ConvertFrom-Json -ErrorAction Stop } catch { } + $raw = @() + if ($payload -and $payload.value) { $raw = @($payload.value) } + + if ($BillingAccountId) { + $raw = @($raw | Where-Object { $_.name -eq $BillingAccountId }) + } + + if ($raw.Count -eq 0) { + return [PSCustomObject]@{ + HasData = $false + Accounts = @() + Note = if ($BillingAccountId) { "No billing account named '$BillingAccountId' is visible to this identity." } else { 'No billing accounts are visible to this identity.' } + } + } + + $eligibleTypes = @('EnterpriseAgreement', 'MicrosoftCustomerAgreement') + $accounts = @() + + foreach ($ba in $raw) { + $name = [string]$ba.name + $agreement = [string]$ba.properties.agreementType + $eligible = $eligibleTypes -contains $agreement + + $eligNote = switch ($agreement) { + 'EnterpriseAgreement' { 'Supported (EA enrollment).' } + 'MicrosoftCustomerAgreement' { 'Supported (MCA).' } + 'MicrosoftPartnerAgreement' { 'Not supported - CSP/partner agreements cannot use cost allocation rules.' } + 'MicrosoftOnlineServicesProgram' { 'Not supported - pay-as-you-go / MOSA cannot use cost allocation rules.' } + default { "Unknown agreement type '$agreement' - verify cost allocation support." } + } + + $access = $null + if ($ProbeAccess -and $eligible) { + $probePath = "/providers/Microsoft.Billing/billingAccounts/$name/providers/Microsoft.CostManagement/costAllocationRules?api-version=$ruleApi" + $pr = Invoke-AzRestMethodWithRetry -Path $probePath -Method 'GET' + $ps = if ($pr) { [int]$pr.StatusCode } else { 0 } + $access = switch ($ps) { + 200 { 'Ok - you can read cost allocation rules here (write needs Cost Management Contributor).' } + 403 { 'Forbidden - you lack access at this scope. Cost Management Contributor is required to write rules.' } + 404 { 'Not found - the cost allocation provider is not enabled or the account does not expose it.' } + default { "HTTP $ps when reading the rules endpoint." } + } + } + + $accounts += [PSCustomObject]@{ + Id = $name + DisplayName = [string]$ba.properties.displayName + AgreementType = $agreement + AccountStatus = [string]$ba.properties.accountStatus + Eligible = $eligible + EligibilityNote = $eligNote + RulesAccess = $access + } + } + + $hasNext = if ($payload.PSObject.Properties['nextLink'] -and $payload.nextLink) { $true } else { $false } + $note = 'Use Id as billingAccountId in set_cost_allocation_rule. Only Eligible=true accounts (EA/MCA) support cost allocation rules.' + if ($hasNext) { $note += ' More accounts exist beyond this page (nextLink present).' } + + return [PSCustomObject]@{ + HasData = $true + Accounts = @($accounts | Sort-Object -Property @{ Expression = 'Eligible'; Descending = $true }, 'DisplayName') + Note = $note + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 new file mode 100644 index 000000000..da5098263 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 @@ -0,0 +1,201 @@ +########################################################################### +# GET-BILLINGSTRUCTURE.PS1 +# AZURE FINOPS MULTITOOL - Billing Profiles, Invoice Sections & Cost Allocation +########################################################################### +# Purpose: Retrieve billing account structure (profiles, invoice sections) +# and any configured cost allocation rules. Requires Billing Reader +# on the billing account for full data; falls back gracefully. +########################################################################### + +function Get-BillingStructure { + [CmdletBinding()] + param( + [object[]]$Subscriptions + ) + + Write-Host " Querying billing structure..." -ForegroundColor Cyan + + $billingAccounts = @() + $billingProfiles = @() + $invoiceSections = @() + $costAllocationRules = @() + + # -- Resolve billing account IDs linked to scanned subscriptions ----- + # Query ALL scanned subscriptions (not just 5) to build a complete set + # of billing accounts that belong to this tenant/scan scope. + $tenantBillingAccountNames = @{} + if ($Subscriptions) { + foreach ($sub in $Subscriptions) { + try { + $biPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Billing/billingInfo/default?api-version=2024-04-01" + $biResp = Invoke-AzRestMethodWithRetry -Path $biPath -Method GET + if ($biResp.StatusCode -eq 200) { + $biResult = ($biResp.Content | ConvertFrom-Json) + $baId = $biResult.properties.billingAccountId + if ($baId) { + # Normalize: extract the account name portion after /billingAccounts/ + $baName = ($baId -replace '(?i).*/billingAccounts/', '').Trim('/') + if ($baName) { $tenantBillingAccountNames[$baName] = $true } + } + } + } catch { } + } + Write-Host " Resolved $($tenantBillingAccountNames.Count) billing account(s) linked to scanned subscriptions." -ForegroundColor Cyan + } + + # -- Step 1: Get Billing Accounts ----------------------------------- + try { + $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" + $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET + if ($baResp.StatusCode -eq 200) { + $baResult = ($baResp.Content | ConvertFrom-Json) + if ($baResult.value) { + foreach ($ba in $baResult.value) { + # Filter to billing accounts associated with scanned subscriptions + if ($tenantBillingAccountNames.Count -gt 0 -and -not $tenantBillingAccountNames.ContainsKey($ba.name)) { + continue + } + # If no billing account names resolved at all, skip rather than showing everything + if ($tenantBillingAccountNames.Count -eq 0) { + Write-Warning " Could not resolve any billing account IDs from subscriptions — skipping billing account: $($ba.properties.displayName)" + continue + } + $props = $ba.properties + $billingAccounts += [PSCustomObject]@{ + AccountId = $ba.name + DisplayName = $props.displayName + AgreementType = $props.agreementType + AccountType = $props.accountType + AccountStatus = $props.accountStatus + FullId = $ba.id + } + } + } + } else { + Write-Warning " Billing accounts returned HTTP $($baResp.StatusCode)" + } + } catch { + Write-Warning " Billing accounts query failed: $($_.Exception.Message)" + } + + # -- Step 2: Get Billing Profiles (MCA only) ------------------------ + foreach ($ba in $billingAccounts) { + if ($ba.AgreementType -notin @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { + continue + } + try { + $bpPath = "$($ba.FullId)/billingProfiles?api-version=2024-04-01" + $bpResp = Invoke-AzRestMethodWithRetry -Path $bpPath -Method GET + if ($bpResp.StatusCode -eq 200) { + $bpResult = ($bpResp.Content | ConvertFrom-Json) + if ($bpResult.value) { + foreach ($bp in $bpResult.value) { + $bpProps = $bp.properties + $billingProfiles += [PSCustomObject]@{ + ProfileId = $bp.name + DisplayName = $bpProps.displayName + BillingAccount = $ba.DisplayName + Currency = $bpProps.currency + InvoiceDay = $bpProps.invoiceDay + Status = $bpProps.status + FullId = $bp.id + } + + # -- Step 3: Invoice Sections per Profile ------- + try { + $isPath = "$($bp.id)/invoiceSections?api-version=2024-04-01" + $isResp = Invoke-AzRestMethodWithRetry -Path $isPath -Method GET + if ($isResp.StatusCode -eq 200) { + $isResult = ($isResp.Content | ConvertFrom-Json) + if ($isResult.value) { + foreach ($section in $isResult.value) { + $sProps = $section.properties + $invoiceSections += [PSCustomObject]@{ + SectionId = $section.name + DisplayName = $sProps.displayName + BillingProfile = $bpProps.displayName + BillingAccount = $ba.DisplayName + State = $sProps.state + SystemId = $sProps.systemId + FullId = $section.id + } + } + } + } + } catch { + Write-Warning " Invoice sections query failed for profile $($bpProps.displayName): $($_.Exception.Message)" + } + } + } + } + } catch { + Write-Warning " Billing profiles query failed: $($_.Exception.Message)" + } + } + + # -- Step 4: EA Departments & Enrollment Accounts (EA only) --------- + $eaDepartments = @() + foreach ($ba in $billingAccounts) { + if ($ba.AgreementType -ne 'EnterpriseAgreement') { continue } + try { + $deptPath = "$($ba.FullId)/departments?api-version=2024-04-01" + $deptResp = Invoke-AzRestMethodWithRetry -Path $deptPath -Method GET + if ($deptResp.StatusCode -eq 200) { + $deptResult = ($deptResp.Content | ConvertFrom-Json) + if ($deptResult.value) { + foreach ($dept in $deptResult.value) { + $dProps = $dept.properties + $eaDepartments += [PSCustomObject]@{ + DepartmentId = $dept.name + DisplayName = $dProps.displayName + BillingAccount = $ba.DisplayName + CostCenter = $dProps.costCenter + Status = $dProps.status + } + } + } + } + } catch { + Write-Warning " EA departments query failed: $($_.Exception.Message)" + } + } + + # -- Step 5: Cost Allocation Rules ---------------------------------- + foreach ($ba in $billingAccounts) { + try { + $carPath = "$($ba.FullId)/providers/Microsoft.CostManagement/costAllocationRules?api-version=2023-11-01" + $carResp = Invoke-AzRestMethodWithRetry -Path $carPath -Method GET + if ($carResp.StatusCode -eq 200) { + $carResult = ($carResp.Content | ConvertFrom-Json) + if ($carResult.value) { + foreach ($rule in $carResult.value) { + $rProps = $rule.properties + $costAllocationRules += [PSCustomObject]@{ + RuleName = $rProps.name + Description = $rProps.description + Status = $rProps.status + BillingAccount = $ba.DisplayName + SourceCount = if ($rProps.details.sourceResources) { $rProps.details.sourceResources.Count } else { 0 } + TargetCount = if ($rProps.details.targetResources) { $rProps.details.targetResources.Count } else { 0 } + CreatedDate = $rProps.createdDate + UpdatedDate = $rProps.updatedDate + } + } + } + } elseif ($carResp.StatusCode -ne 404) { + Write-Warning " Cost allocation rules returned HTTP $($carResp.StatusCode)" + } + } catch { + Write-Warning " Cost allocation rules query failed: $($_.Exception.Message)" + } + } + + return [PSCustomObject]@{ + BillingAccounts = $billingAccounts + BillingProfiles = $billingProfiles + InvoiceSections = $invoiceSections + EADepartments = $eaDepartments + CostAllocationRules = $costAllocationRules + HasBillingAccess = ($billingAccounts.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 new file mode 100644 index 000000000..5b1baf423 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -0,0 +1,364 @@ +########################################################################### +# GET-BUDGETSTATUS.PS1 +# AZURE FINOPS MULTITOOL - Budget vs. Actual Comparison +########################################################################### +# Purpose: Query Azure Budgets (Consumption API) for each subscription to +# show configured budget amount vs current spend. Highlights +# subscriptions at risk of overrun. +########################################################################### + +function Get-BudgetStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions, + + [Parameter()] + $CostData # Existing cost data keyed by subscription ID + ) + + # Guard: extract hashtable if pipeline pollution wrapped it in an array + if ($CostData -and $CostData -isnot [hashtable]) { + $CostData = @($CostData | Where-Object { $_ -is [hashtable] })[-1] + } + if (-not $CostData) { $CostData = @{} } + + $subCount = $Subscriptions.Count + Write-Host " Querying budget status ($subCount subs)..." -ForegroundColor Cyan + + $budgets = [System.Collections.Generic.List[PSCustomObject]]::new() + $subsWithBudget = 0 + $subsWithoutBudget = 0 + $sampled = $false + + # -- For large tenants, sample first to see if budgets exist -------- + $subsToQuery = $Subscriptions + if ($subCount -gt 50) { + $sampleSize = [math]::Min(10, $subCount) + Write-Host " Large tenant: sampling $sampleSize of $subCount subs for budgets..." -ForegroundColor Yellow + $sampleSubs = $Subscriptions | Select-Object -First $sampleSize + $sampleHits = 0 + foreach ($sub in $sampleSubs) { + try { + $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01" + $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET + if ($resp.StatusCode -eq 200) { + $budgets = ($resp.Content | ConvertFrom-Json).value + if ($budgets -and $budgets.Count -gt 0) { $sampleHits++ } + } + } + catch { } + } + + if ($sampleHits -eq 0) { + Write-Host " No budgets found in sample of $sampleSize subs - skipping remaining" -ForegroundColor Yellow + $sampled = $true + $subsWithoutBudget = $subCount + $subsToQuery = @() # Skip the main loop + } + else { + Write-Host " Budgets found in sample ($sampleHits/$sampleSize), querying all $subCount subs..." -ForegroundColor Cyan + } + } + + $i = 0 + foreach ($sub in $subsToQuery) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying budgets ($i/$subCount subs)..." + } + } + try { + $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01" + $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET + + if ($resp.StatusCode -eq 200) { + $data = ($resp.Content | ConvertFrom-Json) + if ($data.value -and $data.value.Count -gt 0) { + $subsWithBudget++ + foreach ($budget in $data.value) { + $bp = $budget.properties + $amount = [math]::Round([double]$bp.amount, 2) + $timeGrain = $bp.timeGrain + $category = $bp.category + + # Current spend from our existing cost data + $actualSpend = 0 + $forecast = 0 + $spendKnown = ($CostData -and $CostData.ContainsKey($sub.Id)) + if ($spendKnown) { + $actualSpend = [math]::Round($CostData[$sub.Id].Actual, 2) + $forecast = [math]::Round($CostData[$sub.Id].Forecast, 2) + } + + # Calculate % used + $pctUsed = if ($amount -gt 0) { [math]::Round(($actualSpend / $amount) * 100, 1) } else { 0 } + $pctForecast = if ($amount -gt 0) { [math]::Round(($forecast / $amount) * 100, 1) } else { 0 } + + # Risk level + # 'Unknown' means we could not read current spend for this + # subscription, so we must NOT claim the budget is On Track. + # 'Over Budget' means actual spend has already exceeded the budget. + # 'Forecast Over' means actual is still within budget but the + # month-end forecast is projected to exceed it (early warning). + $risk = if (-not $spendKnown) { 'Unknown' } + elseif ($pctUsed -gt 100) { 'Over Budget' } + elseif ($pctForecast -gt 100) { 'Forecast Over' } + elseif ($pctForecast -gt 90) { 'At Risk' } + elseif ($pctForecast -gt 75) { 'Watch' } + else { 'On Track' } + + # Notification thresholds and contacts + $thresholds = @() + $contactEmails = @() + $contactRoles = @() + if ($bp.notifications) { + foreach ($notif in $bp.notifications.PSObject.Properties) { + $np = $notif.Value + $thresholds += "$($np.threshold)% ($($np.operator))" + if ($np.contactEmails) { $contactEmails += @($np.contactEmails) } + if ($np.contactRoles) { $contactRoles += @($np.contactRoles) } + } + } + + # Extract tag filters from budget filter property + $tagFilters = @() + if ($bp.filter -and $bp.filter.tags) { + foreach ($tagProp in $bp.filter.tags.PSObject.Properties) { + $tagKey = $tagProp.Name + $tagVals = @() + if ($tagProp.Value -and $tagProp.Value.values) { + $tagVals = @($tagProp.Value.values) + } + $tagFilters += "$tagKey=$($tagVals -join '|')" + } + } + if ($bp.filter -and $bp.filter.dimensions) { + foreach ($dimProp in $bp.filter.dimensions.PSObject.Properties) { + if ($dimProp.Name -match '^Tag') { + $dimName = $dimProp.Name -replace '^Tag', '' + $dimVals = if ($dimProp.Value.values) { @($dimProp.Value.values) } else { @() } + $tagFilters += "$dimName=$($dimVals -join '|')" + } + } + } + $tagFilterStr = $tagFilters -join '; ' + + [void]$budgets.Add([PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + BudgetName = $budget.name + Amount = $amount + TimeGrain = $timeGrain + Category = $category + ActualSpend = $actualSpend + Forecast = $forecast + PctUsed = $pctUsed + PctForecast = $pctForecast + Risk = $risk + Thresholds = ($thresholds -join ', ') + ContactEmails = (($contactEmails | Select-Object -Unique) -join ', ') + ContactRoles = (($contactRoles | Select-Object -Unique) -join ', ') + TagFilter = $tagFilterStr + Currency = if ($CostData -and $CostData.ContainsKey($sub.Id)) { $CostData[$sub.Id].Currency } else { 'USD' } + }) + } + } + else { + $subsWithoutBudget++ + } + } + else { + $subsWithoutBudget++ + } + } + catch { + Write-Warning " Budget query failed for $($sub.Name): $($_.Exception.Message)" + $subsWithoutBudget++ + } + } + + # Count risk levels + # OverBudgetCount = actual spend already exceeded budget (urgent / red). + # AtRiskCount = forecast-driven warnings (projected over, or trending high). + $overBudget = @($budgets | Where-Object { $_.Risk -eq 'Over Budget' }).Count + $atRisk = @($budgets | Where-Object { $_.Risk -in @('Forecast Over', 'At Risk') }).Count + + return [PSCustomObject]@{ + Budgets = @($budgets) + TotalBudgets = $budgets.Count + SubsWithBudget = $subsWithBudget + SubsWithoutBudget = $subsWithoutBudget + OverBudgetCount = $overBudget + AtRiskCount = $atRisk + HasData = ($budgets.Count -gt 0) + Sampled = $sampled + BudgetCoverage = if ($Subscriptions.Count -gt 0) { + [math]::Round(($subsWithBudget / $Subscriptions.Count) * 100, 1) + } + else { 0 } + } +} + +function Get-BudgetHistory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Budgets, + + [Parameter()] + [int]$MonthsBack = 6, + + # Optional Cost Trend result (from Get-CostTrend). When supplied, its + # already-fetched per-subscription monthly spend is reused instead of + # re-querying the (throttle-prone) Cost Management Query API. + [Parameter()] + [object]$CostTrend + ) + + if (-not $Budgets -or $Budgets.Count -eq 0) { return @() } + + $history = [System.Collections.Generic.List[PSCustomObject]]::new() + + # Build a sub -> { 'yyyy-MM' = cost } lookup from Cost Trend so Budget + # History can avoid hitting Cost Management again. Cost Trend already + # fetched the last 6 months of monthly spend per subscription — exactly the + # data Budget History needs — so reusing it eliminates the redundant, + # 429-prone per-sub queries that were leaving history empty under throttle. + $trendBySub = @{} + if ($CostTrend -and $CostTrend.BySubscription) { + foreach ($k in @($CostTrend.BySubscription.Keys)) { + $lookup = @{} + foreach ($m in $CostTrend.BySubscription[$k]) { + if ($m.MonthDate -is [datetime]) { + $lookup[$m.MonthDate.ToString('yyyy-MM')] = [math]::Round([double]$m.Cost, 2) + } + } + if ($lookup.Count -gt 0) { $trendBySub[$k] = $lookup } + } + } + + # Group budgets by subscription to minimize API calls + $bySubId = $Budgets | Group-Object SubscriptionId + + foreach ($subGroup in $bySubId) { + $subId = $subGroup.Name + $subName = $subGroup.Group[0].Subscription + + # Prefer reusing Cost Trend data (zero extra API calls). Fall back to a + # live per-sub Cost Management query only when trend data is missing. + $monthlyCosts = $null + if ($trendBySub.ContainsKey($subId)) { + $monthlyCosts = $trendBySub[$subId] + } + else { + # Query monthly costs for this sub over the last N months + $startDate = (Get-Date).AddMonths(-$MonthsBack).ToString('yyyy-MM-01') + $endDate = (Get-Date -Day 1).AddDays(-1).ToString('yyyy-MM-dd') # Last day of previous month + + $body = @{ + type = 'ActualCost' + timeframe = 'Custom' + timePeriod = @{ from = $startDate; to = $endDate } + dataset = @{ + granularity = 'Monthly' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + } | ConvertTo-Json -Depth 10 + + $costPath = "/subscriptions/$subId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + try { + $resp = Invoke-AzRestMethodWithRetry -Path $costPath -Method POST -Payload $body + if ($resp.StatusCode -ne 200) { continue } + + $result = ($resp.Content | ConvertFrom-Json) + if (-not $result.properties -or -not $result.properties.rows) { continue } + + # Parse columns + $cols = $result.properties.columns + $costIdx = -1; $dateIdx = -1; $currIdx = -1 + for ($i = 0; $i -lt $cols.Count; $i++) { + $n = $cols[$i].name.ToLower() + if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'pretaxcost') { $costIdx = $i } + elseif ($n -match 'billingmonth|usagedate') { $dateIdx = $i } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $i } + } + if ($costIdx -eq -1) { $costIdx = 0 } + if ($dateIdx -eq -1) { $dateIdx = 1 } + if ($currIdx -eq -1) { $currIdx = 2 } + + # Build month → cost lookup + $monthlyCosts = @{} + foreach ($row in $result.properties.rows) { + $cost = [math]::Round([double]$row[$costIdx], 2) + $rawDate = $row[$dateIdx] + # Parse date robustly — API may return a [datetime], a YYYYMMDD + # integer (e.g. 20260101), or a locale-formatted string + # (e.g. "1/1/2026 12:00:00 AM"). Avoid substring slicing, which + # mangles non-ISO date formats and zeroes out actual spend. + $parsed = $null + if ($rawDate -is [datetime]) { + $parsed = $rawDate + } + else { + $dateStr = "$rawDate" + $digitsOnly = $dateStr -replace '[^0-9]', '' + if ($digitsOnly.Length -eq 8 -and $dateStr -notmatch '[/\-:]') { + $parsed = [datetime]::ParseExact($digitsOnly, 'yyyyMMdd', $null) + } + else { + try { $parsed = [datetime]::Parse($dateStr) } catch { continue } + } + } + $monthKey = $parsed.ToString('yyyy-MM') + $monthlyCosts[$monthKey] = $cost + } + } + catch { + Write-Warning " Budget history query failed for $subName : $($_.Exception.Message)" + continue + } + } + + if (-not $monthlyCosts -or $monthlyCosts.Count -eq 0) { continue } + + # Now create history rows per budget per month + foreach ($budget in $subGroup.Group) { + $budgetAmount = [double]$budget.Amount + # For quarterly/annual budgets, pro-rate to monthly equivalent + $monthlyAmount = switch ($budget.TimeGrain) { + 'Quarterly' { [math]::Round($budgetAmount / 3, 2) } + 'Annually' { [math]::Round($budgetAmount / 12, 2) } + default { $budgetAmount } + } + + for ($m = $MonthsBack; $m -ge 1; $m--) { + $monthDate = (Get-Date).AddMonths(-$m) + $monthKey = $monthDate.ToString('yyyy-MM') + $monthLabel = $monthDate.ToString('MMM yyyy') + $actual = if ($monthlyCosts.ContainsKey($monthKey)) { $monthlyCosts[$monthKey] } else { 0 } + $pctUsed = if ($monthlyAmount -gt 0) { [math]::Round(($actual / $monthlyAmount) * 100, 1) } else { 0 } + $status = if ($pctUsed -gt 100) { 'Over' } + elseif ($pctUsed -gt 90) { 'Near Limit' } + else { 'Under' } + + [void]$history.Add([PSCustomObject]@{ + Subscription = $subName + BudgetName = $budget.BudgetName + Month = $monthLabel + MonthSort = $monthKey + BudgetAmount = $monthlyAmount + ActualSpend = $actual + PctUsed = $pctUsed + Status = $status + Currency = $budget.Currency + }) + } + } + } + + return @($history | Sort-Object Subscription, BudgetName, MonthSort) +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 new file mode 100644 index 000000000..a26c29d51 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 @@ -0,0 +1,221 @@ +########################################################################### +# GET-CARBONMETRICS.PS1 +# AZURE FINOPS MULTITOOL - Carbon Emissions (Sustainability) +########################################################################### +# Purpose: Query the Azure Carbon Optimization service for greenhouse-gas +# emissions (kgCO2e) across subscriptions: latest-month total, +# month-over-month change, and a monthly trend. Supports FinOps +# sustainability KPIs (carbon footprint, emissions trend). +########################################################################### +# Notes: +# - Endpoint: POST /providers/Microsoft.Carbon/carbonEmissionReports +# (tenant scope), api-version 2025-04-01. subscriptionList goes in the +# body (max 100 per call, lowercase ids) so we batch in chunks of 100. +# - Emissions data lags ~2 months and is only available for months that +# have been published. We probe recent month windows and walk back +# until the service returns data, then degrade gracefully if none. +# - RBAC: Reader (or Carbon Optimization Reader) on each subscription. +########################################################################### + +function Get-CarbonMetrics { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $apiVersion = '2025-04-01' + $carbonPath = "/providers/Microsoft.Carbon/carbonEmissionReports?api-version=$apiVersion" + $scopeList = @('Scope1', 'Scope2', 'Scope3') + + $subCount = $Subscriptions.Count + Write-Host " Querying carbon emissions ($subCount subs)..." -ForegroundColor Cyan + + # All subscription ids, lowercased, batched into <=100 per request. + $allIds = @($Subscriptions | ForEach-Object { ([string]$_.Id).ToLower() }) + $batches = [System.Collections.Generic.List[object]]::new() + for ($b = 0; $b -lt $allIds.Count; $b += 100) { + $end = [math]::Min($b + 99, $allIds.Count - 1) + [void]$batches.Add(@($allIds[$b..$end])) + } + + # -- Resolve an available emissions window ---------------------------- + # Carbon data publishes ~2 months in arrears. Probe candidate "latest" + # months (current-1 .. current-4, first of month) using the first + # subscription batch; the first window that returns data is reused for + # every batch. Returns $null when no recent window has data. + $firstOfMonth = Get-Date -Day 1 -Hour 0 -Minute 0 -Second 0 + $window = $null + foreach ($lag in 1..4) { + $endMonth = $firstOfMonth.AddMonths(-$lag) + $startMonth = $endMonth.AddMonths(-11) + $probeBody = @{ + reportType = 'OverallSummaryReport' + subscriptionList = $batches[0] + carbonScopeList = $scopeList + dateRange = @{ + start = $startMonth.ToString('yyyy-MM-dd') + end = $endMonth.ToString('yyyy-MM-dd') + } + } | ConvertTo-Json -Depth 6 + + try { + $probe = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $probeBody + } + catch { + $probe = $null + } + + if ($probe -and $probe.StatusCode -eq 200) { + $window = @{ Start = $startMonth; End = $endMonth } + break + } + + # 404/400 here usually means "no data for that window" or the + # provider isn't registered/available - try an earlier window. + } + + if (-not $window) { + Write-Host " No carbon emissions data available (provider unavailable or no published months)." -ForegroundColor DarkGray + return [PSCustomObject]@{ + HasData = $false + TotalEmissionsKg = 0 + PreviousMonthKg = 0 + ChangeRatio = 0 + ChangeValueKg = 0 + LatestMonth = $null + MonthlyTrend = @() + BySubscription = @() + Unit = 'kgCO2e' + ScannedSubs = $subCount + Note = 'Carbon Optimization returned no data. Requires Reader on subscriptions; emissions publish ~2 months in arrears.' + } + } + + $startStr = $window.Start.ToString('yyyy-MM-dd') + $endStr = $window.End.ToString('yyyy-MM-dd') + + $totalLatest = 0.0 + $totalPrevious = 0.0 + $monthlyTotals = @{} # key 'yyyy-MM' -> kgCO2e + $bySub = [System.Collections.Generic.List[PSCustomObject]]::new() + + $batchNo = 0 + foreach ($batch in $batches) { + $batchNo++ + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying carbon emissions (batch $batchNo/$($batches.Count))..." + } + + # -- Overall summary (headline latest + previous month) ----------- + $overallBody = @{ + reportType = 'OverallSummaryReport' + subscriptionList = $batch + carbonScopeList = $scopeList + dateRange = @{ start = $startStr; end = $endStr } + } | ConvertTo-Json -Depth 6 + + try { + $resp = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $overallBody + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + foreach ($row in @($data.value)) { + $latest = if ($null -ne $row.latestMonthEmissions) { [double]$row.latestMonthEmissions } else { 0 } + $previous = if ($null -ne $row.previousMonthEmissions) { [double]$row.previousMonthEmissions } else { 0 } + $totalLatest += $latest + $totalPrevious += $previous + } + } + } + catch { + Write-Warning " Carbon overall query failed (batch $batchNo): $($_.Exception.Message)" + } + + # -- Per-subscription summary (ItemDetails by subscription) ------- + $itemBody = @{ + reportType = 'ItemDetailsReport' + subscriptionList = $batch + carbonScopeList = $scopeList + categoryType = 'Subscription' + orderBy = 'LatestMonthEmissions' + sortDirection = 'Desc' + pageSize = 100 + dateRange = @{ start = $endStr; end = $endStr } + } | ConvertTo-Json -Depth 6 + + try { + $resp = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $itemBody + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + foreach ($row in @($data.value)) { + $subId = if ($row.itemName) { [string]$row.itemName } else { '' } + $subObj = $Subscriptions | Where-Object { ([string]$_.Id).ToLower() -eq $subId.ToLower() } | Select-Object -First 1 + $latest = if ($null -ne $row.latestMonthEmissions) { [double]$row.latestMonthEmissions } else { 0 } + [void]$bySub.Add([PSCustomObject]@{ + Subscription = if ($subObj) { $subObj.Name } else { $subId } + SubscriptionId = $subId + EmissionsKg = [math]::Round($latest, 3) + }) + } + } + } + catch { + Write-Warning " Carbon per-subscription query failed (batch $batchNo): $($_.Exception.Message)" + } + + # -- Monthly trend (12-month series) ------------------------------ + $monthlyBody = @{ + reportType = 'MonthlySummaryReport' + subscriptionList = $batch + carbonScopeList = $scopeList + dateRange = @{ start = $startStr; end = $endStr } + } | ConvertTo-Json -Depth 6 + + try { + $resp = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $monthlyBody + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + foreach ($row in @($data.value)) { + $month = '' + if ($row.date) { try { $month = ([datetime]$row.date).ToString('yyyy-MM') } catch { $month = [string]$row.date } } + elseif ($row.month) { $month = [string]$row.month } + if (-not $month) { continue } + $val = if ($null -ne $row.totalCarbonEmission) { [double]$row.totalCarbonEmission } + elseif ($null -ne $row.carbonEmission) { [double]$row.carbonEmission } + elseif ($null -ne $row.latestMonthEmissions) { [double]$row.latestMonthEmissions } + else { 0 } + if (-not $monthlyTotals.ContainsKey($month)) { $monthlyTotals[$month] = 0.0 } + $monthlyTotals[$month] += $val + } + } + } + catch { + Write-Warning " Carbon monthly query failed (batch $batchNo): $($_.Exception.Message)" + } + } + + $trend = @( + $monthlyTotals.Keys | Sort-Object | ForEach-Object { + [PSCustomObject]@{ Month = $_; EmissionsKg = [math]::Round($monthlyTotals[$_], 3) } + } + ) + + $changeValue = $totalLatest - $totalPrevious + $changeRatio = if ($totalPrevious -ne 0) { [math]::Round(($changeValue / $totalPrevious) * 100, 1) } else { 0 } + + $hasData = ($totalLatest -gt 0) -or ($trend.Count -gt 0) -or ($bySub.Count -gt 0) + + return [PSCustomObject]@{ + HasData = $hasData + TotalEmissionsKg = [math]::Round($totalLatest, 3) + PreviousMonthKg = [math]::Round($totalPrevious, 3) + ChangeValueKg = [math]::Round($changeValue, 3) + ChangeRatio = $changeRatio + LatestMonth = $window.End.ToString('yyyy-MM') + MonthlyTrend = $trend + BySubscription = @($bySub | Sort-Object EmissionsKg -Descending) + Unit = 'kgCO2e' + ScannedSubs = $subCount + Note = if ($hasData) { $null } else { 'No emissions returned for the available window.' } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 new file mode 100644 index 000000000..a123c58d6 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 @@ -0,0 +1,306 @@ +########################################################################### +# GET-COMMITMENTUTILIZATION.PS1 +# AZURE FINOPS MULTITOOL - RI & Savings Plan Utilization +########################################################################### +# Purpose: Query existing reservation and savings plan utilization to show +# how well current commitments are being used. This answers the +# CFO question: "Are we wasting what we already bought?" +########################################################################### + +function Get-CommitmentUtilization { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions, + + [Parameter()] + [string]$AgreementType + ) + + Write-Host " Querying commitment utilization..." -ForegroundColor Cyan + + $reservations = @() + $savingsPlans = @() + $subIds = $Subscriptions | ForEach-Object { $_.Id } + + # Set to $true if a reservation/savings-plan query is forbidden (401/403) + # rather than simply returning no commitments. + $accessDenied = $false + + # -- Step 0 (MCA/MPA): Resolve billing profiles for this tenant ----- + # Under MCA, reservations and savings plans are scoped to the billing + # profile, NOT the subscription. Subscription-level Consumption API + # calls return empty for MCA agreements. + $billingProfileIds = @() + if ($AgreementType -in @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { + Write-Host " MCA/MPA detected — resolving billing profiles..." -ForegroundColor Cyan + # Discover billing account IDs linked to scanned subscriptions + $tenantBillingAccountIds = @{} + foreach ($sub in @($Subscriptions | Select-Object -First 10)) { + try { + $biPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Billing/billingInfo/default?api-version=2024-04-01" + $biResp = Invoke-AzRestMethodWithRetry -Path $biPath -Method GET + if ($biResp.StatusCode -eq 200) { + $biResult = ($biResp.Content | ConvertFrom-Json) + $baId = $biResult.properties.billingAccountId + if ($baId) { $tenantBillingAccountIds[$baId] = $true } + } + } catch { } + } + + # Get billing profiles for those accounts + try { + $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" + $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET + if ($baResp.StatusCode -eq 200) { + $baResult = ($baResp.Content | ConvertFrom-Json) + foreach ($ba in $baResult.value) { + if ($tenantBillingAccountIds.Count -gt 0 -and -not $tenantBillingAccountIds.ContainsKey($ba.id)) { + # Normalize — try matching on name portion only + $baNamePortion = $ba.id -replace '.*/billingAccounts/', '' + $matched = $false + foreach ($k in $tenantBillingAccountIds.Keys) { + $kName = $k -replace '.*/billingAccounts/', '' + if ($kName -eq $baNamePortion) { $matched = $true; break } + } + if (-not $matched) { continue } + } + if ($ba.properties.agreementType -notin @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { continue } + try { + $bpPath = "$($ba.id)/billingProfiles?api-version=2024-04-01" + $bpResp = Invoke-AzRestMethodWithRetry -Path $bpPath -Method GET + if ($bpResp.StatusCode -eq 200) { + $bpResult = ($bpResp.Content | ConvertFrom-Json) + foreach ($bp in $bpResult.value) { $billingProfileIds += $bp.id } + } + } catch { } + } + } + } catch { + Write-Warning " Billing profile resolution failed: $($_.Exception.Message)" + } + Write-Host " Found $($billingProfileIds.Count) billing profile(s) for MCA commitment queries." -ForegroundColor Cyan + } + + # -- Step 1: Get all reservations and their utilization -------------- + # For MCA: query at billing-profile scope first + if ($billingProfileIds.Count -gt 0) { + foreach ($bpId in $billingProfileIds) { + try { + $summaryPath = "$bpId/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'" + $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET + if ($resp.StatusCode -eq 200) { + $data = ($resp.Content | ConvertFrom-Json) + if ($data.value) { + foreach ($item in $data.value) { + $p = $item.properties + $reservations += [PSCustomObject]@{ + ReservationOrderId = $p.reservationOrderId + ReservationId = $p.reservationId + SkuName = $p.skuName + Kind = $p.kind + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1) + MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1) + ReservedHours = $p.reservedHours + UsedHours = $p.usedHours + UsageDate = $p.usageDate + } + } + } + } + elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Reservation query at billing profile scope failed: $($_.Exception.Message)" + } + } + } + + # For EA / fallback: query at subscription scope + if ($reservations.Count -eq 0) { + foreach ($sub in $Subscriptions | Select-Object -First 10) { + try { + $summaryPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'" + $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET + if ($resp.StatusCode -eq 200) { + $data = ($resp.Content | ConvertFrom-Json) + if ($data.value) { + foreach ($item in $data.value) { + $p = $item.properties + $reservations += [PSCustomObject]@{ + ReservationOrderId = $p.reservationOrderId + ReservationId = $p.reservationId + SkuName = $p.skuName + Kind = $p.kind + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1) + MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1) + ReservedHours = $p.reservedHours + UsedHours = $p.usedHours + UsageDate = $p.usageDate + } + } + break # Got data from one sub, don't repeat + } + } + elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Reservation summaries query failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Step 2: Try the Reservation Orders API at billing scope -- + if ($reservations.Count -eq 0) { + try { + $roPath = "/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $roPath -Method GET + if ($resp.StatusCode -eq 200) { + $data = ($resp.Content | ConvertFrom-Json) + if ($data.value) { + foreach ($order in $data.value) { + $op = $order.properties + if ($op.reservations) { + foreach ($ri in $op.reservations) { + # Get utilization summary for each reservation + try { + $utilPath = "$($ri.id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'" + $utilResp = Invoke-AzRestMethodWithRetry -Path $utilPath -Method GET + if ($utilResp.StatusCode -eq 200) { + $utilData = ($utilResp.Content | ConvertFrom-Json) + if ($utilData.value -and $utilData.value.Count -gt 0) { + $latest = $utilData.value | Select-Object -Last 1 + $up = $latest.properties + $reservations += [PSCustomObject]@{ + ReservationOrderId = $order.name + ReservationId = $ri.id.Split('/')[-1] + SkuName = $op.displayProvisioningState + Kind = $op.billingScopeId + AvgUtilization = [math]::Round([double]$up.avgUtilizationPercentage, 1) + MinUtilization = [math]::Round([double]$up.minUtilizationPercentage, 1) + MaxUtilization = [math]::Round([double]$up.maxUtilizationPercentage, 1) + ReservedHours = $up.reservedHours + UsedHours = $up.usedHours + UsageDate = $up.usageDate + } + } + } + } catch { } + } + } + } + } + } + elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Reservation orders query failed: $($_.Exception.Message)" + } + } + + # -- Step 3: Savings Plans utilization via Benefit Utilization Summaries -- + # For MCA: query at billing-profile scope first + if ($billingProfileIds.Count -gt 0 -and $savingsPlans.Count -eq 0) { + foreach ($bpId in $billingProfileIds) { + try { + $spPath = "$bpId/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly" + $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET + if ($spResp.StatusCode -eq 200) { + $spData = ($spResp.Content | ConvertFrom-Json) + if ($spData.value) { + foreach ($item in $spData.value) { + $p = $item.properties + if ($p.benefitType -eq 'SavingsPlan') { + $savingsPlans += [PSCustomObject]@{ + BenefitId = $p.benefitOrderId + BenefitType = $p.benefitType + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + UsageDate = $p.usageDate + } + } + } + } + } + elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Savings plan query at billing profile scope failed: $($_.Exception.Message)" + } + } + } + + # Fallback: subscription scope (EA, PAYG, etc.) + if ($savingsPlans.Count -eq 0) { + try { + foreach ($sub in $Subscriptions | Select-Object -First 5) { + $spPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly" + $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET + if ($spResp.StatusCode -eq 200) { + $spData = ($spResp.Content | ConvertFrom-Json) + if ($spData.value) { + foreach ($item in $spData.value) { + $p = $item.properties + if ($p.benefitType -eq 'SavingsPlan') { + $savingsPlans += [PSCustomObject]@{ + BenefitId = $p.benefitOrderId + BenefitType = $p.benefitType + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + UsageDate = $p.usageDate + } + } + } + if ($savingsPlans.Count -gt 0) { break } + } + } + elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Savings plan utilization query failed: $($_.Exception.Message)" + } + } + + # -- Step 4: Calculate summary stats -- + $riAvgUtil = 0 + $riCount = $reservations.Count + if ($riCount -gt 0) { + $riAvgUtil = [math]::Round(($reservations | Measure-Object -Property AvgUtilization -Average).Average, 1) + } + + $spAvgUtil = 0 + $spCount = $savingsPlans.Count + if ($spCount -gt 0) { + $spAvgUtil = [math]::Round(($savingsPlans | Measure-Object -Property AvgUtilization -Average).Average, 1) + } + + $underutilized = @($reservations | Where-Object { $_.AvgUtilization -lt 80 }) + + $denied = ($accessDenied -and $riCount -eq 0 -and $spCount -eq 0) + + # Human-readable summary so the zeros below are never mistaken for + # "no commitments / all healthy" when the real cause is no access. + $note = if ($denied) { + 'Access denied reading reservation/savings-plan utilization (needs Cost Management Reader / billing-scope access). The zero counts below reflect missing access, NOT confirmed absence of commitments.' + } + elseif ($riCount -eq 0 -and $spCount -eq 0) { + 'No reservations or savings plans found in scope.' + } + else { + "$riCount reservation(s) avg $riAvgUtil% util; $spCount savings plan(s) avg $spAvgUtil% util." + } + + return [PSCustomObject]@{ + Reservations = $reservations + SavingsPlans = $savingsPlans + RICount = $riCount + SPCount = $spCount + RIAvgUtilization = $riAvgUtil + SPAvgUtilization = $spAvgUtil + UnderutilizedRIs = $underutilized + HasData = ($riCount -gt 0 -or $spCount -gt 0) + AccessDenied = $denied + Note = $note + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 new file mode 100644 index 000000000..e869e3f5f --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 @@ -0,0 +1,155 @@ +########################################################################### +# GET-CONTRACTINFO.PS1 +# AZURE FINOPS MULTITOOL - Billing Account & Contract Type Detection +########################################################################### +# Purpose: Detect the customer's Azure contract type (EA, MCA, PAYGO, CSP) +# and return billing account details. +# +# Contract Types: +# EnterpriseAgreement Enterprise Agreement (EA) +# MicrosoftCustomerAgreement Microsoft Customer Agreement (MCA) +# MicrosoftOnlineServicesProgram Pay-As-You-Go (PAYGO / MOSP) +# MicrosoftPartnerAgreement CSP / Partner (MPA) +# +# Reference: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/view-all-accounts +########################################################################### + +function Get-ContractInfo { + [CmdletBinding()] + param( + [Parameter()] + [object[]]$Subscriptions + ) + + $inferredAgreement = $null + $inferredFriendly = $null + + # -- Step 1: Detect agreement type from subscription quotaId --------- + # QuotaId is always scoped to the correct tenant when using passed subs + $subsToCheck = if ($Subscriptions) { @($Subscriptions | Select-Object -First 3) } else { @() } + if ($subsToCheck.Count -eq 0) { + try { $subsToCheck = @(Get-AzSubscription -ErrorAction SilentlyContinue | Select-Object -First 3) } catch { } + } + + foreach ($sub in $subsToCheck) { + try { + $subPath = "/subscriptions/$($sub.Id)?api-version=2022-12-01" + $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method GET + if ($subResp.StatusCode -eq 200) { + $subDetail = ($subResp.Content | ConvertFrom-Json) + $quotaId = $subDetail.properties.subscriptionPolicies.quotaId + + $mapped = switch -Regex ($quotaId) { + 'EnterpriseAgreement' { @{ Agreement = 'EnterpriseAgreement'; Friendly = 'Enterprise Agreement (EA)' } } + 'MCSFree|MSDN|Visual' { @{ Agreement = 'MSDN'; Friendly = 'Visual Studio / MSDN' } } + 'PayAsYouGo|PAYG' { @{ Agreement = 'MicrosoftOnlineServicesProgram'; Friendly = 'Pay-As-You-Go (PAYGO)' } } + 'Sponsored' { @{ Agreement = 'Sponsored'; Friendly = 'Azure Sponsored' } } + 'CSP' { @{ Agreement = 'MicrosoftPartnerAgreement'; Friendly = 'CSP / Partner Agreement' } } + 'Internal' { @{ Agreement = 'Internal'; Friendly = 'Microsoft Internal' } } + 'MCA' { @{ Agreement = 'MicrosoftCustomerAgreement'; Friendly = 'Microsoft Customer Agreement (MCA)' } } + 'FreeTrial' { @{ Agreement = 'FreeTrial'; Friendly = 'Free Trial' } } + 'AAD' { @{ Agreement = 'AAD'; Friendly = 'Azure AD Subscription' } } + 'MSAZR' { @{ Agreement = 'MicrosoftOnlineServicesProgram'; Friendly = 'Pay-As-You-Go (PAYGO)' } } + default { @{ Agreement = $quotaId; Friendly = $quotaId } } + } + + if ($mapped) { + $inferredAgreement = $mapped.Agreement + $inferredFriendly = $mapped.Friendly + Write-Host " QuotaId detected: $quotaId -> $inferredFriendly" -ForegroundColor Green + break + } + } + } catch { } + } + + # -- Step 2: Try billing accounts API, filtered by inferred type ----- + try { + $response = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" -Method GET + if (-not $response -or -not $response.Content) { throw "Billing accounts API returned no content (HTTP $($response.StatusCode))" } + $result = ($response.Content | ConvertFrom-Json) + + if ($result.value -and $result.value.Count -gt 0) { + $matchedAccount = $null + + if ($result.value.Count -eq 1) { + # Single billing account in scope - unambiguous. + $matchedAccount = $result.value[0] + } + else { + # Multiple billing accounts are visible across every tenant the + # signed-in identity can reach. Picking by agreement type alone + # can surface an account from an unrelated tenant, so confirm the + # account actually owns one of the scanned subscriptions before + # trusting it. Prefer candidates matching the inferred agreement. + $scanIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($sc in $subsToCheck) { if ($sc.Id) { [void]$scanIds.Add([string]$sc.Id) } } + + $candidates = if ($inferredAgreement) { + @($result.value | Where-Object { $_.properties.agreementType -eq $inferredAgreement }) + } else { @() } + if (-not $candidates -or $candidates.Count -eq 0) { $candidates = @($result.value) } + + foreach ($cand in $candidates) { + if ($scanIds.Count -eq 0) { break } + try { + $bsPath = "/providers/Microsoft.Billing/billingAccounts/$($cand.name)/billingSubscriptions?api-version=2024-04-01" + $bsResp = Invoke-AzRestMethodWithRetry -Path $bsPath -Method GET + if ($bsResp -and $bsResp.StatusCode -eq 200 -and $bsResp.Content) { + $bsData = ($bsResp.Content | ConvertFrom-Json) + $owns = $false + foreach ($bs in @($bsData.value)) { + $bsSubId = if ($bs.properties.subscriptionId) { [string]$bs.properties.subscriptionId } else { [string]$bs.name } + if ($scanIds.Contains($bsSubId)) { $owns = $true; break } + } + if ($owns) { $matchedAccount = $cand; break } + } + } catch { } + } + # No account could be confirmed to own the scanned subscription - + # fall through to the subscription-accurate quotaId inference. + } + + if (-not $matchedAccount) { throw "No billing account confirmed for the scanned subscription" } + + $props = $matchedAccount.properties + $friendlyType = switch ($props.agreementType) { + 'EnterpriseAgreement' { 'Enterprise Agreement (EA)' } + 'MicrosoftCustomerAgreement' { 'Microsoft Customer Agreement (MCA)' } + 'MicrosoftOnlineServicesProgram' { 'Pay-As-You-Go (PAYGO)' } + 'MicrosoftPartnerAgreement' { 'CSP / Partner Agreement (MPA)' } + default { $props.agreementType } + } + + return @([PSCustomObject]@{ + AccountName = $props.displayName + AccountId = $matchedAccount.name + AgreementType = $props.agreementType + FriendlyType = $friendlyType + AccountStatus = $props.accountStatus + Currency = if ($props.soldTo) { $props.soldTo.country } else { 'Unknown' } + }) + } + } catch { + Write-Warning "Billing account query failed: $($_.Exception.Message)" + } + + # -- Step 3: Return quotaId-based inference if billing API failed ---- + if ($inferredAgreement) { + $subName = if ($subsToCheck.Count -gt 0) { $subsToCheck[0].Name } else { 'Unknown' } + return @([PSCustomObject]@{ + AccountName = "Inferred from subscription: $subName" + AccountId = if ($subsToCheck.Count -gt 0) { $subsToCheck[0].Id } else { '' } + AgreementType = $inferredAgreement + FriendlyType = $inferredFriendly + AccountStatus = 'Active' + Currency = 'Unknown' + }) + } + + return @([PSCustomObject]@{ + AccountName = 'Unknown' + AgreementType = 'Unknown' + FriendlyType = 'Could not detect (assign Billing Reader for accurate detection)' + }) +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 new file mode 100644 index 000000000..7b13837d9 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 @@ -0,0 +1,515 @@ +########################################################################### +# GET-COSTBYTAG.PS1 +# AZURE FINOPS MULTITOOL - Cost Breakdown by Tag +########################################################################### +# Purpose: For each CAF allocation tag (CostCenter, BusinessUnit, +# etc.), query Cost Management to show how spend distributes +# across tag values. If no meaningful tags exist, fall back +# to cost-by-subscription so the user still sees a breakdown. +# +# This is the "Understand" pillar - cost allocation and showback. +########################################################################### + +function Get-CostByTag { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [hashtable]$ExistingTags, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [switch]$RestrictToSelected + ) + + # Tags we want to break cost down by (in priority order — matches CAF allocation tags) + $targetTags = @('CostCenter', 'BusinessUnit', 'ApplicationName', 'WorkloadName', 'OpsTeam', 'Criticality', 'DataClassification') + + # Also check variations + $variations = @{ + 'CostCenter' = @('cost-center', 'costcenter', 'cost_center', 'cc') + 'BusinessUnit' = @('bu', 'businessunit', 'business-unit', 'department', 'dept') + 'ApplicationName' = @('applicationname', 'application', 'app', 'appname', 'app-name') + 'WorkloadName' = @('workloadname', 'workload', 'workload-name', 'workload_name') + 'OpsTeam' = @('opsteam', 'ops-team', 'ops_team', 'owner', 'technicalowner') + 'Criticality' = @('criticality', 'sla', 'tier', 'importance') + 'DataClassification' = @('dataclassification', 'data-classification', 'data_classification', 'classification') + } + + $existingKeys = if ($ExistingTags) { $ExistingTags.Keys | ForEach-Object { $_.ToLower() } } else { @() } + $tagsToQuery = @() + + foreach ($tag in $targetTags) { + # Check exact match first + $match = $existingKeys | Where-Object { $_ -eq $tag.ToLower() } | Select-Object -First 1 + if ($match) { + # Find the properly-cased version from existing tags + $properCase = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $match } | Select-Object -First 1 + $tagsToQuery += $properCase + continue + } + + # Check variations + if ($variations.ContainsKey($tag)) { + $varMatch = $existingKeys | Where-Object { $_ -in $variations[$tag] } | Select-Object -First 1 + if ($varMatch) { + $properCase = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $varMatch } | Select-Object -First 1 + $tagsToQuery += $properCase + } + } + } + + # Also include any additional existing tags not already in the list + # Sorted by resource coverage (descending) so the most-used tags survive any cap + if ($ExistingTags) { + $alreadyLower = $tagsToQuery | ForEach-Object { $_.ToLower() } + $systemPrefixes = @('hidden-', 'ms-resource-', 'aks-managed-', 'kubernetes.io', 'displayname') + # Exact-match system/auto-generated tags (Azure Policy, Monitor, Automanage, etc.) + $systemExact = @( + 'action', 'automanage', 'alertrulecreatedwithalertsrecommendations', + 'createdby', 'createddate', 'createdtime', 'createdon', + 'environment-type', 'intune-deployed', 'policyassignmentname', + 'statuschangedate', 'vmsize', 'offer', 'publisher', 'sku' + ) + $extras = @() + foreach ($key in $ExistingTags.Keys) { + if ($key.ToLower() -in $alreadyLower) { continue } + $skip = $false + if ($key.ToLower() -in $systemExact) { $skip = $true } + if (-not $skip) { + foreach ($prefix in $systemPrefixes) { + if ($key.ToLower().StartsWith($prefix)) { $skip = $true; break } + } + } + if (-not $skip) { + $coverage = if ($ExistingTags[$key].TotalResources) { $ExistingTags[$key].TotalResources } else { 0 } + $extras += [PSCustomObject]@{ Name = $key; Coverage = $coverage } + } + } + $extras = $extras | Sort-Object Coverage -Descending + foreach ($e in $extras) { $tagsToQuery += $e.Name } + } + + # Skip tags with very low resource coverage (< 3 resources tagged) + # These produce mostly "(untagged)" results and waste API calls + if ($ExistingTags -and $tagsToQuery.Count -gt 8) { + $cafTags = $tagsToQuery | Select-Object -First ([math]::Min($tagsToQuery.Count, 7)) + $extraTags = $tagsToQuery | Select-Object -Skip 7 + $filteredExtras = @() + foreach ($t in $extraTags) { + $tagInfo = if ($ExistingTags.ContainsKey($t)) { $ExistingTags[$t] } else { $null } + $count = if ($tagInfo -and $tagInfo.TotalResources) { $tagInfo.TotalResources } else { 0 } + if ($count -ge 3) { $filteredExtras += $t } + } + $skipped = $tagsToQuery.Count - $cafTags.Count - $filteredExtras.Count + $tagsToQuery = $cafTags + $filteredExtras + if ($skipped -gt 0) { + Write-Host " Skipped $skipped low-coverage tags (< 3 resources) to reduce API calls" -ForegroundColor Yellow + } + } + + $results = @{} + + # Helper: normalize an ARG tags bag (PSCustomObject or hashtable) into a + # plain hashtable of tagKey -> tagValue. + function ConvertTo-TagHash { + param($Tags) + $h = @{} + if (-not $Tags) { return $h } + if ($Tags -is [System.Collections.IDictionary]) { + foreach ($k in $Tags.Keys) { $h[[string]$k] = [string]$Tags[$k] } + } + else { + foreach ($p in $Tags.PSObject.Properties) { $h[[string]$p.Name] = [string]$p.Value } + } + return $h + } + + # === Build resource -> tag maps via Azure Resource Graph ============= + # The reliable, throttle-resistant way to allocate cost by tag is to query + # cost grouped by ResourceId (one cheap, universally-supported call per + # subscription) and join it client-side against each resource's tags. ARG + # supplies that join data without hammering the Cost Management API. We + # capture resource-level tags plus resource-group-level tags (used as a + # fallback when a resource carries no tag of its own but its RG does). + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + $resTagMap = @{} # lowercased resourceId -> @{ tagKey = tagValue } + $rgTagMap = @{} # lowercased resourceGroup id -> @{ tagKey = tagValue } + try { + Write-Host " Building resource -> tag map via Resource Graph..." -ForegroundColor Cyan + $resTagQuery = "resources | where isnotempty(tags) | project id, tags" + $skip = $null + do { + $r = Search-AzGraphSafe -Query $resTagQuery -Subscription $subIds -First 1000 -SkipToken $skip + if (-not $r -or -not $r.Data) { break } + foreach ($row in $r.Data) { + if ($row.id) { $resTagMap[([string]$row.id).ToLower()] = ConvertTo-TagHash $row.tags } + } + $skip = $r.SkipToken + } while ($skip) + + $rgTagQuery = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups' | where isnotempty(tags) | project id, tags" + $skip = $null + do { + $r = Search-AzGraphSafe -Query $rgTagQuery -Subscription $subIds -First 1000 -SkipToken $skip + if (-not $r -or -not $r.Data) { break } + foreach ($row in $r.Data) { + if ($row.id) { $rgTagMap[([string]$row.id).ToLower()] = ConvertTo-TagHash $row.tags } + } + $skip = $r.SkipToken + } while ($skip) + + Write-Host " Mapped tags for $($resTagMap.Count) resources, $($rgTagMap.Count) resource groups" -ForegroundColor Gray + } + catch { + Write-Warning "Resource tag map build failed: $($_.Exception.Message). Cost-by-tag will rely on whatever map data was collected." + } + + # Helper: parse a ResourceId-grouped Cost Management response into rows of + # @{ ResourceId; Cost; Currency }. Rows with an empty ResourceId represent + # non-resource charges (reservations, marketplace, refunds/credits). + function Parse-ResourceIdRows { + param($ResponseContent) + $parsed = [System.Collections.Generic.List[PSCustomObject]]::new() + $result = ($ResponseContent | ConvertFrom-Json) + if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) { + return $parsed + } + $cols = $result.properties.columns + $costIdx = -1; $ridIdx = -1; $currIdx = -1 + for ($i = 0; $i -lt $cols.Count; $i++) { + $n = $cols[$i].name.ToLower() + if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $i } + elseif ($n -eq 'resourceid') { $ridIdx = $i } + } + if ($costIdx -eq -1) { $costIdx = 0 } + if ($ridIdx -eq -1) { $ridIdx = 1 } + if ($currIdx -eq -1) { $currIdx = if ($cols.Count -ge 3) { 2 } else { -1 } } + + foreach ($row in $result.properties.rows) { + $cost = [math]::Round([double]$row[$costIdx], 2) + $rid = if ($ridIdx -ge 0 -and $ridIdx -lt $row.Count -and $row[$ridIdx]) { [string]$row[$ridIdx] } else { '' } + $currency = if ($currIdx -ge 0 -and $currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' } + [void]$parsed.Add([PSCustomObject]@{ ResourceId = $rid; Cost = $cost; Currency = $currency }) + } + return $parsed + } + + # Helper: parse Cost Management query response using column headers + function Parse-CostRows { + param($ResponseContent) + $parsed = [System.Collections.Generic.List[PSCustomObject]]::new() + $result = ($ResponseContent | ConvertFrom-Json) + if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) { + return $parsed + } + # Build column index map from response + $cols = $result.properties.columns + $costIdx = -1; $tagIdx = -1; $currIdx = -1 + for ($i = 0; $i -lt $cols.Count; $i++) { + $n = $cols[$i].name.ToLower() + if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $i } + elseif ($n -eq 'tagvalue') { $tagIdx = $i } + } + # Fallback: if TagValue column not found, pick first String column that isn't TagKey or Currency + if ($tagIdx -eq -1) { + for ($i = 0; $i -lt $cols.Count; $i++) { + if ($cols[$i].type -eq 'String' -and $i -ne $currIdx -and $cols[$i].name.ToLower() -ne 'tagkey') { $tagIdx = $i; break } + } + } + # Final positional fallback + if ($costIdx -eq -1) { $costIdx = 0 } + if ($tagIdx -eq -1) { $tagIdx = if ($cols.Count -ge 4) { 2 } else { 1 } } + if ($currIdx -eq -1) { $currIdx = if ($cols.Count -ge 4) { 3 } else { 2 } } + + foreach ($row in $result.properties.rows) { + $cost = [math]::Round([double]$row[$costIdx], 2) + $value = if ($row[$tagIdx]) { $row[$tagIdx] } else { '(untagged)' } + $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' } + [void]$parsed.Add([PSCustomObject]@{ TagValue = $value; Cost = $cost; Currency = $currency }) + } + return $parsed + } + + # Helper: parse batched TagKey+TagValue response into per-tag results + function Parse-BatchedCostRows { + param($ResponseContent) + $perTag = @{} + $result = ($ResponseContent | ConvertFrom-Json) + if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) { + return $perTag + } + $cols = $result.properties.columns + $costIdx = -1; $keyIdx = -1; $valIdx = -1; $currIdx = -1 + for ($i = 0; $i -lt $cols.Count; $i++) { + $n = $cols[$i].name.ToLower() + if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i } + elseif ($n -eq 'tagkey') { $keyIdx = $i } + elseif ($n -eq 'tagvalue') { $valIdx = $i } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $i } + } + if ($costIdx -eq -1) { $costIdx = 0 } + if ($keyIdx -eq -1) { $keyIdx = 1 } + if ($valIdx -eq -1) { $valIdx = 2 } + if ($currIdx -eq -1) { $currIdx = 3 } + + foreach ($row in $result.properties.rows) { + $tagKey = if ($row[$keyIdx]) { $row[$keyIdx] } else { '' } + $tagVal = if ($row[$valIdx]) { $row[$valIdx] } else { '(untagged)' } + $cost = [math]::Round([double]$row[$costIdx], 2) + $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' } + if (-not $perTag.ContainsKey($tagKey)) { + $perTag[$tagKey] = [System.Collections.Generic.List[PSCustomObject]]::new() + } + [void]$perTag[$tagKey].Add([PSCustomObject]@{ TagValue = $tagVal; Cost = $cost; Currency = $currency }) + } + return $perTag + } + + # Helper: Fire multiple REST calls in parallel using the shared runspace pool. + # Each call handles its own 429 retry internally, so pool slots may block + # briefly on throttle but other slots continue processing. + function Invoke-ParallelRestCalls { + param( + [array]$Calls, # Array of @{ Path; Body; SubId; SubName } + [int]$TimeoutSeconds = 90 + ) + $pendingJobs = [System.Collections.Generic.List[hashtable]]::new() + foreach ($call in $Calls) { + $ps = [powershell]::Create() + $ps.RunspacePool = $script:RunspacePool + [void]$ps.AddScript({ + param($path, $payload) + for ($attempt = 0; $attempt -le 3; $attempt++) { + $params = @{ Path = $path; Method = 'POST'; ErrorAction = 'Stop' } + if ($payload) { $params['Payload'] = $payload } + try { + $r = Invoke-AzRestMethod @params + if ($r.StatusCode -ne 429) { + $hdrs = @{} + if ($r.Headers) { foreach ($k in $r.Headers.Keys) { $hdrs[$k] = $r.Headers[$k] } } + return [PSCustomObject]@{ StatusCode = $r.StatusCode; Content = $r.Content; Headers = $hdrs } + } + # 429 — parse Retry-After or exponential backoff + $retryAfter = 10 + if ($r.Headers -and $r.Headers['Retry-After']) { + $parsed = 0 + if ([int]::TryParse($r.Headers['Retry-After'], [ref]$parsed)) { $retryAfter = [math]::Max($parsed, 5) } + } + else { $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 60) } + Start-Sleep -Seconds $retryAfter + } + catch { + return [PSCustomObject]@{ StatusCode = 0; Content = "{`"error`":{`"message`":`"$($_.Exception.Message)`"}}"; Headers = @{} } + } + } + return [PSCustomObject]@{ StatusCode = 429; Content = '{"error":{"message":"Rate limited after retries"}}'; Headers = @{} } + }).AddArgument($call.Path).AddArgument($call.Body) + $async = $ps.BeginInvoke() + [void]$pendingJobs.Add(@{ PS = $ps; Async = $async; Call = $call; Result = $null }) + } + + # Poll until all complete or timeout, keeping WPF UI responsive + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $allDone = $true + foreach ($job in $pendingJobs) { + if ($null -ne $job.Result) { continue } + if ($job.Async.IsCompleted) { + try { + $raw = $job.PS.EndInvoke($job.Async) + $resp = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null } + if (-not $resp) { $resp = [PSCustomObject]@{ StatusCode = 0; Content = '{}'; Headers = @{} } } + if ($null -eq $resp.Content) { $resp = [PSCustomObject]@{ StatusCode = $resp.StatusCode; Content = '{}'; Headers = @{} } } + $job.Result = $resp + } + catch { + $job.Result = [PSCustomObject]@{ StatusCode = 0; Content = '{}'; Headers = @{} } + } + $job.PS.Dispose() + } + else { $allDone = $false } + } + if ($allDone) { break } + Wait-WithDispatcher -Milliseconds 50 + } + + # Cleanup any timed-out jobs + foreach ($job in $pendingJobs) { + if ($null -eq $job.Result) { + try { $job.PS.Stop() } catch { } + $job.PS.Dispose() + $job.Result = [PSCustomObject]@{ StatusCode = 408; Content = '{"error":{"message":"Timeout"}}'; Headers = @{} } + } + } + return $pendingJobs + } + + # === Query cost grouped by ResourceId, one call per subscription ====== + # ResourceId grouping is supported at subscription scope across EA, MCA and + # pay-as-you-go (unlike TagKey/TagValue, which 400/408s on most sub types). + # This collapses the old tags x subs x timeframes call matrix down to a + # single call per subscription, then attributes each resource's cost to its + # tag values client-side. The scan is built to COMPLETE even when some subs + # fail: a failed subscription is recorded and skipped, never thrown. + $usedTimeframe = 'MonthToDate' + $timeframes = @('MonthToDate', 'Custom') + + # Per-tag aggregation: tagName -> (tagValue -> accumulated cost) + $tagAgg = @{} + foreach ($t in $tagsToQuery) { $tagAgg[$t] = @{} } + $currencySeen = 'USD' + $subsQueried = 0 + $subsFailed = 0 + $grandTotal = 0.0 + + if (-not $Subscriptions -or $Subscriptions.Count -eq 0) { + Write-Host " No subscriptions available for cost-by-tag." -ForegroundColor Yellow + } + elseif ($tagsToQuery.Count -eq 0) { + Write-Host " No allocation tags found to break cost down by." -ForegroundColor Yellow + } + else { + foreach ($tf in $timeframes) { + # MonthToDate first; only fall back to last month (Custom) when MTD + # produced no cost at all (e.g. the first day of a new billing month). + if ($tf -eq 'Custom' -and $grandTotal -gt 0) { break } + if ($tf -eq 'Custom') { + Write-Host " MonthToDate returned no cost - retrying with last month..." -ForegroundColor Yellow + foreach ($t in $tagsToQuery) { $tagAgg[$t] = @{} } + $subsQueried = 0; $subsFailed = 0; $grandTotal = 0.0 + } + + $bodyObj = @{ + type = 'ActualCost' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @( @{ type = 'Dimension'; name = 'ResourceId' } ) + } + } + if ($tf -eq 'Custom') { + $lastMonthStart = (Get-Date).AddMonths(-1).ToString('yyyy-MM-01') + $lastMonthEnd = (Get-Date -Day 1).AddDays(-1).ToString('yyyy-MM-dd') + $bodyObj['timeframe'] = 'Custom' + $bodyObj['timePeriod'] = @{ from = $lastMonthStart; to = $lastMonthEnd } + } + else { + $bodyObj['timeframe'] = $tf + } + $body = $bodyObj | ConvertTo-Json -Depth 10 + + # Fan out across subscriptions in capped batches so a large tenant + # never floods the Cost Management API. Each call self-retries 429. + $batchSize = 8 + $subList = @($Subscriptions) + for ($offset = 0; $offset -lt $subList.Count; $offset += $batchSize) { + $upper = [math]::Min($offset + $batchSize - 1, $subList.Count - 1) + $slice = @($subList[$offset..$upper]) + $calls = @() + foreach ($sub in $slice) { + $calls += @{ + Path = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + Body = $body + SubId = $sub.Id + SubName = $sub.Name + } + } + $done = [math]::Min($offset + $slice.Count, $subList.Count) + Write-Host " Cost-by-tag: subscriptions $($offset + 1)-$done of $($subList.Count) ($tf)..." -ForegroundColor Cyan + $jobs = Invoke-ParallelRestCalls -Calls $calls -TimeoutSeconds 120 + foreach ($pj in $jobs) { + $subResp = $pj.Result + if ($subResp.StatusCode -eq 200) { + $subsQueried++ + $rows = Parse-ResourceIdRows -ResponseContent $subResp.Content + foreach ($row in $rows) { + $cost = $row.Cost + $grandTotal += $cost + if ($row.Currency) { $currencySeen = $row.Currency } + + if ([string]::IsNullOrWhiteSpace($row.ResourceId)) { + foreach ($t in $tagsToQuery) { + $cur = [double]$tagAgg[$t]['(non-resource charges)'] + $tagAgg[$t]['(non-resource charges)'] = $cur + $cost + } + continue + } + + $ridLower = $row.ResourceId.ToLower() + $tags = $resTagMap[$ridLower] + if (-not $tags -or $tags.Count -eq 0) { + $pIdx = $ridLower.IndexOf('/providers/') + if ($pIdx -gt 0) { + $rgId = $ridLower.Substring(0, $pIdx) + $tags = $rgTagMap[$rgId] + } + } + + foreach ($t in $tagsToQuery) { + $val = $null + if ($tags -and $tags.Count -gt 0) { + foreach ($k in $tags.Keys) { + if ($k -ieq $t) { $val = $tags[$k]; break } + } + } + if ([string]::IsNullOrWhiteSpace($val)) { $val = '(untagged resources)' } + $cur = [double]$tagAgg[$t][$val] + $tagAgg[$t][$val] = $cur + $cost + } + } + } + elseif ($subResp.StatusCode -eq 403) { + $subsFailed++ + $script:costAccessIssue = 'MCA' + } + elseif ($subResp.StatusCode -eq 400) { + $subsFailed++ + $errBody = try { ($subResp.Content | ConvertFrom-Json).error.message } catch { '' } + if ($errBody -match 'AO View Charges') { $script:costAccessIssue = 'EA' } + } + else { + $subsFailed++ + } + } + } + + $usedTimeframe = $tf + } + + if ($subsFailed -gt 0) { + Write-Host " Cost-by-tag completed: $subsQueried subscription(s) returned data, $subsFailed skipped (timeout / throttle / access)." -ForegroundColor Yellow + } + else { + Write-Host " Cost-by-tag completed: $subsQueried subscription(s) returned data." -ForegroundColor Green + } + } + + # === Materialize aggregation into the result contract ================= + # For each tag, emit { TagValue; Cost; Currency } rows sorted by cost desc. + # The synthetic "(untagged resources)" and "(non-resource charges)" values + # reconcile the breakdown back to the subscription invoice total. + foreach ($t in $tagsToQuery) { + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($val in $tagAgg[$t].Keys) { + $c = [math]::Round([double]$tagAgg[$t][$val], 2) + if ($c -eq 0) { continue } + [void]$rows.Add([PSCustomObject]@{ TagValue = $val; Cost = $c; Currency = $currencySeen }) + } + $results[$t] = @($rows | Sort-Object Cost -Descending) + } + + return [PSCustomObject]@{ + TagsQueried = $tagsToQuery + CostByTag = $results + NoTagsFound = ($tagsToQuery.Count -eq 0) + UsedTimeframe = $usedTimeframe + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 new file mode 100644 index 000000000..a290f339c --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 @@ -0,0 +1,355 @@ +########################################################################### +# GET-COSTDATA.PS1 +# AZURE FINOPS MULTITOOL - Current & Forecasted Cost Data +########################################################################### +# Purpose: Query Cost Management API at the management-group scope to +# retrieve actual month-to-date spend and forecasted spend for +# every subscription in a single efficient call. +# +# Approach: MG-scope queries avoid N per-subscription calls. We group +# results by SubscriptionId so costs roll up correctly. +# +# Reference: https://learn.microsoft.com/en-us/rest/api/cost-management/query/usage +########################################################################### + +function Get-CostData { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [switch]$RestrictToSelected + ) + + $costMap = @{} + + # Restrict results to the subscriptions the user selected. MG-scope + # queries return every subscription under the management group, so we + # filter to the selected set to avoid showing unselected siblings. + $selectedSubs = $null + if ($Subscriptions -and $Subscriptions.Count -gt 0) { + $selectedSubs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($s in $Subscriptions) { if ($s.Id) { [void]$selectedSubs.Add([string]$s.Id) } } + } + + # Resolve the management-group scope we can actually query for cost. + # Falls back to per-subscription only if no accessible MG returns cost data. + # When the user picked a subset of subscriptions we KEEP the single fast + # MG-scope query but add a server-side SubscriptionId filter so only the + # selected subs are returned - this avoids the slow per-subscription + # fan-out (N calls per timeframe) that triggers 429 throttling. + $mgScopeId = Resolve-CostMgId -TenantId $TenantId + $subFilter = if ($RestrictToSelected) { Get-CostSubscriptionFilter -Subscriptions $Subscriptions } else { $null } + if (-not $mgScopeId) { + Write-Host " Querying actual costs (per-subscription)..." -ForegroundColor Cyan + return Get-CostDataPerSubscription -Subscriptions $Subscriptions + } + + # -- Actual Cost (Month-to-Date) ------------------------------------ + try { + Write-Host " Querying actual costs (MG scope)..." -ForegroundColor Cyan + $actualDataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'SubscriptionId' } + ) + } + if ($subFilter) { $actualDataset['filter'] = $subFilter } + $actualBody = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = $actualDataset + } | ConvertTo-Json -Depth 10 + + $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $actualBody + + if ($response.StatusCode -in @(401, 403)) { + Set-MgCostScopeFailed + throw "MG-scope cost query returned HTTP $($response.StatusCode). Falling back to per-subscription." + } + if ($response.StatusCode -ne 200) { + throw "MG-scope cost query returned HTTP $($response.StatusCode). Falling back to per-subscription." + } + + $result = ($response.Content | ConvertFrom-Json) + + if ($result.properties.rows) { + foreach ($row in $result.properties.rows) { + $subId = $row[1] + $amount = [math]::Round($row[0], 2) + $currency = $row[2] + + if ($selectedSubs -and -not $selectedSubs.Contains([string]$subId)) { continue } + + if (-not $costMap.ContainsKey($subId)) { + $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = $currency } + } + $costMap[$subId].Actual = $amount + $costMap[$subId].Currency = $currency + } + } + } + catch { + Write-Warning "Actual cost query failed: $($_.Exception.Message)" + Write-Warning "Falling back to per-subscription queries." + $costMap = Get-CostDataPerSubscription -Subscriptions $Subscriptions + return $costMap + } + + # -- Forecasted Cost (Current Billing Period) ----------------------- + # Try MG-scope first, fall back to per-subscription if it fails + $forecastSuccess = $false + try { + Write-Host " Querying forecast costs (MG scope)..." -ForegroundColor Cyan + $now = Get-Date + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + + $forecastBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.ToString('yyyy-MM-dd') + to = $monthEnd.ToString('yyyy-MM-dd') + } + dataset = $( + $fcDataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'SubscriptionId' } + ) + } + if ($subFilter) { $fcDataset['filter'] = $subFilter } + $fcDataset + ) + includeActualCost = $true + includeFreshPartialCost = $false + } | ConvertTo-Json -Depth 10 + + $forecastPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" + $fResponse = Invoke-AzRestMethodWithRetry -Path $forecastPath -Method POST -Payload $forecastBody + + if ($fResponse.StatusCode -ne 200) { + throw "Forecast query returned HTTP $($fResponse.StatusCode)" + } + + $fResult = ($fResponse.Content | ConvertFrom-Json) + + if ($fResult.properties.rows -and $fResult.properties.rows.Count -gt 0) { + # The Forecast API with includeActualCost returns rows that may have + # a CostStatus column (Actual/Forecast). Sum all rows per subscription + # to get the full-month projected cost. + # Resolve column indices by name. The forecast endpoint may order + # columns differently and adds a CostStatus column, so positional + # access can mistake "Forecast"/"Actual" text for a subscription ID. + $fCols = $fResult.properties.columns + $fCostIdx = -1; $fSubIdx = -1 + if ($fCols) { + for ($ci = 0; $ci -lt $fCols.Count; $ci++) { + $cn = ([string]$fCols[$ci].name).ToLower() + if ($cn -eq 'subscriptionid') { $fSubIdx = $ci } + elseif ($cn -match 'cost|pretaxcost') { $fCostIdx = $ci } + } + } + if ($fCostIdx -eq -1) { $fCostIdx = 0 } + if ($fSubIdx -eq -1) { $fSubIdx = 1 } + + $forecastSums = @{} + foreach ($row in $fResult.properties.rows) { + $subId = [string]$row[$fSubIdx] + if ($subId -notmatch '^[0-9a-fA-F]{8}-') { continue } + if ($selectedSubs -and -not $selectedSubs.Contains($subId)) { continue } + $amount = [double]$row[$fCostIdx] + if (-not $forecastSums.ContainsKey($subId)) { $forecastSums[$subId] = 0 } + $forecastSums[$subId] += $amount + } + foreach ($subId in $forecastSums.Keys) { + if (-not $costMap.ContainsKey($subId)) { + $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = 'USD' } + } + $costMap[$subId].Forecast = [math]::Round($forecastSums[$subId], 2) + } + $forecastSuccess = $true + Write-Host " MG-scope forecast: got data for $($forecastSums.Count) subscriptions" -ForegroundColor Green + } + else { + throw "MG-scope forecast returned 0 rows" + } + } + catch { + Write-Warning "MG-scope forecast failed: $($_.Exception.Message)" + Write-Host " Falling back to per-subscription forecast queries..." -ForegroundColor Yellow + } + + # Per-subscription forecast fallback + if (-not $forecastSuccess -and $Subscriptions) { + $now = Get-Date + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + $subCount = $Subscriptions.Count + $i = 0 + $hitCount = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i % [math]::Max(1, [int]($subCount / 10)) -eq 0) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying forecasts ($i/$subCount subs)..." + } + } + try { + $fBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.ToString('yyyy-MM-dd') + to = $monthEnd.ToString('yyyy-MM-dd') + } + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + includeActualCost = $true + includeFreshPartialCost = $false + } | ConvertTo-Json -Depth 10 + + $fResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" -Method POST -Payload $fBody + if ($fResp.StatusCode -eq 200) { + $fRes = ($fResp.Content | ConvertFrom-Json) + if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) { + $total = 0 + foreach ($row in $fRes.properties.rows) { $total += [double]$row[0] } + if (-not $costMap.ContainsKey($sub.Id)) { + $costMap[$sub.Id] = @{ Actual = 0; Forecast = 0; Currency = 'USD' } + } + $costMap[$sub.Id].Forecast = [math]::Round($total, 2) + $hitCount++ + } + } + } + catch { + # Forecast not available for this sub + } + } + Write-Host " Per-sub forecast: got data for $hitCount of $subCount subscriptions" -ForegroundColor $(if ($hitCount -gt 0) { 'Green' } else { 'Yellow' }) + } + + # Ensure any subs without forecast data default to actual + foreach ($subId in $costMap.Keys) { + if ($costMap[$subId].Forecast -eq 0 -and $costMap[$subId].Actual -gt 0) { + $costMap[$subId].Forecast = $costMap[$subId].Actual + } + } + + return $costMap +} + +# -- Fallback: Per-Subscription Cost Queries ---------------------------- +function Get-CostDataPerSubscription { + param([object[]]$Subscriptions) + + $costMap = @{} + $subCount = $Subscriptions.Count + $skipForecast = ($subCount -gt 100) # For very large tenants, skip per-sub forecast to halve API calls + if ($skipForecast) { + Write-Host " Large tenant ($subCount subs): skipping per-sub forecast to reduce API calls" -ForegroundColor Yellow + } + + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying costs ($i/$subCount subs)..." + } + } + try { + $body = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + } | ConvertTo-Json -Depth 10 + + $path = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement" + $resp = Invoke-AzRestMethodWithRetry -Path "$path/query?api-version=2023-11-01" -Method POST -Payload $body + + $actual = 0; $currency = 'USD' + if ($resp.StatusCode -eq 200) { + $res = ($resp.Content | ConvertFrom-Json) + if ($res.properties.rows -and $res.properties.rows.Count -gt 0) { + $actual = [math]::Round($res.properties.rows[0][0], 2) + $currency = $res.properties.rows[0][1] + } + } + elseif ($resp.StatusCode -in @(400, 403) -and $resp.Content) { + $errMsg = try { ($resp.Content | ConvertFrom-Json).error.message } catch { '' } + if ($errMsg -match 'AO View Charges') { + $script:costAccessIssue = 'EA' + Write-Warning " Cost data disabled for EA account owners. Enable 'AO View Charges' in the EA portal." + } + elseif ($resp.StatusCode -eq 403) { + $script:costAccessIssue = 'MCA' + Write-Warning " Cost data access denied. Verify Billing Profile Reader or Cost Management Reader role assignment." + } + } + + $costMap[$sub.Id] = @{ Actual = $actual; Forecast = $actual; Currency = $currency } + + # Per-sub forecast (skipped for large tenants) + if (-not $skipForecast) { + try { + $now = Get-Date + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + $fBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.ToString('yyyy-MM-dd') + to = $monthEnd.ToString('yyyy-MM-dd') + } + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + includeActualCost = $true + includeFreshPartialCost = $false + } | ConvertTo-Json -Depth 10 + + $fResp = Invoke-AzRestMethodWithRetry -Path "$path/forecast?api-version=2023-11-01" -Method POST -Payload $fBody + if ($fResp.StatusCode -eq 200) { + $fRes = ($fResp.Content | ConvertFrom-Json) + if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) { + $fAmount = [math]::Round($fRes.properties.rows[0][0], 2) + $costMap[$sub.Id].Forecast = $actual + $fAmount + } + } + } + catch { + # Forecast not available for all account types + } + } + } + catch { + Write-Warning " Cost query failed for $($sub.Name): $($_.Exception.Message)" + } + } + return $costMap +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 new file mode 100644 index 000000000..8b30bccb9 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 @@ -0,0 +1,304 @@ +########################################################################### +# GET-COSTTREND.PS1 +# AZURE FINOPS MULTITOOL - 6-Month Cost Trend Data +########################################################################### +# Purpose: Query Cost Management for the last 6 months of actual spend, +# returning monthly totals suitable for a bar chart display. +########################################################################### + +function Get-CostTrend { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [switch]$RestrictToSelected + ) + + Write-Host " Querying 6-month cost trend..." -ForegroundColor Cyan + + $endDate = Get-Date -Day 1 # First of current month + $startDate = $endDate.AddMonths(-6) + $fromStr = $startDate.ToString('yyyy-MM-dd') + $toStr = (Get-Date).ToString('yyyy-MM-dd') + + $body = @{ + type = 'ActualCost' + timeframe = 'Custom' + timePeriod = @{ + from = $fromStr + to = $toStr + } + dataset = @{ + granularity = 'Monthly' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + } | ConvertTo-Json -Depth 10 + + $months = [System.Collections.Generic.List[PSCustomObject]]::new() + $bySubscription = @{} # key = subId, value = sorted list of month entries + + # When the user picked a subset of subscriptions we KEEP the single fast + # MG-scope grouped call but add a server-side SubscriptionId filter so the + # trend only includes the selected subs - avoids per-subscription fan-out. + $subFilter = if ($RestrictToSelected) { Get-CostSubscriptionFilter -Subscriptions $Subscriptions } else { $null } + + # Grouped variant: one MG-scope call returns the per-subscription matrix + # (month x subscription) in a single response, avoiding an N-subscription loop. + $groupedDataset = @{ + granularity = 'Monthly' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'SubscriptionId' } + ) + } + if ($subFilter) { $groupedDataset['filter'] = $subFilter } + $groupedBody = @{ + type = 'ActualCost' + timeframe = 'Custom' + timePeriod = @{ + from = $fromStr + to = $toStr + } + dataset = $groupedDataset + } | ConvertTo-Json -Depth 10 + + # Helper: parse cost query rows into month entries + function Parse-CostRows { + param($Rows, $Columns) + $entries = [System.Collections.Generic.List[PSCustomObject]]::new() + if (-not $Rows) { return $entries } + + $costIdx = -1; $dateIdx = -1; $currIdx = -1 + if ($Columns) { + for ($ci = 0; $ci -lt $Columns.Count; $ci++) { + $n = $Columns[$ci].name.ToLower() + $t = $Columns[$ci].type.ToLower() + if ($n -match 'cost|precost|pretaxcost') { $costIdx = $ci } + elseif ($t -eq 'number' -and $costIdx -eq -1) { $costIdx = $ci } + elseif ($n -match 'billingmonth|usagedate' -or $t -eq 'datetime') { $dateIdx = $ci } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $ci } + } + } + if ($costIdx -eq -1) { $costIdx = 0 } + if ($dateIdx -eq -1) { $dateIdx = 1 } + if ($currIdx -eq -1) { $currIdx = 2 } + + foreach ($row in $Rows) { + $cost = [math]::Round([double]$row[$costIdx], 2) + $dateVal = $row[$dateIdx].ToString() + $dateClean = $dateVal -replace '[^0-9\-]', '' + if ($dateClean.Length -eq 8) { + $parsed = [datetime]::ParseExact($dateClean, 'yyyyMMdd', $null) + } else { + $parsed = [datetime]::Parse($dateVal) + } + $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' } + [void]$entries.Add([PSCustomObject]@{ + Month = $parsed.ToString('MMM yyyy') + MonthDate = $parsed + Cost = $cost + Currency = $currency + }) + } + return $entries + } + + try { + # Parse a SubscriptionId-grouped Monthly response into per-sub entries. + function Parse-GroupedCostRows { + param($Rows, $Columns) + $out = [System.Collections.Generic.List[PSCustomObject]]::new() + if (-not $Rows) { return $out } + $costIdx = -1; $dateIdx = -1; $currIdx = -1; $subIdx = -1 + if ($Columns) { + for ($ci = 0; $ci -lt $Columns.Count; $ci++) { + $n = $Columns[$ci].name.ToLower() + $t = $Columns[$ci].type.ToLower() + if ($n -match 'subscriptionid') { $subIdx = $ci } + elseif ($n -match 'cost|precost|pretaxcost') { $costIdx = $ci } + elseif ($n -match 'billingmonth|usagedate' -or $t -eq 'datetime') { $dateIdx = $ci } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $ci } + elseif ($t -eq 'number' -and $costIdx -eq -1) { $costIdx = $ci } + } + } + if ($costIdx -eq -1) { $costIdx = 0 } + if ($dateIdx -eq -1) { $dateIdx = 1 } + foreach ($row in $Rows) { + $cost = [math]::Round([double]$row[$costIdx], 2) + $dateVal = $row[$dateIdx].ToString() + $dateClean = $dateVal -replace '[^0-9\-]', '' + if ($dateClean.Length -eq 8) { + $parsed = [datetime]::ParseExact($dateClean, 'yyyyMMdd', $null) + } else { + $parsed = [datetime]::Parse($dateVal) + } + $currency = if ($currIdx -ge 0 -and $currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' } + $subId = if ($subIdx -ge 0 -and $subIdx -lt $row.Count) { [string]$row[$subIdx] } else { '' } + [void]$out.Add([PSCustomObject]@{ + SubId = $subId + Month = $parsed.ToString('MMM yyyy') + MonthDate = $parsed + Cost = $cost + Currency = $currency + }) + } + return $out + } + + # Build the aggregate month list + per-sub breakdown from grouped entries. + function Set-TrendFromGrouped { + param($Entries) + $agg = @{} + foreach ($e in $Entries) { + if ($e.SubId) { + if (-not $bySubscription.ContainsKey($e.SubId)) { + $bySubscription[$e.SubId] = [System.Collections.Generic.List[PSCustomObject]]::new() + } + [void]$bySubscription[$e.SubId].Add([PSCustomObject]@{ + Month = $e.Month; MonthDate = $e.MonthDate; Cost = $e.Cost; Currency = $e.Currency + }) + } + $key = $e.MonthDate.ToString('yyyy-MM') + if (-not $agg.ContainsKey($key)) { + $agg[$key] = @{ Cost = 0; Date = $e.MonthDate; Currency = $e.Currency } + } + $agg[$key].Cost += $e.Cost + } + foreach ($k in @($bySubscription.Keys)) { + $bySubscription[$k] = @($bySubscription[$k] | Sort-Object MonthDate) + } + foreach ($entry in $agg.GetEnumerator() | Sort-Object Key) { + [void]$months.Add([PSCustomObject]@{ + Month = $entry.Value.Date.ToString('MMM yyyy') + MonthDate = $entry.Value.Date + Cost = [math]::Round($entry.Value.Cost, 2) + Currency = $entry.Value.Currency + }) + } + } + + $subCount = if ($Subscriptions) { $Subscriptions.Count } else { 0 } + + # -- Fast path: single subscription in scope --------------------- + # No management-group resolution needed - the subscription IS the + # scope. One direct query populates both the aggregate and the + # single-sub breakdown, avoiding the throttle-prone MG probe loop. + if ($subCount -eq 1) { + $only = $Subscriptions[0] + $subPath = "/subscriptions/$($only.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body + if ($subResp.StatusCode -eq 200) { + $subResult = ($subResp.Content | ConvertFrom-Json) + if ($subResult.properties.rows) { + $months = Parse-CostRows -Rows $subResult.properties.rows -Columns $subResult.properties.columns + $bySubscription[$only.Id] = @($months | Sort-Object MonthDate) + } + } else { + Write-Warning " Single-sub cost trend returned HTTP $($subResp.StatusCode)" + } + } + else { + # -- Multi-sub path: one grouped MG-scope query --------------- + # group by SubscriptionId so a single call returns the month x + # subscription matrix (aggregate + per-sub) in one response. + # When the user picked a subset of subscriptions, skip MG scope + # (whole management group) and use the per-subscription loop so the + # trend only reflects the selected subscriptions. + $mgScopeId = Resolve-CostMgId -TenantId $TenantId + $useMgScope = [bool]$mgScopeId + $groupedOk = $false + + if ($useMgScope) { + $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $groupedBody + if ($response.StatusCode -eq 200) { + $result = ($response.Content | ConvertFrom-Json) + if ($result.properties.rows) { + $entries = Parse-GroupedCostRows -Rows $result.properties.rows -Columns $result.properties.columns + Set-TrendFromGrouped -Entries $entries + $groupedOk = ($months.Count -gt 0) + } + } else { + if ($response.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed } + Write-Warning " MG-scope grouped cost trend returned HTTP $($response.StatusCode) - falling back to per-sub" + $useMgScope = $false + } + } + + # -- Fallback: per-subscription loop (MG scope unavailable) --- + if (-not $groupedOk -and $Subscriptions) { + $sampleErrors = 0 + $sampleSize = [math]::Min(3, $subCount) + $aggTotals = @{} # used for aggregate if MG scope failed + + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying cost trend ($i/$subCount subs)..." + } + } + + $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body + + if ($subResp.StatusCode -eq 200) { + $subResult = ($subResp.Content | ConvertFrom-Json) + if ($subResult.properties.rows) { + $subMonths = Parse-CostRows -Rows $subResult.properties.rows -Columns $subResult.properties.columns + $bySubscription[$sub.Id] = @($subMonths | Sort-Object MonthDate) + + foreach ($sm in $subMonths) { + $key = $sm.MonthDate.ToString('yyyy-MM') + if (-not $aggTotals.ContainsKey($key)) { + $aggTotals[$key] = @{ Cost = 0; Date = $sm.MonthDate; Currency = $sm.Currency } + } + $aggTotals[$key].Cost += $sm.Cost + } + } + } else { + if ($i -le $sampleSize) { $sampleErrors++ } + } + + if ($i -eq $sampleSize -and $sampleErrors -eq $sampleSize -and $subCount -gt $sampleSize) { + Write-Host " All $sampleSize sample subs returned errors - skipping remaining $($subCount - $sampleSize) subs" -ForegroundColor Yellow + break + } + } + + if ($months.Count -eq 0 -and $aggTotals.Count -gt 0) { + foreach ($entry in $aggTotals.GetEnumerator() | Sort-Object Key) { + [void]$months.Add([PSCustomObject]@{ + Month = $entry.Value.Date.ToString('MMM yyyy') + MonthDate = $entry.Value.Date + Cost = [math]::Round($entry.Value.Cost, 2) + Currency = $entry.Value.Currency + }) + } + } + } + } + } catch { + Write-Warning "Cost trend query failed: $($_.Exception.Message)" + } + + # Sort by date + $sorted = @($months | Sort-Object MonthDate) + + return [PSCustomObject]@{ + Months = $sorted + BySubscription = $bySubscription + HasData = ($sorted.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 new file mode 100644 index 000000000..a34c6e03b --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 @@ -0,0 +1,161 @@ +########################################################################### +# GET-IDLEVMS.PS1 +# AZURE FINOPS MULTITOOL - Idle & Underutilized VM Detection +########################################################################### +# Purpose: Query Azure Monitor metrics to find running VMs with very low +# CPU and network utilization that Advisor hasn't flagged yet. +# These are candidates for downsizing or shutting down. +########################################################################### + +function Get-IdleVMs { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + Write-Host " Scanning for idle and underutilized VMs..." -ForegroundColor Cyan + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $results = [System.Collections.Generic.List[PSCustomObject]]::new() + + # -- 1: Find all running VMs ------------------------------------------ + # Pull every VM with its power state so we can distinguish "no VMs at all" + # from "VMs exist but are all deallocated" (idle detection only applies to + # RUNNING VMs - a deallocated VM has no CPU to sample). + $totalVMs = 0 + $deallocatedCount = 0 + try { + $query = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| extend powerState = tostring(properties.extended.instanceView.powerState.code) +| project name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + osType = properties.storageProfile.osDisk.osType, + powerState +"@ + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 + $allVMs = if ($result) { @($result.Data) } else { @() } + $totalVMs = $allVMs.Count + $runningVMs = @($allVMs | Where-Object { $_.powerState -eq 'PowerState/running' }) + $deallocatedCount = $totalVMs - $runningVMs.Count + Write-Host " VMs found: $totalVMs ($($runningVMs.Count) running, $deallocatedCount stopped/deallocated)" -ForegroundColor Gray + } catch { + Write-Warning " Running VM query failed: $($_.Exception.Message)" + $runningVMs = @() + } + + if ($runningVMs.Count -eq 0) { + $note = if ($totalVMs -gt 0) { + "$totalVMs VM(s) found but none are running ($deallocatedCount stopped/deallocated), so there is no CPU to sample for idle detection. Stopped/deallocated VMs still incur disk and IP cost - see scan_orphaned_resources." + } + else { + 'No virtual machines found in scope.' + } + return [PSCustomObject]@{ + IdleVMs = @() + Count = 0 + HasData = $false + ScannedVMs = 0 + TotalVMs = $totalVMs + DeallocatedVMs = $deallocatedCount + Note = $note + } + } + + # -- 2: Query 14-day avg CPU + Network for each VM ------------------- + $token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com').Token + $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } + $now = (Get-Date).ToUniversalTime() + $fourteenDaysAgo = $now.AddDays(-14).ToString('yyyy-MM-ddTHH:mm:ssZ') + $nowStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ') + + $cpuThreshold = 5 # avg CPU < 5% = idle + $networkThreshold = 1048576 # < 1 MB/day total network = idle (14d * 1MB = 14MB) + $networkThreshold14d = $networkThreshold * 14 + + $vmCount = $runningVMs.Count + $vmIdx = 0 + foreach ($vm in $runningVMs) { + $vmIdx++ + if ($vmCount -gt 10 -and ($vmIdx -eq 1 -or $vmIdx % [math]::Max(1, [int]($vmCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Checking VM metrics ($vmIdx/$vmCount VMs)..." + } + } + $scope = "/subscriptions/$($vm.subscriptionId)/resourceGroups/$($vm.resourceGroup)/providers/Microsoft.Compute/virtualMachines/$($vm.name)" + try { + # Query CPU + Network In + Network Out in a single call + $metricUri = "https://management.azure.com$scope/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Percentage CPU,Network In Total,Network Out Total×pan=$fourteenDaysAgo/$nowStr&aggregation=Average,Total&interval=P14D" + $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop + $metricData = ($resp.Content | ConvertFrom-Json) + + $avgCpu = $null + $totalNetIn = 0 + $totalNetOut = 0 + + foreach ($metric in $metricData.value) { + $metricName = $metric.name.value + foreach ($ts in $metric.timeseries) { + foreach ($dp in $ts.data) { + switch ($metricName) { + 'Percentage CPU' { + if ($dp.average -ne $null) { $avgCpu = $dp.average } + } + 'Network In Total' { + if ($dp.total) { $totalNetIn += $dp.total } + } + 'Network Out Total' { + if ($dp.total) { $totalNetOut += $dp.total } + } + } + } + } + } + + $totalNetwork = $totalNetIn + $totalNetOut + + # Classify: idle if CPU < threshold AND network < threshold + $isIdle = $false + $classification = $null + + if ($avgCpu -ne $null -and $avgCpu -lt $cpuThreshold -and $totalNetwork -lt $networkThreshold14d) { + $isIdle = $true + $classification = 'Idle' + } elseif ($avgCpu -ne $null -and $avgCpu -lt 10 -and $totalNetwork -lt ($networkThreshold14d * 10)) { + $isIdle = $true + $classification = 'Underutilized' + } + + if ($isIdle) { + $dailyNetMB = [math]::Round($totalNetwork / 14 / 1MB, 2) + [void]$results.Add([PSCustomObject]@{ + VMName = $vm.name + ResourceGroup = $vm.resourceGroup + SubscriptionId = $vm.subscriptionId + Location = $vm.location + VMSize = $vm.vmSize + OS = $vm.osType + AvgCPU14d = [math]::Round($avgCpu, 1) + NetworkPerDay = "$($dailyNetMB) MB" + Classification = $classification + Recommendation = if ($classification -eq 'Idle') { 'Deallocate or delete' } else { 'Downsize VM' } + }) + } + } catch { + # Metrics not available — skip this VM + } + } + + Write-Host " Idle/underutilized VMs: $($results.Count)" -ForegroundColor Gray + + [PSCustomObject]@{ + IdleVMs = @($results) + Count = $results.Count + HasData = ($results.Count -gt 0) + ScannedVMs = $runningVMs.Count + TotalVMs = $totalVMs + DeallocatedVMs = $deallocatedCount + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 new file mode 100644 index 000000000..7855ad3e6 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 @@ -0,0 +1,182 @@ +########################################################################### +# GET-LEGACYRESOURCES.PS1 +# AZURE FINOPS MULTITOOL - Legacy & Retiring Resource Detection +########################################################################### +# Purpose: Use Azure Resource Graph to surface resources running on legacy +# or retiring SKUs that should be modernized: first-generation VM +# families (A/D/F/G v1), HDD (Standard_LRS) managed disks, +# unmanaged (VHD) disks, and Basic-SKU public IPs / load balancers +# (retiring Sept 2025). Supports the FinOps modernization KPI. +########################################################################### +# Notes: +# - RBAC: Reader on each subscription (Azure Resource Graph). +# - "Legacy" VM families are v1 sizes with no version suffix (e.g. +# Standard_D2 vs Standard_D2_v3) plus Basic_A / Standard_A0-A7 and the +# G/GS series. These typically have a modern, cheaper successor. +########################################################################### + +function Get-LegacyResources { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + Write-Host " Scanning for legacy and retiring resources..." -ForegroundColor Cyan + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $allLegacy = [System.Collections.Generic.List[PSCustomObject]]::new() + + # -- 1: Legacy (v1) VM families --------------------------------------- + try { + $vmQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| extend vmSize = tostring(properties.hardwareProfile.vmSize) +| where vmSize matches regex @'(?i)^(Basic_A[0-9]+|Standard_A[0-7]|Standard_D[0-9]+|Standard_DS[0-9]+|Standard_F[0-9]+|Standard_G[0-9]+|Standard_GS[0-9]+)$' +| project name, resourceGroup, subscriptionId, location, vmSize +"@ + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allLegacy.Add([PSCustomObject]@{ + Category = 'Legacy VM Family' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.vmSize) (v1 - upgrade to current generation)" + Impact = 'High' + }) + } + Write-Host " Legacy VM families: $($rows.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " Legacy VM query failed: $($_.Exception.Message)" + } + + # -- 2: Unmanaged (VHD) disks ----------------------------------------- + try { + $vhdQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where isnotempty(properties.storageProfile.osDisk.vhd.uri) +| project name, resourceGroup, subscriptionId, location, + vhd = tostring(properties.storageProfile.osDisk.vhd.uri) +"@ + $result = Search-AzGraphSafe -Query $vhdQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allLegacy.Add([PSCustomObject]@{ + Category = 'Unmanaged Disk' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = 'VM uses unmanaged (VHD) OS disk - migrate to managed disks' + Impact = 'High' + }) + } + Write-Host " Unmanaged-disk VMs: $($rows.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " Unmanaged disk query failed: $($_.Exception.Message)" + } + + # -- 3: HDD (Standard_LRS) managed disks ------------------------------ + try { + $hddQuery = @" +resources +| where type =~ 'microsoft.compute/disks' +| where tostring(sku.name) =~ 'Standard_LRS' +| where toint(properties.diskSizeGB) >= 128 +| project name, resourceGroup, subscriptionId, location, + diskSizeGb = properties.diskSizeGB, sku = sku.name +"@ + $result = Search-AzGraphSafe -Query $hddQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allLegacy.Add([PSCustomObject]@{ + Category = 'HDD Managed Disk' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.diskSizeGb) GB HDD (Standard_LRS) - consider Standard/Premium SSD" + Impact = 'Low' + }) + } + Write-Host " HDD managed disks: $($rows.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " HDD disk query failed: $($_.Exception.Message)" + } + + # -- 4: Basic-SKU public IPs (retiring Sept 2025) --------------------- + try { + $pipQuery = @" +resources +| where type =~ 'microsoft.network/publicipaddresses' +| where tostring(sku.name) =~ 'Basic' +| project name, resourceGroup, subscriptionId, location, sku = sku.name +"@ + $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allLegacy.Add([PSCustomObject]@{ + Category = 'Basic Public IP' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = 'Basic-SKU public IP (retires Sep 2025) - migrate to Standard' + Impact = 'High' + }) + } + Write-Host " Basic public IPs: $($rows.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " Basic public IP query failed: $($_.Exception.Message)" + } + + # -- 5: Basic-SKU load balancers (retiring Sept 2025) ----------------- + try { + $lbQuery = @" +resources +| where type =~ 'microsoft.network/loadbalancers' +| where tostring(sku.name) =~ 'Basic' +| project name, resourceGroup, subscriptionId, location, sku = sku.name +"@ + $result = Search-AzGraphSafe -Query $lbQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allLegacy.Add([PSCustomObject]@{ + Category = 'Basic Load Balancer' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = 'Basic-SKU load balancer (retires Sep 2025) - migrate to Standard' + Impact = 'High' + }) + } + Write-Host " Basic load balancers: $($rows.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " Basic load balancer query failed: $($_.Exception.Message)" + } + + $byCategory = @( + $allLegacy | Group-Object Category | ForEach-Object { + [PSCustomObject]@{ Category = $_.Name; Count = $_.Count } + } | Sort-Object Count -Descending + ) + + return [PSCustomObject]@{ + HasData = ($allLegacy.Count -gt 0) + TotalCount = $allLegacy.Count + LegacyResources = @($allLegacy | Sort-Object @{ Expression = 'Impact'; Descending = $true }, Category) + ByCategory = $byCategory + ScannedSubs = $Subscriptions.Count + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 new file mode 100644 index 000000000..d42ef081d --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 @@ -0,0 +1,153 @@ +########################################################################### +# GET-MACCCOMMITMENT.PS1 +# AZURE FINOPS MULTITOOL - MACC Consumption Commitment Tracking +########################################################################### +# Purpose: Read the customer's Microsoft Azure Consumption Commitment (MACC) +# and return a minimal readout: commitment, consumed, remaining, +# % burned, and status for the current agreement period. +# +# Description: +# Queries the Consumption Lots API at billing-account scope and filters for +# lots whose source is 'ConsumptionCommitment' (the MACC): +# 1. Discover reachable billing accounts and their agreement type +# 2. For EA / MCA accounts, list consumption lots +# 3. Keep MACC lots and compute commitment / consumed / remaining / % / status +# +# ── Parameters ────────────────────────────────────────────────── +# Subscriptions Subscriptions in scope (used only to bound discovery) +# AgreementType Detected agreement type from Get-ContractInfo +# +# Notes: +# - The lots API is supported for Microsoft Customer Agreement (MCA) and +# Direct Enterprise Agreement (EA) only. PAYGO / CSP / MSDN return nothing. +# - Requires a billing role (Billing Account Reader / EA Reader) on the +# billing account. Standard subscription RBAC does not grant access. +# +# Reference: https://learn.microsoft.com/en-us/rest/api/consumption/lots/list-by-billing-account +########################################################################### + +function Get-MaccCommitment { + [CmdletBinding()] + param( + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [string]$AgreementType + ) + + Write-Host " Querying MACC consumption commitment..." -ForegroundColor Cyan + + # -- Result shape (single source of truth) -------------------------- + $result = [PSCustomObject]@{ + HasMacc = $false + Applicable = $true + Reason = '' + Commitments = @() + } + + # -- Step 0: Gate on agreement type --------------------------------- + # The lots API only returns data for MCA and Direct EA billing accounts. + if ($AgreementType -and $AgreementType -notin @('EnterpriseAgreement', 'MicrosoftCustomerAgreement')) { + $result.Applicable = $false + $result.Reason = "MACC tracking applies to Enterprise Agreement (EA) and Microsoft Customer Agreement (MCA) only. Detected agreement type: $AgreementType." + Write-Host " MACC not applicable for agreement type '$AgreementType'." -ForegroundColor DarkGray + return $result + } + + # -- Step 1: Discover reachable billing accounts -------------------- + $billingAccounts = @() + try { + $resp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" -Method GET + if ($resp.StatusCode -eq 200) { + $parsed = ($resp.Content | ConvertFrom-Json) + foreach ($a in $parsed.value) { + if ($a.properties.agreementType -in @('EnterpriseAgreement', 'MicrosoftCustomerAgreement')) { + $billingAccounts += [PSCustomObject]@{ + Name = $a.name + DisplayName = $a.properties.displayName + Agreement = $a.properties.agreementType + } + } + } + } + } catch { + $result.Reason = "Could not list billing accounts: $($_.Exception.Message)" + Write-Warning " MACC: $($result.Reason)" + return $result + } + + if ($billingAccounts.Count -eq 0) { + $result.Reason = 'No EA/MCA billing account is reachable. Assign a billing role (Billing Account Reader or EA Reader) to view MACC data.' + Write-Host " MACC: no reachable EA/MCA billing account." -ForegroundColor DarkGray + return $result + } + + # -- Step 2: List MACC lots per billing account --------------------- + $lots = [System.Collections.Generic.List[PSCustomObject]]::new() + $lotAccessDenied = $false # set if the lots call is forbidden (403) for any account + foreach ($ba in $billingAccounts) { + # Server-side filter to ConsumptionCommitment (the MACC) lots only. This + # mirrors the proven Cost Management data factory sample + # (MSBrett/ccm_datafactory), which reads the same lots endpoint. + $lotPath = "/providers/Microsoft.Billing/billingAccounts/$($ba.Name)/providers/Microsoft.Consumption/lots?api-version=2023-05-01&`$filter=source%20eq%20'ConsumptionCommitment'" + try { + $lotResp = Invoke-AzRestMethodWithRetry -Path $lotPath -Method GET + if ($lotResp.StatusCode -ne 200) { + # 403/401 = you can see the billing account but lack the billing + # role to read its consumption lots (the MACC). 404 = no lots + # exist for this account, which is genuinely "no MACC." + if ($lotResp.StatusCode -in @(401, 403)) { $lotAccessDenied = $true } + continue + } + $lotData = ($lotResp.Content | ConvertFrom-Json) + foreach ($lot in $lotData.value) { + $p = $lot.properties + if ("$($p.source)" -ne 'ConsumptionCommitment') { continue } + + $currency = if ($p.billingCurrency) { $p.billingCurrency } elseif ($p.originalAmount.currency) { $p.originalAmount.currency } else { 'USD' } + $original = if ($null -ne $p.originalAmount.value) { [double]$p.originalAmount.value } else { 0 } + + # The Lots API does not return a "used" amount. closedBalance is + # the amount REMAINING on the commitment, so consumed is simply + # originalAmount - closedBalance. This is the same calculation + # used by the Cost Management data factory sample + # (MSBrett/ccm_datafactory). + $remaining = if ($null -ne $p.closedBalance.value) { [double]$p.closedBalance.value } else { 0 } + $used = if ($original -gt 0) { [math]::Round($original - $remaining, 2) } else { 0 } + $pctUsed = if ($original -gt 0) { [math]::Round(($used / $original) * 100, 1) } else { 0 } + + $lots.Add([PSCustomObject]@{ + BillingAccount = $ba.DisplayName + Agreement = $ba.Agreement + Currency = $currency + Commitment = [math]::Round($original, 2) + Consumed = $used + Remaining = [math]::Round($remaining, 2) + PctUsed = $pctUsed + Status = if ($p.status) { $p.status } else { 'Unknown' } + StartDate = if ($p.startDate) { ([datetime]$p.startDate).ToString('yyyy-MM-dd') } else { '' } + ExpirationDate = if ($p.expirationDate) { ([datetime]$p.expirationDate).ToString('yyyy-MM-dd') } else { '' } + Estimated = [bool]$p.isEstimatedBalance + }) + } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $lotAccessDenied = $true } + Write-Warning " MACC lot query failed for account $($ba.DisplayName): $($_.Exception.Message)" + } + } + + if ($lots.Count -gt 0) { + $result.HasMacc = $true + $result.Commitments = @($lots) + Write-Host " Found $($lots.Count) MACC commitment lot(s)." -ForegroundColor Green + } elseif ($lotAccessDenied) { + $result.Reason = 'MACC data could not be read due to insufficient billing permissions. You can see the EA/MCA billing account, but reading its consumption commitment (lots) requires a billing role: for EA, Enterprise Administrator (read-only) or EA Reader at the enrollment scope; for MCA, Billing account reader or Billing profile reader. Standard subscription RBAC (Owner/Contributor/Reader) does not grant access. Ask a billing admin to assign one of these roles, then re-scan. Reference: https://learn.microsoft.com/azure/cost-management-billing/manage/understand-mca-roles' + Write-Host " MACC: billing access denied reading consumption lots." -ForegroundColor Yellow + } else { + $result.Reason = 'No MACC commitment found on the reachable EA/MCA billing account(s). This agreement may not include a consumption commitment.' + Write-Host " MACC: no consumption-commitment lots found." -ForegroundColor DarkGray + } + + return $result +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 new file mode 100644 index 000000000..21865787b --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 @@ -0,0 +1,173 @@ +########################################################################### +# GET-OPTIMIZATIONADVICE.PS1 +# AZURE FINOPS MULTITOOL - Azure Advisor Cost Optimization +########################################################################### +# Purpose: Pull all cost optimization recommendations from Azure Advisor +# across every subscription. Categorize by type: rightsize, +# shutdown, delete, modernize. +# +# Reference: https://learn.microsoft.com/en-us/azure/advisor/advisor-cost-recommendations +########################################################################### + +function Get-OptimizationAdvice { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $allRecs = [System.Collections.Generic.List[PSCustomObject]]::new() + + # Build subscription ID list and name lookup + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + $subNameMap = @{} + foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id] = $sub.Name } + + # Query all Advisor cost recommendations via Resource Graph (single call) + $query = @" +advisorresources +| where type == 'microsoft.advisor/recommendations' +| where properties.category == 'Cost' +| project subscriptionId, + shortDescriptionProblem = tostring(properties.shortDescription.problem), + shortDescriptionSolution = tostring(properties.shortDescription.solution), + impact = tostring(properties.impact), + impactedField = tostring(properties.impactedField), + impactedValue = tostring(properties.impactedValue), + annualSavings = tostring(properties.extendedProperties.annualSavingsAmount), + savingsAmount = tostring(properties.extendedProperties.savingsAmount), + savingsCurrency = tostring(properties.extendedProperties.savingsCurrency) +"@ + + try { + Write-Host " Querying Advisor cost recommendations via Resource Graph..." -ForegroundColor Cyan + $allRows = [System.Collections.Generic.List[object]]::new() + $skipToken = $null + + do { + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -SkipToken $skipToken + if ($result -and $result.Data) { foreach ($r in $result.Data) { [void]$allRows.Add($r) } } + $skipToken = if ($result) { $result.SkipToken } else { $null } + } while ($skipToken) + + Write-Host " Retrieved $($allRows.Count) Advisor cost recommendations." -ForegroundColor Cyan + + foreach ($row in $allRows) { + $problem = $row.shortDescriptionProblem + $solution = $row.shortDescriptionSolution + + # Skip reservation/savings plan recs (handled by Get-ReservationAdvice) + if ($problem -match 'reserv|savings plan') { continue } + + # Categorize the recommendation + $catText = "$problem $solution" + $category = switch -Regex ($catText) { + 'right.?siz|resize|downsize|scale down' { 'Rightsize' } + 'shut.?down|deallocate|idle|stopped' { 'Shutdown / Deallocate' } + 'delet|unused|orphan|unattached' { 'Delete Unused' } + 'modern|upgrade|migrate|move to' { 'Modernize' } + 'burstable|B-series' { 'Rightsize' } + default { 'Other' } + } + + $savings = $null + if ($row.annualSavings) { + $savings = [math]::Round([double]$row.annualSavings, 2) + } + elseif ($row.savingsAmount) { + $savings = [math]::Round([double]$row.savingsAmount, 2) + } + + $subId = $row.subscriptionId + [void]$allRecs.Add([PSCustomObject]@{ + Subscription = if ($subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { $subId } + SubscriptionId = $subId + Category = $category + Impact = $row.impact + Problem = $problem + Solution = $solution + ResourceType = $row.impactedField + ResourceName = $row.impactedValue + AnnualSavings = $savings + Currency = $row.savingsCurrency + }) + } + } catch { + Write-Warning " Advisor Resource Graph query failed: $($_.Exception.Message)" + Write-Warning " Falling back to per-subscription REST calls..." + + # Fallback: per-subscription REST API (slow but reliable) + foreach ($sub in $Subscriptions) { + try { + $advPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Advisor/recommendations?api-version=2023-01-01&`$filter=Category eq 'Cost'" + $advResp = Invoke-AzRestMethodWithRetry -Path $advPath -Method GET + if ($advResp.StatusCode -ne 200) { continue } + $advResult = ($advResp.Content | ConvertFrom-Json) + + foreach ($item in $advResult.value) { + $rec = $item.properties + if ($rec.shortDescription.problem -match 'reserv|savings plan') { continue } + + $catText = "$($rec.shortDescription.problem) $($rec.shortDescription.solution)" + $category = switch -Regex ($catText) { + 'right.?siz|resize|downsize|scale down' { 'Rightsize' } + 'shut.?down|deallocate|idle|stopped' { 'Shutdown / Deallocate' } + 'delet|unused|orphan|unattached' { 'Delete Unused' } + 'modern|upgrade|migrate|move to' { 'Modernize' } + 'burstable|B-series' { 'Rightsize' } + default { 'Other' } + } + + $savings = $null + if ($rec.extendedProperties.annualSavingsAmount) { + $savings = [math]::Round([double]$rec.extendedProperties.annualSavingsAmount, 2) + } elseif ($rec.extendedProperties.savingsAmount) { + $savings = [math]::Round([double]$rec.extendedProperties.savingsAmount, 2) + } + + [void]$allRecs.Add([PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + Category = $category + Impact = $rec.impact + Problem = $rec.shortDescription.problem + Solution = $rec.shortDescription.solution + ResourceType = $rec.impactedField + ResourceName = $rec.impactedValue + AnnualSavings = $savings + Currency = $rec.extendedProperties.savingsCurrency + }) + } + } catch { + Write-Warning " Advisor query failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Summarize by category ------------------------------------------ + $byCat = $allRecs | Group-Object Category | ForEach-Object { + [PSCustomObject]@{ + Category = $_.Name + Count = $_.Count + TotalSavings = [math]::Round(($_.Group | Where-Object { $_.AnnualSavings } | + Measure-Object -Property AnnualSavings -Sum).Sum, 2) + } + } + + $totalSavings = ($allRecs | Where-Object { $_.AnnualSavings } | + Measure-Object -Property AnnualSavings -Sum).Sum + + # -- Summarize by impact -------------------------------------------- + $byImpact = $allRecs | Group-Object Impact | ForEach-Object { + [PSCustomObject]@{ Impact = $_.Name; Count = $_.Count } + } + + return [PSCustomObject]@{ + Recommendations = $allRecs + ByCategory = $byCat + ByImpact = $byImpact + TotalCount = $allRecs.Count + EstimatedAnnualSavings = [math]::Round($totalSavings, 2) + Summary = "$($allRecs.Count) optimization recommendations (est. `$$([math]::Round($totalSavings, 2))/yr savings)" + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 new file mode 100644 index 000000000..761034ee3 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -0,0 +1,216 @@ +########################################################################### +# GET-ORPHANEDRESOURCES.PS1 +# AZURE FINOPS MULTITOOL - Orphaned & Idle Resource Detection +########################################################################### +# Purpose: Use Azure Resource Graph to find resources that are costing +# money but serving no purpose: orphaned disks, unattached IPs, +# empty App Service Plans, unattached NICs, and stopped VMs +# that are still incurring compute charges. +########################################################################### + +function Get-OrphanedResources { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + Write-Host " Scanning for orphaned and idle resources..." -ForegroundColor Cyan + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $allOrphans = [System.Collections.Generic.List[PSCustomObject]]::new() + + # -- 1: Orphaned Managed Disks (no ownerVM) -------------------------- + try { + $diskQuery = @" +resources +| where type =~ 'microsoft.compute/disks' +| where managedBy == '' or isnull(managedBy) +| where properties.diskState == 'Unattached' +| project name, resourceGroup, subscriptionId, location, + diskSizeGb = properties.diskSizeGB, + sku = sku.name, diskState = properties.diskState, + type = 'Orphaned Disk' +"@ + $result = Search-AzGraphSafe -Query $diskQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Orphaned Disk' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.diskSizeGb) GB ($($r.sku))" + Impact = 'Medium' + }) + } + Write-Host " Orphaned disks: $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Orphaned disk query failed: $($_.Exception.Message)" + } + + # -- 2: Unattached Public IPs ----------------------------------------- + try { + $pipQuery = @" +resources +| where type =~ 'microsoft.network/publicipaddresses' +| where properties.ipConfiguration == '' or isnull(properties.ipConfiguration) +| where properties.natGateway == '' or isnull(properties.natGateway) +| project name, resourceGroup, subscriptionId, location, + sku = sku.name, ipAddress = properties.ipAddress, + allocationMethod = properties.publicIPAllocationMethod, + type = 'Unattached Public IP' +"@ + $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Unattached Public IP' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.sku) - $($r.allocationMethod)" + Impact = if ($r.sku -eq 'Standard') { 'Medium' } else { 'Low' } + }) + } + Write-Host " Unattached public IPs: $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Unattached public IP query failed: $($_.Exception.Message)" + } + + # -- 3: Unattached NICs ----------------------------------------------- + try { + $nicQuery = @" +resources +| where type =~ 'microsoft.network/networkinterfaces' +| where isnull(properties.virtualMachine) or properties.virtualMachine == '' +| where isnull(properties.privateEndpoint) or properties.privateEndpoint == '' +| project name, resourceGroup, subscriptionId, location, + enableAcceleratedNetworking = properties.enableAcceleratedNetworking, + type = 'Unattached NIC' +"@ + $result = Search-AzGraphSafe -Query $nicQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Unattached NIC' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "Accelerated: $($r.enableAcceleratedNetworking)" + Impact = 'Low' + }) + } + Write-Host " Unattached NICs: $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Unattached NIC query failed: $($_.Exception.Message)" + } + + # -- 4: Stopped (deallocated) VMs still on disk ----------------------- + try { + $vmQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where properties.extended.instanceView.powerState.displayStatus == 'VM deallocated' + or properties.extended.instanceView.powerState.code == 'PowerState/deallocated' +| project name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + powerState = properties.extended.instanceView.powerState.displayStatus, + type = 'Deallocated VM' +"@ + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Deallocated VM' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.vmSize) - still incurs disk/IP costs" + Impact = 'Medium' + }) + } + Write-Host " Deallocated VMs: $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Deallocated VM query failed: $($_.Exception.Message)" + } + + # -- 5: Empty App Service Plans (0 apps) ------------------------------ + try { + $aspQuery = @" +resources +| where type =~ 'microsoft.web/serverfarms' +| where properties.numberOfSites == 0 +| where sku.tier != 'Free' and sku.tier != 'Shared' +| project name, resourceGroup, subscriptionId, location, + sku = strcat(sku.tier, ' / ', sku.name), + workers = properties.numberOfWorkers, + type = 'Empty App Service Plan' +"@ + $result = Search-AzGraphSafe -Query $aspQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Empty App Service Plan' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.sku), $($r.workers) worker(s), 0 apps" + Impact = 'High' + }) + } + Write-Host " Empty App Service Plans: $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Empty ASP query failed: $($_.Exception.Message)" + } + + # -- 6: Orphaned Snapshots (older than 30 days) ----------------------- + try { + $snapshotCutoff = (Get-Date).AddDays(-30).ToString('yyyy-MM-dd') + $snapQuery = @" +resources +| where type =~ 'microsoft.compute/snapshots' +| where properties.timeCreated < datetime('$snapshotCutoff') +| project name, resourceGroup, subscriptionId, location, + diskSizeGb = properties.diskSizeGB, + timeCreated = properties.timeCreated, + type = 'Old Snapshot' +"@ + $result = Search-AzGraphSafe -Query $snapQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Old Snapshot (30d+)' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.diskSizeGb) GB, created $($r.timeCreated)" + Impact = 'Low' + }) + } + Write-Host " Old snapshots (30d+): $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Snapshot query failed: $($_.Exception.Message)" + } + + # -- Summary by category -- + $summary = $allOrphans | Group-Object Category | ForEach-Object { + [PSCustomObject]@{ + Category = $_.Name + Count = $_.Count + } + } + + return [PSCustomObject]@{ + Orphans = @($allOrphans) + Summary = @($summary) + TotalCount = $allOrphans.Count + HasData = ($allOrphans.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 new file mode 100644 index 000000000..05c0d05a7 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -0,0 +1,285 @@ +########################################################################### +# GET-POLICYINVENTORY.PS1 +# AZURE FINOPS MULTITOOL - Policy Inventory Across the Tenant +########################################################################### +# Purpose: Scan all policy assignments across the tenant's subscriptions +# and return a summary of assigned policies, their effects, +# scopes, and compliance state. +# +# Strategy: Resource Graph for assignments (1 paginated call) + +# MG-scope Policy Insights for compliance (1 call). +# Falls back to per-sub only for small tenants if above fail. +########################################################################### + +function Get-PolicyInventory { + [CmdletBinding()] + param( + [Parameter()] + [string]$TenantId, + + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $subCount = $Subscriptions.Count + Write-Host " Scanning policy assignments across $subCount subscriptions..." -ForegroundColor Cyan + + $allAssignments = [System.Collections.Generic.List[PSCustomObject]]::new() + $complianceMap = @{} + $gotAssignments = $false + $gotCompliance = $false + + # -- Strategy 1: ARM REST API for ALL effective assignments ---------- + # Resource Graph policyresources at subscription scope only returns + # assignments AT that scope. The ARM Policy API returns ALL effective + # assignments including those inherited from management groups and + # the tenant root group. + try { + Write-Host " Querying policy assignments via ARM REST API..." -ForegroundColor Cyan + $seenIds = @{} + foreach ($sub in $Subscriptions) { + $subName = $sub.Name + $nextLink = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01" + while ($nextLink) { + $resp = Invoke-AzRestMethodWithRetry -Path $nextLink -Method GET + if ($resp.StatusCode -ne 200) { break } + $body = $resp.Content | ConvertFrom-Json + foreach ($a in $body.value) { + # De-duplicate (same MG assignment appears under each sub) + if ($seenIds.ContainsKey($a.id)) { continue } + $seenIds[$a.id] = $true + + $props = $a.properties + $defId = $props.policyDefinitionId + $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' } + elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } + else { 'Custom' } + $scope = if ($a.id -match '^(.*)/providers/Microsoft\.Authorization/policyAssignments/') { + $Matches[1] + } else { '' } + + [void]$allAssignments.Add([PSCustomObject]@{ + AssignmentName = if ($props.displayName) { $props.displayName } else { $a.name } + AssignmentId = $a.id + PolicyDefId = $defId + Scope = $scope + Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' } + EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' } + Origin = $origin + Subscription = $subName + Description = if ($props.description) { $props.description } else { '' } + }) + } + # Handle pagination via nextLink + $nextLink = if ($body.nextLink) { + $body.nextLink -replace '^https://management\.azure\.com', '' + } else { $null } + } + } + + if ($allAssignments.Count -gt 0) { + $gotAssignments = $true + Write-Host " ARM REST API: $($allAssignments.Count) unique policy assignments (including inherited)" -ForegroundColor Green + } + } catch { + Write-Warning " ARM REST policy query failed: $($_.Exception.Message)" + } + + # Fallback: Resource Graph if ARM REST didn't find any + if (-not $gotAssignments) { + try { + Write-Host " Falling back to Resource Graph for policy assignments..." -ForegroundColor Yellow + $argQuery = @" +policyresources +| where type =~ 'microsoft.authorization/policyassignments' +| project id, name, properties, subscriptionId, type +"@ + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $skipToken = $null + $pageNum = 0 + do { + $pageNum++ + $result = Search-AzGraphSafe -Query $argQuery -Subscription $subIds -First 1000 -SkipToken $skipToken + if ($result -and $result.Data) { + foreach ($r in $result.Data) { + $props = $r.properties + $defId = $props.policyDefinitionId + $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' } + elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } + else { 'Custom' } + $subName = $r.subscriptionId + $matchSub = $Subscriptions | Where-Object { $_.Id -eq $r.subscriptionId } | Select-Object -First 1 + if ($matchSub) { $subName = $matchSub.Name } + [void]$allAssignments.Add([PSCustomObject]@{ + AssignmentName = if ($props.displayName) { $props.displayName } else { $r.name } + AssignmentId = $r.id + PolicyDefId = $defId + Scope = if ($props.scope) { $props.scope } else { ($r.id -replace '/providers/Microsoft\.Authorization/policyAssignments/.*', '') } + Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' } + EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' } + Origin = $origin + Subscription = $subName + Description = if ($props.description) { $props.description } else { '' } + }) + } + $skipToken = $result.SkipToken + } else { $skipToken = $null } + } while ($skipToken) + if ($allAssignments.Count -gt 0) { + $gotAssignments = $true + Write-Host " Resource Graph fallback: $($allAssignments.Count) assignments" -ForegroundColor Green + } + } catch { + Write-Warning " Resource Graph policy query failed: $($_.Exception.Message)" + } + } + + # -- Strategy 2: Resource Graph for compliance (tenant-wide, fast) --- + # The MG-scope PolicyInsights summarize REST API hangs indefinitely, + # and per-sub REST loops are slow on large tenants. + # Resource Graph policyresources table gives us compliance across ALL + # subscriptions in a single paginated call - fast and complete. + try { + Write-Host " Querying policy compliance via Resource Graph..." -ForegroundColor Cyan + $compQuery = @" +policyresources +| where type =~ 'microsoft.policyinsights/policystates' +| extend complianceState = tostring(properties.complianceState) +| summarize + Compliant = countif(complianceState =~ 'Compliant'), + NonCompliant = countif(complianceState =~ 'NonCompliant'), + Total = count() + by subscriptionId +"@ + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $compResult = Search-AzGraphSafe -Query $compQuery -Subscription $subIds -First 1000 + + if ($compResult -and $compResult.Data -and $compResult.Data.Count -gt 0) { + foreach ($row in $compResult.Data) { + $subName = $row.subscriptionId + $matchSub = $Subscriptions | Where-Object { $_.Id -eq $row.subscriptionId } | Select-Object -First 1 + if ($matchSub) { $subName = $matchSub.Name } + + $complianceMap[$row.subscriptionId] = [PSCustomObject]@{ + Subscription = $subName + SubscriptionId = $row.subscriptionId + TotalResources = $row.Total + NonCompliant = $row.NonCompliant + Compliant = $row.Compliant + PolicyCount = 0 + } + } + $gotCompliance = $true + Write-Host " Resource Graph compliance: $($complianceMap.Count) subscriptions" -ForegroundColor Green + } + } catch { + Write-Warning " Resource Graph compliance query failed: $($_.Exception.Message)" + } + + # -- Compliance fallback: per-sub REST (only if ARG compliance failed) -- + if (-not $gotCompliance) { + Write-Host " Falling back to per-sub compliance queries..." -ForegroundColor Yellow + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($subCount -gt 20 -and ($i % 10 -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Scanning policy compliance ($i/$subCount)..." + } + } + try { + $compPath = "/subscriptions/$($sub.Id)/providers/Microsoft.PolicyInsights/policyStates/latest/summarize?api-version=2019-10-01" + $compResp = Invoke-AzRestMethodWithRetry -Path $compPath -Method POST + if ($compResp.StatusCode -eq 200) { + $summary = ($compResp.Content | ConvertFrom-Json).value + if ($summary -and $summary.Count -gt 0) { + $s = $summary[0].results + $complianceMap[$sub.Id] = [PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + TotalResources = $s.resourceDetails | ForEach-Object { $_.count } | Measure-Object -Sum | Select-Object -ExpandProperty Sum + NonCompliant = ($s.resourceDetails | Where-Object { $_.complianceState -eq 'noncompliant' }).count + Compliant = ($s.resourceDetails | Where-Object { $_.complianceState -eq 'compliant' }).count + PolicyCount = $s.policyDetails | ForEach-Object { $_.count } | Measure-Object -Sum | Select-Object -ExpandProperty Sum + } + } + } + } catch { + Write-Warning " Policy compliance failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Strategy 3: Per-sub fallback (only if Resource Graph failed) --- + if (-not $gotAssignments) { + Write-Host " Falling back to per-subscription policy scan..." -ForegroundColor Yellow + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Scanning policies ($i/$subCount subs)..." + } + } + try { + $assignPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01" + $resp = Invoke-AzRestMethodWithRetry -Path $assignPath -Method GET + if ($resp.StatusCode -eq 200) { + $assignments = ($resp.Content | ConvertFrom-Json).value + foreach ($a in $assignments) { + $props = $a.properties + $defId = $props.policyDefinitionId + $origin = if ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } else { 'Custom' } + if ($defId -match '/policySetDefinitions/') { $origin = 'Initiative' } + + [void]$allAssignments.Add([PSCustomObject]@{ + AssignmentName = $props.displayName + AssignmentId = $a.id + PolicyDefId = $defId + Scope = $props.scope + Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' } + EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' } + Origin = $origin + Subscription = $sub.Name + Description = if ($props.description) { $props.description } else { '' } + }) + } + } + } catch { + Write-Warning " Policy assignments failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Deduplicate assignments by name + scope ----------------------- + $seen = @{} + $unique = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($a in $allAssignments) { + $key = "$($a.AssignmentName)|$($a.Scope)" + if (-not $seen.ContainsKey($key)) { + $seen[$key] = $true + [void]$unique.Add($a) + } + } + + # -- Compliance totals --------------------------------------------- + $totalCompliant = 0 + $totalNonCompliant = 0 + foreach ($c in $complianceMap.Values) { + $totalCompliant += $c.Compliant + $totalNonCompliant += $c.NonCompliant + } + $totalEvaluated = $totalCompliant + $totalNonCompliant + $compliancePct = if ($totalEvaluated -gt 0) { [math]::Round(($totalCompliant / $totalEvaluated) * 100, 1) } else { 0 } + + return [PSCustomObject]@{ + Assignments = $unique + AssignmentCount = $unique.Count + ComplianceBySubMap = $complianceMap + CompliancePct = $compliancePct + TotalCompliant = $totalCompliant + TotalNonCompliant = $totalNonCompliant + TotalEvaluated = $totalEvaluated + HasComplianceData = ($totalEvaluated -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 new file mode 100644 index 000000000..d615843d9 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 @@ -0,0 +1,251 @@ +########################################################################### +# GET-POLICYRECOMMENDATIONS.PS1 +# AZURE FINOPS MULTITOOL - FinOps Policy Recommendations +########################################################################### +# Purpose: Compare the customer's existing policy assignments against a +# curated list of Microsoft-recommended FinOps/cost governance +# policies (Azure built-in policy definitions). +# +# Sources: +# - Azure built-in policies (Tags, General, Compute, Storage categories) +# - Microsoft Cloud Adoption Framework cost governance guidance +# - AzAdvertizer.net policy catalog reference +# +# Each recommendation includes the built-in policy definition ID so +# it can be deployed directly from the GUI. +########################################################################### + +function Get-PolicyRecommendations { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$ExistingAssignments # Policy assignment objects from Get-PolicyInventory + ) + + # -- Curated FinOps / Cost Governance Policies ---------------------- + # These are Azure built-in policy definition IDs verified from + # https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies + $recommendedPolicies = @( + # === TAGGING & NAMING (CAF: Enforce Tagging and Naming) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/726aca4c-86e9-4b04-b0c5-073027359532' + DisplayName = 'Require a tag on resources' + Category = 'Tags' + Pillar = 'Understand' + Priority = 'Required' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Enforce tagging on all resources for cost allocation and chargeback visibility' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags' + Parameters = @( + @{ Name = 'tagName'; Label = 'Tag name (e.g. CostCenter)'; Required = $true } + @{ Name = 'tagValue'; Label = 'Tag value (leave blank for any value)'; Required = $false } + ) + } + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/96670d01-0a4d-4649-9c89-2d3abc0a5025' + DisplayName = 'Require a tag on resource groups' + Category = 'Tags' + Pillar = 'Understand' + Priority = 'Required' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Enforce tagging on resource groups for cost allocation at the container level' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags' + Parameters = @( + @{ Name = 'tagName'; Label = 'Tag name (e.g. CostCenter)'; Required = $true } + ) + } + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcbef5-37f7' + DisplayName = 'Inherit a tag from the resource group if missing' + Category = 'Tags' + Pillar = 'Understand' + Priority = 'Recommended' + DefaultEffect = 'Modify' + AllowedEffects = @('Modify','Disabled') + Purpose = 'Auto-inherit tags from resource group to child resources for consistent cost allocation' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags' + Parameters = @( + @{ Name = 'tagName'; Label = 'Tag name to inherit (e.g. CostCenter)'; Required = $true } + ) + } + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/40df99da-1232-49b1-a39a-6da8d878f469' + DisplayName = 'Inherit a tag from the subscription if missing' + Category = 'Tags' + Pillar = 'Understand' + Priority = 'Recommended' + DefaultEffect = 'Modify' + AllowedEffects = @('Modify','Disabled') + Purpose = 'Auto-inherit tags from subscription to resources for top-level cost allocation' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags' + Parameters = @( + @{ Name = 'tagName'; Label = 'Tag name to inherit (e.g. CostCenter)'; Required = $true } + ) + } + + # === ALLOWED RESOURCE LOCATIONS (CAF) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c' + DisplayName = 'Allowed locations' + Category = 'General' + Pillar = 'Optimize' + Priority = 'Required' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Restrict resource deployment to authorized Azure regions for compliance and cost control' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#general' + Parameters = @( + @{ Name = 'listOfAllowedLocations'; Label = 'Allowed locations (comma-separated, e.g. eastus,westus2,centralus)'; Required = $true; IsArray = $true } + ) + } + + # === RESTRICT VM SIZES (CAF) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/cccc23c7-8427-4f53-ad12-b6a63eb452b3' + DisplayName = 'Allowed virtual machine size SKUs' + Category = 'Compute' + Pillar = 'Optimize' + Priority = 'Required' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Restrict VM sizes to prevent over-provisioning and control compute costs' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#compute' + Parameters = @( + @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed VM SKUs (comma-separated, e.g. Standard_D2s_v3,Standard_B2ms)'; Required = $true; IsArray = $true } + ) + } + + # === ALLOWED STORAGE ACCOUNT SKUS (CAF) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1' + DisplayName = 'Allowed storage account SKUs' + Category = 'Storage' + Pillar = 'Optimize' + Priority = 'Recommended' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Restrict storage account types to control costs and enforce standard tiers' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#storage' + Parameters = @( + @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed storage SKUs (comma-separated, e.g. Standard_LRS,Standard_GRS,Standard_ZRS)'; Required = $true; IsArray = $true } + ) + } + + # === ALLOWED DISK SKUS (CAF) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/06a78e20-9358-41c9-923c-fb736d382a4d' + DisplayName = 'Allowed managed disk SKUs' + Category = 'Compute' + Pillar = 'Optimize' + Priority = 'Recommended' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Restrict managed disk types to prevent costly Premium or Ultra disks where not needed' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#compute' + Parameters = @( + @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed disk SKUs (comma-separated, e.g. Standard_LRS,StandardSSD_LRS,Premium_LRS)'; Required = $true; IsArray = $true } + ) + } + + # === DEPLOY DIAGNOSTIC SETTINGS (CAF) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7f89b1eb-583c-429a-8828-af049802c1d9' + DisplayName = 'Audit diagnostic setting' + Category = 'Monitoring' + Pillar = 'Understand' + Priority = 'Required' + DefaultEffect = 'AuditIfNotExists' + AllowedEffects = @('AuditIfNotExists','Disabled') + Purpose = 'Automatically enable logging for diagnostics - ensures visibility into resource operations and costs' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#monitoring' + Parameters = @( + @{ Name = 'listOfResourceTypes'; Label = 'Resource types to audit (comma-separated, e.g. Microsoft.Compute/virtualMachines,Microsoft.Sql/servers,Microsoft.Storage/storageAccounts)'; Required = $true; IsArray = $true } + ) + } + + # === ADDITIONAL FINOPS-ALIGNED === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/6c112d4e-5bc7-47ae-a041-ea2d9dccd749' + DisplayName = 'Not allowed resource types' + Category = 'General' + Pillar = 'Optimize' + Priority = 'Recommended' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Block expensive or unnecessary resource types to reduce cost sprawl' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#general' + Parameters = @( + @{ Name = 'listOfResourceTypesNotAllowed'; Label = 'Resource types to block (comma-separated, e.g. Microsoft.Sql/servers,Microsoft.HDInsight/clusters)'; Required = $true; IsArray = $true } + ) + } + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1' + DisplayName = 'Storage accounts should be limited by allowed SKUs' + Category = 'Storage' + Pillar = 'Optimize' + Priority = 'Recommended' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Prevent Premium storage where Standard suffices to reduce storage costs' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#storage' + Parameters = @( + @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed storage SKUs (comma-separated, e.g. Standard_LRS,Standard_GRS)'; Required = $true; IsArray = $true } + ) + } + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/013e242c-8828-4970-87b3-ab247555486d' + DisplayName = 'Azure Backup should be enabled for Virtual Machines' + Category = 'Backup' + Pillar = 'Quantify' + Priority = 'Recommended' + DefaultEffect = 'AuditIfNotExists' + AllowedEffects = @('AuditIfNotExists','Disabled') + Purpose = 'Ensure VMs are backed up to prevent costly data loss recovery scenarios' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#backup' + } + ) + + # -- Match existing assignments against recommendations ------------ + $existingDefIds = @{} + $existingNames = @{} + foreach ($a in $ExistingAssignments) { + if ($a.PolicyDefId) { + $existingDefIds[$a.PolicyDefId.ToLower()] = $true + } + if ($a.AssignmentName) { + $existingNames[$a.AssignmentName.ToLower()] = $true + } + } + + $analysis = foreach ($rec in $recommendedPolicies) { + $foundById = $existingDefIds.ContainsKey($rec.PolicyDefId.ToLower()) + $foundByName = $existingNames.ContainsKey($rec.DisplayName.ToLower()) + $status = if ($foundById -or $foundByName) { 'Assigned' } else { 'Missing' } + + [PSCustomObject]@{ + DisplayName = $rec.DisplayName + Status = $status + Category = $rec.Category + Pillar = $rec.Pillar + Priority = $rec.Priority + DefaultEffect = $rec.DefaultEffect + AllowedEffects = $rec.AllowedEffects + Purpose = $rec.Purpose + PolicyDefId = $rec.PolicyDefId + Reference = $rec.Reference + Parameters = if ($rec.Parameters) { $rec.Parameters } else { @() } + } + } + + $missing = @($analysis | Where-Object { $_.Status -eq 'Missing' }) + $assigned = @($analysis | Where-Object { $_.Status -eq 'Assigned' }) + + return [PSCustomObject]@{ + Analysis = $analysis + Missing = $missing + Assigned = $assigned + CompliancePct = [math]::Round(($assigned.Count / $analysis.Count) * 100, 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 new file mode 100644 index 000000000..3ca656525 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 @@ -0,0 +1,230 @@ +########################################################################### +# GET-RESERVATIONADVICE.PS1 +# AZURE FINOPS MULTITOOL - Reservation & Savings Plan Recommendations +########################################################################### +# Purpose: Pull RI (Reserved Instance) and Savings Plan recommendations +# from Azure Advisor and the Reservation Recommendation API. +# +# Rate optimization (RI/SP) is the #1 FinOps quick win - typical +# savings are 30-72% versus pay-as-you-go pricing. +# +# Reference: https://learn.microsoft.com/en-us/azure/advisor/advisor-cost-recommendations +########################################################################### + +function Get-ReservationAdvice { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $allRecommendations = [System.Collections.Generic.List[PSCustomObject]]::new() + + # Set to $true if an Advisor or reservation-recommendation query is + # forbidden (401/403) rather than simply returning no recommendations. + $accessDenied = $false + + # Build subscription ID list and name lookup + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + $subNameMap = @{} + foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id] = $sub.Name } + + # Query Advisor cost recommendations via Resource Graph (single call) + $query = @" +advisorresources +| where type == 'microsoft.advisor/recommendations' +| where properties.category == 'Cost' +| where properties.shortDescription.problem matches regex '(?i)reserv|savings plan|reserved instance' + or properties.shortDescription.solution matches regex '(?i)reserv|savings plan|reserved instance' +| project subscriptionId, + shortDescriptionProblem = tostring(properties.shortDescription.problem), + shortDescriptionSolution = tostring(properties.shortDescription.solution), + impact = tostring(properties.impact), + impactedField = tostring(properties.impactedField), + impactedValue = tostring(properties.impactedValue), + annualSavings = tostring(properties.extendedProperties.annualSavingsAmount), + savingsCurrency = tostring(properties.extendedProperties.savingsCurrency), + term = tostring(properties.extendedProperties.term), + displaySKU = tostring(properties.extendedProperties.displaySKU), + region = tostring(properties.extendedProperties.region), + displayQty = tostring(properties.extendedProperties.displayQty), + recName = name +"@ + + try { + Write-Host " Querying RI/SP recommendations via Resource Graph..." -ForegroundColor Cyan + $allRows = [System.Collections.Generic.List[object]]::new() + $skipToken = $null + + do { + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -SkipToken $skipToken + if ($result -and $result.Data) { foreach ($r in $result.Data) { [void]$allRows.Add($r) } } + $skipToken = if ($result) { $result.SkipToken } else { $null } + } while ($skipToken) + + Write-Host " Retrieved $($allRows.Count) RI/SP recommendations." -ForegroundColor Cyan + + foreach ($row in $allRows) { + $subId = $row.subscriptionId + $savings = if ($row.annualSavings) { [math]::Round([double]$row.annualSavings, 2) } else { $null } + $subName = if ($subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { $subId } + + # Savings Plans are subscription-scoped, flexible commitments: Advisor + # returns the subscription GUID as impactedValue and carries no + # SKU/region/qty. Resolve the GUID to the subscription name and label + # the flexible dimensions 'Any' so the row reads as a real scope-wide + # commitment rather than a bare ID with missing data. RIs keep their + # real SKU/region/qty. The savings come straight from Advisor. + $isSavingsPlan = ($row.shortDescriptionSolution -match '(?i)savings plan') -or ($row.shortDescriptionProblem -match '(?i)savings plan') + $isSubScope = $row.impactedField -match '(?i)subscriptions/subscriptions' + $resName = if ($isSubScope) { "$subName (subscription-wide)" } else { $row.impactedValue } + $sku = if ($row.displaySKU) { $row.displaySKU } elseif ($isSavingsPlan) { 'Any (flexible)' } else { '-' } + $region = if ($row.region) { $row.region } elseif ($isSavingsPlan) { 'Any' } else { '-' } + $qty = if ($row.displayQty) { $row.displayQty } elseif ($isSavingsPlan) { 'Commitment' } else { '-' } + + [void]$allRecommendations.Add([PSCustomObject]@{ + Subscription = $subName + SubscriptionId = $subId + Problem = $row.shortDescriptionProblem + Solution = $row.shortDescriptionSolution + Impact = $row.impact + Category = 'Reservation / Savings Plan' + ResourceType = $row.impactedField + ResourceName = $resName + SKU = $sku + Region = $region + Qty = $qty + AnnualSavings = $savings + Currency = $row.savingsCurrency + Term = $row.term + RecommendationId = $row.recName + }) + } + } catch { + Write-Warning " Advisor Resource Graph query failed: $($_.Exception.Message)" + Write-Warning " Falling back to per-subscription REST calls..." + + foreach ($sub in $Subscriptions) { + try { + $advPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Advisor/recommendations?api-version=2023-01-01&`$filter=Category eq 'Cost'" + $advResp = Invoke-AzRestMethodWithRetry -Path $advPath -Method GET + if ($advResp.StatusCode -ne 200) { + if ($advResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + continue + } + $advResult = ($advResp.Content | ConvertFrom-Json) + + $riRecs = $advResult.value | Where-Object { + $_.properties.shortDescription.problem -match 'reserv|savings plan|reserved instance' -or + $_.properties.shortDescription.solution -match 'reserv|savings plan|reserved instance' + } + + foreach ($item in $riRecs) { + $rec = $item.properties + $isSavingsPlan = ($rec.shortDescription.solution -match '(?i)savings plan') -or ($rec.shortDescription.problem -match '(?i)savings plan') + $isSubScope = $rec.impactedField -match '(?i)subscriptions/subscriptions' + $resName = if ($isSubScope) { "$($sub.Name) (subscription-wide)" } else { $rec.impactedValue } + $sku = if ($rec.extendedProperties.displaySKU) { $rec.extendedProperties.displaySKU } elseif ($isSavingsPlan) { 'Any (flexible)' } else { '-' } + $region = if ($rec.extendedProperties.region) { $rec.extendedProperties.region } elseif ($isSavingsPlan) { 'Any' } else { '-' } + $qty = if ($rec.extendedProperties.displayQty) { $rec.extendedProperties.displayQty } elseif ($isSavingsPlan) { 'Commitment' } else { '-' } + [void]$allRecommendations.Add([PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + Problem = $rec.shortDescription.problem + Solution = $rec.shortDescription.solution + Impact = $rec.impact + Category = 'Reservation / Savings Plan' + ResourceType = $rec.impactedField + ResourceName = $resName + SKU = $sku + Region = $region + Qty = $qty + AnnualSavings = if ($rec.extendedProperties.annualSavingsAmount) { + [math]::Round([double]$rec.extendedProperties.annualSavingsAmount, 2) + } else { $null } + Currency = $rec.extendedProperties.savingsCurrency + Term = $rec.extendedProperties.term + RecommendationId = $item.name + }) + } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Advisor query failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Also try the Reservation Recommendation API -------------------- + $reservationRecs = [System.Collections.Generic.List[PSCustomObject]]::new() + try { + $rrPath = "/providers/Microsoft.Consumption/reservationRecommendations?api-version=2023-05-01&`$filter=properties/scope eq 'Shared' and properties/lookBackPeriod eq 'Last30Days'" + $rrResp = Invoke-AzRestMethodWithRetry -Path $rrPath -Method GET + if ($rrResp -and $rrResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + if (-not $rrResp -or -not $rrResp.Content) { throw "Reservation recommendation API returned no content (HTTP $($rrResp.StatusCode))" } + $rrResult = ($rrResp.Content | ConvertFrom-Json) + + if ($rrResult.value) { + foreach ($item in $rrResult.value) { + $props = $item.properties + [void]$reservationRecs.Add([PSCustomObject]@{ + ResourceType = $props.resourceType + SKU = $props.skuProperties.name + RecommendedQty = $props.recommendedQuantity + Term = $props.term + CostWithoutRI = if ($props.costWithNoReservedInstances) { [math]::Round($props.costWithNoReservedInstances, 2) } else { $null } + CostWithRI = if ($props.totalCostWithReservedInstances) { [math]::Round($props.totalCostWithReservedInstances, 2) } else { $null } + NetSavings = if ($props.netSavings) { [math]::Round($props.netSavings, 2) } else { $null } + Currency = $props.currencyCode + Scope = $props.scope + LookBackPeriod = $props.lookBackPeriod + }) + } + } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning "Reservation recommendation API query failed (non-critical): $($_.Exception.Message)" + } + + # -- De-duplicate Advisor records ----------------------------------- + # Azure Advisor frequently emits several identical recommendation + # records that differ only by recommendation GUID (overlapping + # generation cycles). Collapse them on the meaningful tuple + # (sub + resource type + term + SKU + region + qty + savings) so the + # grid shows one row per distinct buy. DuplicateCount records how + # many Advisor records were rolled into each row, and de-duping also + # prevents the estimated-savings total from being inflated 3x. + $deduped = [System.Collections.Generic.List[PSCustomObject]]::new() + $seenKeys = @{} + foreach ($rec in $allRecommendations) { + $key = '{0}|{1}|{2}|{3}|{4}|{5}|{6}' -f ` + $rec.SubscriptionId, $rec.ResourceType, $rec.Term, $rec.SKU, $rec.Region, $rec.Qty, $rec.AnnualSavings + if ($seenKeys.ContainsKey($key)) { + $seenKeys[$key].DuplicateCount++ + } + else { + $rec | Add-Member -NotePropertyName DuplicateCount -NotePropertyValue 1 -Force + $seenKeys[$key] = $rec + [void]$deduped.Add($rec) + } + } + if ($allRecommendations.Count -ne $deduped.Count) { + Write-Host " Collapsed $($allRecommendations.Count) Advisor records into $($deduped.Count) distinct recommendations." -ForegroundColor Cyan + } + $allRecommendations = $deduped + + # -- Aggregate savings ---------------------------------------------- + $totalAnnualSavings = ($allRecommendations | Where-Object { $_.AnnualSavings } | + Measure-Object -Property AnnualSavings -Sum).Sum + + $denied = ($accessDenied -and $allRecommendations.Count -eq 0 -and $reservationRecs.Count -eq 0) + + return [PSCustomObject]@{ + AdvisorRecommendations = $allRecommendations + ReservationRecommendations = $reservationRecs + TotalAdvisorCount = $allRecommendations.Count + TotalReservationCount = $reservationRecs.Count + EstimatedAnnualSavings = [math]::Round($totalAnnualSavings, 2) + AccessDenied = $denied + Summary = "$($allRecommendations.Count) Advisor + $($reservationRecs.Count) reservation recommendations" + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 new file mode 100644 index 000000000..6bacca3ca --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 @@ -0,0 +1,366 @@ +########################################################################### +# GET-RESOURCECOSTS.PS1 +# AZURE FINOPS MULTITOOL - Per-Resource Cost Breakdown +########################################################################### +# Purpose: Query Cost Management per subscription to retrieve actual and +# forecasted spend grouped by individual resource. +########################################################################### + +function Get-ResourceCosts { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions, + + [Parameter()] + [string]$TenantId, + + [Parameter()] + $CostData, # Per-sub cost data for forecast ratio distribution + + [Parameter()] + [switch]$RestrictToSelected + ) + + # Guard: extract hashtable if pipeline pollution wrapped it in an array + if ($CostData -and $CostData -isnot [hashtable]) { + $CostData = @($CostData | Where-Object { $_ -is [hashtable] })[-1] + } + if (-not $CostData) { $CostData = @{} } + + $allRows = [System.Collections.Generic.List[PSCustomObject]]::new() + + # Linear month-to-date projection factor for per-resource forecasts. The + # per-resource Cost Management query only returns ActualCost (MTD), so a + # native forecast is not available. Project to month-end (Actual / dayOfMonth + # * daysInMonth) so Forecast is a real projection instead of equal to Actual. + $now = Get-Date + $daysInMonth = [DateTime]::DaysInMonth($now.Year, $now.Month) + $dayOfMonth = [math]::Max(1, $now.Day) + $forecastMult = $daysInMonth / $dayOfMonth + + # Friendly resource type map + $typeMap = @{ + 'microsoft.compute/virtualmachines' = 'Virtual Machine' + 'microsoft.compute/disks' = 'Managed Disk' + 'microsoft.network/loadbalancers' = 'Load Balancer' + 'microsoft.network/applicationgateways' = 'App Gateway' + 'microsoft.network/azurefirewalls' = 'Azure Firewall' + 'microsoft.network/publicipaddresses' = 'Public IP' + 'microsoft.network/virtualnetworkgateways' = 'VNet Gateway' + 'microsoft.network/virtualnetworks' = 'Virtual Network' + 'microsoft.network/privatednszones' = 'Private DNS Zone' + 'microsoft.network/networkinterfaces' = 'NIC' + 'microsoft.network/networksecuritygroups' = 'NSG' + 'microsoft.network/bastionhosts' = 'Bastion' + 'microsoft.containerservice/managedclusters' = 'AKS Cluster' + 'microsoft.sql/servers' = 'SQL Server' + 'microsoft.sql/servers/databases' = 'SQL Database' + 'microsoft.storage/storageaccounts' = 'Storage Account' + 'microsoft.web/sites' = 'App Service' + 'microsoft.web/serverfarms' = 'App Service Plan' + 'microsoft.keyvault/vaults' = 'Key Vault' + 'microsoft.operationalinsights/workspaces' = 'Log Analytics' + 'microsoft.insights/components' = 'App Insights' + 'microsoft.recoveryservices/vaults' = 'Recovery Vault' + 'microsoft.automation/automationaccounts' = 'Automation Account' + 'microsoft.dbformysql/flexibleservers' = 'MySQL Flexible' + 'microsoft.dbforpostgresql/flexibleservers' = 'PostgreSQL Flexible' + 'microsoft.cosmosdb/databaseaccounts' = 'Cosmos DB' + 'microsoft.cache/redis' = 'Redis Cache' + 'microsoft.cdn/profiles' = 'CDN / Front Door' + 'microsoft.containerregistry/registries' = 'Container Registry' + 'microsoft.apimanagement/service' = 'API Management' + 'microsoft.eventgrid/topics' = 'Event Grid Topic' + 'microsoft.servicebus/namespaces' = 'Service Bus' + 'microsoft.logic/workflows' = 'Logic App' + 'microsoft.security/pricings' = 'Defender Plan' + 'microsoft.hybridcompute/machines' = 'Arc Server' + } + + $gotMgData = $false + + # -- Strategy 1: MG-scope query (1-10 API calls instead of 300+) ---- + # When the user picked a subset of subscriptions we KEEP the fast MG-scope + # query but add a server-side SubscriptionId filter so only the selected + # subs' resources are returned - avoids the slow per-subscription fan-out + # that triggers 429 throttling. + $mgScopeId = if ($TenantId) { Resolve-CostMgId -TenantId $TenantId } else { $null } + $subFilter = if ($RestrictToSelected) { Get-CostSubscriptionFilter -Subscriptions $Subscriptions } else { $null } + if ($mgScopeId) { + try { + Write-Host " Querying resource costs (MG scope)..." -ForegroundColor Cyan + $rcDataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'ResourceId' } + @{ type = 'Dimension'; name = 'ResourceGroupName' } + ) + } + if ($subFilter) { $rcDataset['filter'] = $subFilter } + $body = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = $rcDataset + } | ConvertTo-Json -Depth 10 + + $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $body + + if ($resp.StatusCode -eq 200) { + $result = ($resp.Content | ConvertFrom-Json) + $cols = @{} + for ($i = 0; $i -lt $result.properties.columns.Count; $i++) { + $cols[$result.properties.columns[$i].name] = $i + } + + $page = $result + $pageNum = 0 + do { + $pageNum++ + if ($page.properties.rows) { + if ($pageNum -eq 1 -or $pageNum % 3 -eq 0) { + Write-Host " Page $pageNum ($($page.properties.rows.Count) rows)..." -ForegroundColor Gray + } + foreach ($row in $page.properties.rows) { + $cost = [math]::Round($row[$cols['Cost']], 2) + $currency = $row[$cols['Currency']] + $resourceId = $row[$cols['ResourceId']] + $rg = $row[$cols['ResourceGroupName']] + + $resType = 'Unknown' + $resName = $resourceId + if ($resourceId -match '/providers/(.+)/([^/]+)$') { + $providerType = $Matches[1].ToLower() + $resName = $Matches[2] + $resType = if ($typeMap.ContainsKey($providerType)) { $typeMap[$providerType] } else { $providerType -replace 'microsoft\.', '' } + } + + [void]$allRows.Add([PSCustomObject]@{ + Subscription = '' + ResourceGroup = $rg + ResourceType = $resType + ResourcePath = $resourceId + Actual = $cost + Forecast = [math]::Round($cost * $forecastMult, 2) + Currency = $currency + }) + } + } + if ($page.properties.nextLink) { + $nextUri = [System.Uri]$page.properties.nextLink + $nResp = Invoke-AzRestMethodWithRetry -Path $nextUri.PathAndQuery -Method GET + if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) } + else { break } + } else { break } + } while ($true) + + if ($allRows.Count -gt 0) { + $gotMgData = $true + Write-Host " MG scope: $($allRows.Count) resources across $pageNum page(s)" -ForegroundColor Green + + # Populate subscription names from ARM resource ID + $subNameMap = @{} + foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id.ToLower()] = $sub.Name } + foreach ($r in $allRows) { + if ($r.ResourcePath -match '/subscriptions/([^/]+)/') { + $sid = $Matches[1].ToLower() + $r.Subscription = if ($subNameMap.ContainsKey($sid)) { $subNameMap[$sid] } else { $sid } + } + } + + # Apply forecast ratios from CostData (actual + forecast per sub) + if ($CostData) { + $ratios = @{} + foreach ($entry in $CostData.GetEnumerator()) { + $a = $entry.Value.Actual + $f = $entry.Value.Forecast + if ($a -gt 0 -and $f -gt $a) { $ratios[$entry.Key.ToLower()] = $f / $a } + } + foreach ($r in $allRows) { + if ($r.ResourcePath -match '/subscriptions/([^/]+)/') { + $sid = $Matches[1].ToLower() + if ($ratios.ContainsKey($sid)) { + $r.Forecast = [math]::Round($r.Actual * $ratios[$sid], 2) + } + } + } + } + } + } else { + if ($resp.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed } + Write-Warning " MG-scope resource cost query returned HTTP $($resp.StatusCode)" + } + } catch { + Write-Warning " MG-scope resource cost query failed: $($_.Exception.Message)" + } + } + + # -- Strategy 2: Per-subscription fallback (only if MG scope failed) - + if (-not $gotMgData) { + $subCount = $Subscriptions.Count + $skipForecast = ($subCount -gt 50) # For large tenants, skip per-sub forecast to halve API calls + if ($skipForecast) { + Write-Host " Large tenant ($subCount subs): skipping per-resource forecast to reduce API calls" -ForegroundColor Yellow + } + + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying resource costs ($i/$subCount subs)..." + } + } + $basePath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement" + + # -- Actual cost grouped by resource ---------------------------- + $actualMap = @{} + try { + Write-Host " Querying resource costs for $($sub.Name)..." -ForegroundColor Cyan + $body = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'ResourceId' } + @{ type = 'Dimension'; name = 'ResourceGroupName' } + ) + } + } | ConvertTo-Json -Depth 10 + + $resp = Invoke-AzRestMethodWithRetry -Path "$basePath/query?api-version=2023-11-01" -Method POST -Payload $body + + if ($resp.StatusCode -eq 200) { + $result = ($resp.Content | ConvertFrom-Json) + + # Build column index from response metadata (same for all pages) + $cols = @{} + for ($i = 0; $i -lt $result.properties.columns.Count; $i++) { + $cols[$result.properties.columns[$i].name] = $i + } + + # Process all pages (Cost Management API paginates at ~5000 rows) + $page = $result + do { + if ($page.properties.rows) { + foreach ($row in $page.properties.rows) { + $cost = [math]::Round($row[$cols['Cost']], 2) + $currency = $row[$cols['Currency']] + $resourceId = $row[$cols['ResourceId']] + $rg = $row[$cols['ResourceGroupName']] + + # Extract resource type from ARM ID + $resType = 'Unknown' + $resName = $resourceId + if ($resourceId -match '/providers/(.+)/([^/]+)$') { + $providerType = $Matches[1].ToLower() + $resName = $Matches[2] + $resType = if ($typeMap.ContainsKey($providerType)) { $typeMap[$providerType] } else { $providerType -replace 'microsoft\.', '' } + } + + $actualMap[$resourceId] = [PSCustomObject]@{ + Subscription = $sub.Name + ResourceGroup = $rg + ResourceType = $resType + ResourcePath = $resourceId + Actual = $cost + Forecast = [math]::Round($cost * $forecastMult, 2) + Currency = $currency + } + } + } + # Follow pagination link if present + if ($page.properties.nextLink) { + $uri = [System.Uri]$page.properties.nextLink + $nResp = Invoke-AzRestMethodWithRetry -Path $uri.PathAndQuery -Method GET + if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) } + else { break } + } else { break } + } while ($true) + } + } catch { + Write-Warning " Resource cost query failed for $($sub.Name): $($_.Exception.Message)" + } + + # -- Forecast: use subscription-level forecast ratio ------------- + # The forecast API does not reliably support ResourceId grouping, + # so we get the sub-level forecast and distribute proportionally. + # For large tenants (50+ subs), skip per-sub forecast API calls + # and use CostData ratios if available. + $subTotalActual = 0 + foreach ($entry in $actualMap.Values) { $subTotalActual += $entry.Actual } + + $subForecast = $subTotalActual # default: same as actual + + # Use CostData ratio if available (avoids extra API call) + if ($CostData -and $CostData.ContainsKey($sub.Id)) { + $cd = $CostData[$sub.Id] + if ($cd.Forecast -gt $cd.Actual -and $cd.Actual -gt 0) { + $subForecast = $subTotalActual * ($cd.Forecast / $cd.Actual) + } + } + elseif (-not $skipForecast) { + # Only call forecast API for small tenants without CostData + try { + $now = Get-Date + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + + $fBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.ToString('yyyy-MM-dd') + to = $monthEnd.ToString('yyyy-MM-dd') + } + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + includeActualCost = $true + includeFreshPartialCost = $false + } | ConvertTo-Json -Depth 10 + + $fResp = Invoke-AzRestMethodWithRetry -Path "$basePath/forecast?api-version=2023-11-01" -Method POST -Payload $fBody + + if ($fResp.StatusCode -eq 200) { + $fResult = ($fResp.Content | ConvertFrom-Json) + if ($fResult.properties.rows -and $fResult.properties.rows.Count -gt 0) { + $forecastTotal = 0 + foreach ($row in $fResult.properties.rows) { + $forecastTotal += [double]$row[0] + } + $subForecast = [math]::Round($forecastTotal, 2) + } + } + } catch { + # Forecast not available for all account types + } + } + + # Apply forecast ratio proportionally to each resource + if ($subTotalActual -gt 0 -and $subForecast -gt $subTotalActual) { + $ratio = $subForecast / $subTotalActual + foreach ($entry in $actualMap.Values) { + $entry.Forecast = [math]::Round($entry.Actual * $ratio, 2) + } + } + + # Collect rows from this sub + foreach ($entry in $actualMap.Values) { + [void]$allRows.Add($entry) + } + } + } # end per-sub fallback + + return $allRows +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 new file mode 100644 index 000000000..17db7ba4d --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -0,0 +1,372 @@ +########################################################################### +# GET-SAVINGSREALIZED.PS1 +# AZURE FINOPS MULTITOOL - Savings Already Realized from Commitments +########################################################################### +# Purpose: Calculate how much existing RIs, Savings Plans, and AHB have +# already saved vs pay-as-you-go. This is the "value delivered" +# metric that FinOps teams report to leadership. +########################################################################### + +function Get-SavingsRealized { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions, + + [Parameter()] + [string]$TenantId, + + [Parameter()] + [object]$CommitmentData + ) + + Write-Host " Calculating savings already realized..." -ForegroundColor Cyan + + $riSavings = 0 + $spSavings = 0 + $ahbSavings = 0 + # Amortized cost split by pricing model, used for commitment COVERAGE + # (how much of eligible spend rides on a commitment) - distinct from the + # savings amounts above. Spot is excluded from the eligible base because it + # cannot be covered by a reservation or savings plan. + $committedAmort = 0.0 + $onDemandAmort = 0.0 + $spotAmort = 0.0 + $details = [System.Collections.Generic.List[PSCustomObject]]::new() + + # -- Short-circuit: skip RI/SP queries if no commitments exist ------- + $hasCommitments = $true + if ($CommitmentData -and $CommitmentData.PSObject.Properties['HasData']) { + if (-not $CommitmentData.HasData) { + $hasCommitments = $false + Write-Host " No reservations or savings plans detected — skipping commitment savings queries" -ForegroundColor DarkGray + } + } + + $gotMgData = $false + $subCount = if ($Subscriptions) { $Subscriptions.Count } else { 0 } + + # Map subscription Id -> friendly name so MG-grouped rows keep per-sub attribution + $subNameById = @{} + foreach ($s in $Subscriptions) { $subNameById[$s.Id] = $s.Name } + + # Build a Cost Management query body with the requested grouping dimensions + function New-SavingsQueryBody { + param([string]$Type, [string[]]$Dimensions) + @{ + type = $Type + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @($Dimensions | ForEach-Object { @{ type = 'Dimension'; name = $_ } }) + } + } | ConvertTo-Json -Depth 10 + } + + # Resolve named column indices from a Cost Management query result + function Get-SavingsColMap { + param($Columns) + $map = @{ Cost = 0; ChargeType = -1; PricingModel = -1; SubscriptionId = -1 } + for ($c = 0; $c -lt $Columns.Count; $c++) { + switch ($Columns[$c].name) { + 'Cost' { $map.Cost = $c } + 'ChargeType' { $map.ChargeType = $c } + 'PricingModel' { $map.PricingModel = $c } + 'SubscriptionId' { $map.SubscriptionId = $c } + } + } + $map + } + + # Parse an ActualCost result for UnusedReservation waste; returns detail rows + function Read-SavingsActual { + param($Result) + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + if (-not $Result -or -not $Result.properties.rows) { return $rows } + $m = Get-SavingsColMap -Columns $Result.properties.columns + foreach ($row in $Result.properties.rows) { + $charge = if ($m.ChargeType -ge 0) { [string]$row[$m.ChargeType] } else { '' } + if ($charge -match 'UnusedReservation') { + $sub = 'All (MG scope)' + if ($m.SubscriptionId -ge 0) { + $sid = [string]$row[$m.SubscriptionId] + $sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid } + } + $rows.Add([PSCustomObject]@{ + Subscription = $sub + Category = 'Unused Reservation' + Amount = [math]::Round([double]$row[$m.Cost], 2) + Type = 'Waste' + }) + } + } + return $rows + } + + # Parse an AmortizedCost result for RI/SP benefit; returns rows + savings totals + function Read-SavingsAmort { + param($Result) + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + $ri = 0.0; $sp = 0.0 + $committed = 0.0; $onDemand = 0.0; $spot = 0.0 + if ($Result -and $Result.properties.rows) { + $m = Get-SavingsColMap -Columns $Result.properties.columns + foreach ($row in $Result.properties.rows) { + $pm = if ($m.PricingModel -ge 0) { [string]$row[$m.PricingModel] } else { '' } + $cost = [math]::Round([double]$row[$m.Cost], 2) + $sub = 'All (MG scope)' + if ($m.SubscriptionId -ge 0) { + $sid = [string]$row[$m.SubscriptionId] + $sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid } + } + if ($pm -match 'Reservation') { + $ri += $cost * 0.4 + $committed += $cost + $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Reservation Benefit'; Amount = $cost; Type = 'Commitment' }) + } + elseif ($pm -match 'SavingsPlan') { + $sp += $cost * 0.25 + $committed += $cost + $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Savings Plan Benefit'; Amount = $cost; Type = 'Commitment' }) + } + elseif ($pm -match 'Spot') { $spot += $cost } + elseif ($pm) { $onDemand += $cost } + } + } + return [PSCustomObject]@{ Rows = $rows; RI = $ri; SP = $sp; Committed = $committed; OnDemand = $onDemand; Spot = $spot } + } + + if ($hasCommitments -and $subCount -eq 1) { + # -- Strategy 0: single-subscription fast path (skip MG resolution entirely) -- + $only = $Subscriptions[0] + try { + Write-Host " Calculating savings (single subscription, direct scope)..." -ForegroundColor Cyan + $subPath = "/subscriptions/$($only.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + + $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload (New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('ChargeType')) + if ($actualResp.StatusCode -eq 200) { + foreach ($d in (Read-SavingsActual -Result ($actualResp.Content | ConvertFrom-Json))) { + $d.Subscription = $only.Name; [void]$details.Add($d) + } + } + + $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload (New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('PricingModel')) + if ($amortResp.StatusCode -eq 200) { + $parsed = Read-SavingsAmort -Result ($amortResp.Content | ConvertFrom-Json) + foreach ($d in $parsed.Rows) { $d.Subscription = $only.Name; [void]$details.Add($d) } + $riSavings += $parsed.RI + $spSavings += $parsed.SP + $committedAmort += $parsed.Committed + $onDemandAmort += $parsed.OnDemand + $spotAmort += $parsed.Spot + } + + $gotMgData = $true + Write-Host " Single-subscription savings calculated (2 API calls)" -ForegroundColor Green + } catch { + Write-Warning " Single-subscription savings query failed: $($_.Exception.Message)" + } + } + elseif ($hasCommitments) { + # -- Strategy 1: MG-scope grouped by SubscriptionId (2 calls, per-sub attribution) -- + $mgScopeId = if ($TenantId) { Resolve-CostMgId -TenantId $TenantId } else { $null } + if ($mgScopeId) { + try { + Write-Host " Calculating savings (MG scope, grouped by subscription)..." -ForegroundColor Cyan + $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + + $actualResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload (New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('SubscriptionId', 'ChargeType')) + if ($actualResp.StatusCode -in @(401, 403)) { + Set-MgCostScopeFailed + throw "MG-scope savings query returned HTTP $($actualResp.StatusCode)" + } + if ($actualResp.StatusCode -eq 200) { + foreach ($d in (Read-SavingsActual -Result ($actualResp.Content | ConvertFrom-Json))) { [void]$details.Add($d) } + } + + $amortResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload (New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('SubscriptionId', 'PricingModel')) + if ($amortResp.StatusCode -eq 200) { + $parsed = Read-SavingsAmort -Result ($amortResp.Content | ConvertFrom-Json) + foreach ($d in $parsed.Rows) { [void]$details.Add($d) } + $riSavings += $parsed.RI + $spSavings += $parsed.SP + $committedAmort += $parsed.Committed + $onDemandAmort += $parsed.OnDemand + $spotAmort += $parsed.Spot + } + + $gotMgData = $true + Write-Host " MG scope savings calculated (2 API calls)" -ForegroundColor Green + } catch { + Write-Warning " MG-scope savings query failed: $($_.Exception.Message)" + } + } + } + + # -- Strategy 2: Per-subscription fallback (only if MG/direct scope unavailable) -- + if ($hasCommitments -and -not $gotMgData) { + # -- Step 1: Query amortized vs actual to find RI/SP benefit amounts -- + # The difference between ActualCost and AmortizedCost reveals commitment savings + $subCount = $Subscriptions.Count + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($subCount -gt 5 -and ($i -eq 1 -or $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Calculating savings ($i/$subCount subs)..." + } + } + try { + # Get ActualCost MonthToDate + $actualBody = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'ChargeType' } + ) + } + } | ConvertTo-Json -Depth 10 + + $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $actualBody + + if ($actualResp.StatusCode -eq 200) { + $actualResult = ($actualResp.Content | ConvertFrom-Json) + if ($actualResult.properties.rows) { + foreach ($row in $actualResult.properties.rows) { + $chargeType = $row[1] + $cost = [math]::Round([double]$row[0], 2) + + # RI/SP purchases show as separate charge types + if ($chargeType -match 'UnusedReservation') { + # This is wasted money — unused RI capacity + [void]$details.Add([PSCustomObject]@{ + Subscription = $sub.Name + Category = 'Unused Reservation' + Amount = $cost + Type = 'Waste' + }) + } + } + } + } + + # Get benefit usage via the reservation transactions or amortized view + $amortBody = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'PricingModel' } + ) + } + } | ConvertTo-Json -Depth 10 + + $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $amortBody + if ($amortResp.StatusCode -eq 200) { + $amortResult = ($amortResp.Content | ConvertFrom-Json) + if ($amortResult.properties.rows) { + foreach ($row in $amortResult.properties.rows) { + $pricingModel = $row[1] + $cost = [math]::Round([double]$row[0], 2) + + if ($pricingModel -match 'Reservation') { + # Amortized RI cost — the actual RI spend + $riSavings += $cost * 0.4 # Approximate: RIs typically save ~40% vs PAYG + $committedAmort += $cost + [void]$details.Add([PSCustomObject]@{ + Subscription = $sub.Name + Category = 'Reservation Benefit' + Amount = $cost + Type = 'Commitment' + }) + } + elseif ($pricingModel -match 'SavingsPlan') { + $spSavings += $cost * 0.25 # Approximate: SPs save ~25% on average + $committedAmort += $cost + [void]$details.Add([PSCustomObject]@{ + Subscription = $sub.Name + Category = 'Savings Plan Benefit' + Amount = $cost + Type = 'Commitment' + }) + } + elseif ($pricingModel -match 'Spot') { $spotAmort += $cost } + elseif ($pricingModel) { $onDemandAmort += $cost } + } + } + } + } catch { + Write-Warning " Savings query failed for $($sub.Name): $($_.Exception.Message)" + } + } + } # end per-sub fallback + + # -- Step 2: AHB realized savings (per-SKU Windows license premium) --- + try { + $ahbQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where properties.licenseType == 'Windows_Server' +| project vmSize = tostring(properties.hardwareProfile.vmSize), location +"@ + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $ahbResult = Search-AzGraphSafe -Query $ahbQuery -Subscription $subIds + $ahbVMs = if ($ahbResult.Data) { @($ahbResult.Data) } else { @() } + if ($ahbVMs.Count -gt 0) { + $ahbSavings = 0 + $haveRates = Get-Command Get-AhbVmRates -ErrorAction SilentlyContinue + foreach ($vm in $ahbVMs) { + $perVm = 50 # fallback monthly estimate per VM when live rates are unavailable + if ($haveRates) { + $rates = Get-AhbVmRates -VmSize $vm.vmSize -Region $vm.location + if ($rates) { $perVm = [math]::Round($rates.HourlyPremium * 730, 2) } + } + $ahbSavings += $perVm + } + [void]$details.Add([PSCustomObject]@{ + Subscription = 'All' + Category = 'Azure Hybrid Benefit (VMs)' + Amount = [math]::Round($ahbSavings, 2) + Type = 'AHB' + }) + } + } catch { + Write-Warning " AHB savings query failed: $($_.Exception.Message)" + } + + $totalMonthly = [math]::Round($riSavings + $spSavings + $ahbSavings, 2) + $totalAnnual = [math]::Round($totalMonthly * 12, 2) + + # Commitment coverage = committed eligible spend / total eligible spend. + # Eligible = everything except Spot (Spot cannot be covered by a commitment). + $eligibleBase = $committedAmort + $onDemandAmort + $commitmentCoverage = if ($eligibleBase -gt 0) { + [math]::Round(100 * $committedAmort / $eligibleBase, 1) + } + else { $null } + + return [PSCustomObject]@{ + RISavingsMonthly = [math]::Round($riSavings, 2) + SPSavingsMonthly = [math]::Round($spSavings, 2) + AHBSavingsMonthly = [math]::Round($ahbSavings, 2) + TotalMonthly = $totalMonthly + TotalAnnual = $totalAnnual + CommittedAmortized = [math]::Round($committedAmort, 2) + OnDemandAmortized = [math]::Round($onDemandAmort, 2) + SpotAmortized = [math]::Round($spotAmort, 2) + CommitmentCoveragePct = $commitmentCoverage + Details = @($details) + HasData = ($totalMonthly -gt 0 -or $details.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 new file mode 100644 index 000000000..3f71762e1 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -0,0 +1,419 @@ +########################################################################### +# GET-SHAREDCOSTALLOCATION.PS1 +# AZURE FINOPS MULTITOOL - Shared Hub Cost Allocation (Showback) +########################################################################### +# Purpose: Distribute the billed cost of SHARED hub resources (ExpressRoute +# gateway/circuit, VPN gateway, Azure Firewall, shared bandwidth) +# across the spoke subscriptions that consume them, and report each +# spoke's full "solution cost" = its own resources + its allocated +# share of the shared pool. +# +# Why: Shared connectivity bills entirely to the hub subscription, so +# Cost Management cannot tell which spoke generated which gigabyte. +# The split key (GB/TB transferred) lives in network telemetry, not +# billing data. This tool keeps the cost math here and takes the +# weighting vector as input - so an agent can fetch GB-per-spoke +# from Azure Monitor / Traffic Analytics (e.g. via the Azure MCP +# server) and hand it in, or fall back to a proxy key when no +# flow logs exist. +# +# Weighting providers: +# inline - exact GB/TB map supplied by the caller (best) +# trafficAnalytics - exact: queries Traffic Analytics / VNet flow logs in a +# Log Analytics workspace for measured GB per spoke +# equal - split evenly across spokes (no measurement) +# resourceCount - proxy: billable resource count per spoke (ARG, estimate) +# +# Split model (per shared pool): +# spoke_i = (Fixed / nSpokes) + (Variable * weight_i / totalWeight) +# where Fixed = pool * FixedRatio (split evenly - gateways cost money at +# zero traffic) and Variable = pool * (1 - FixedRatio) (by transfer). +# +# Sources: Live Cost Management API (default) OR the FinOps Hub / export +# when -HubData is injected by the MCP dispatcher (fast path). +# +# Notes: +# - RBAC: Cost Management Reader (hub + spoke subs) + Reader (ARG). +# - Per-spoke ExpressRoute attribution is impossible from billing alone; it +# requires the flow-log weighting key. Without it, results are estimates +# and are labelled as such. +########################################################################### + +# -- Resolve the shared cost pool resources -------------------------------- +# From explicit resource IDs and/or a resource group, confirm the shared +# resources exist via ARG and return their IDs + subscriptions. +function Resolve-SharedCostPool { + param( + [string[]]$SubscriptionIds, + [string[]]$ResourceIds, + [string]$ResourceGroup + ) + + $clauses = @() + if ($ResourceIds -and $ResourceIds.Count -gt 0) { + $idList = ($ResourceIds | ForEach-Object { "'$_'" }) -join ',' + $clauses += "id in~ ($idList)" + } + if ($ResourceGroup) { + $clauses += "resourceGroup =~ '$ResourceGroup'" + } + if ($clauses.Count -eq 0) { return @() } + + $where = $clauses -join ' or ' + $query = @" +resources +| where $where +| project id, name, type, resourceGroup, subscriptionId +"@ + + $res = Search-AzGraphSafe -Query $query -Subscription $SubscriptionIds -First 200 + $rows = if ($res) { @($res.Data) } else { @() } + + return @($rows | ForEach-Object { + [PSCustomObject]@{ + Id = [string]$_.id + Name = [string]$_.name + Type = [string]$_.type + ResourceGroup = [string]$_.resourceGroup + SubscriptionId = [string]$_.subscriptionId + } + }) +} + +# -- Build the spoke weighting vector -------------------------------------- +# Returns a hashtable: spokeId -> weight (double). Method decides the source. +function Get-SpokeWeighting { + param( + [string[]]$Spokes, + [string]$Method, + [object]$Values, + [string[]]$SubscriptionIds, + [string]$WorkspaceId, + [int]$LookbackDays = 30, + [string]$Query + ) + + $weights = @{} + foreach ($s in $Spokes) { $weights[$s] = 0.0 } + + switch ($Method) { + 'equal' { + foreach ($s in $Spokes) { $weights[$s] = 1.0 } + } + 'trafficAnalytics' { + if (-not $WorkspaceId) { + Write-Warning ' trafficAnalytics weighting needs workspaceId (the Log Analytics workspace GUID); weights left at 0.' + break + } + $lb = if ($LookbackDays -gt 0) { $LookbackDays } else { 30 } + # The query must return two columns: Spoke (subscription id) and GB (number). + $kql = if ($Query) { + $Query + } + else { + @" +AzureNetworkAnalytics_CL +| where TimeGenerated >= ago(${lb}d) +| where SubType_s == 'FlowLog' +| extend Spoke = column_ifexists('Subscription1_s', '') +| where isnotempty(Spoke) +| summarize GB = sum(InboundBytes_d + OutboundBytes_d) / 1e9 by Spoke +"@ + } + try { + $qr = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $kql -ErrorAction Stop + foreach ($row in @($qr.Results)) { + $sid = [string]$row.Spoke + if ($weights.ContainsKey($sid)) { $weights[$sid] = [double]$row.GB } + } + } + catch { + Write-Warning " Traffic Analytics query failed: $($_.Exception.Message)" + } + } + 'resourceCount' { + $spokeList = ($Spokes | ForEach-Object { "'$_'" }) -join ',' + $query = @" +resources +| where subscriptionId in~ ($spokeList) +| summarize c = count() by subscriptionId +"@ + $res = Search-AzGraphSafe -Query $query -Subscription $Spokes -First 1000 + foreach ($r in @($res.Data)) { + $sid = [string]$r.subscriptionId + if ($weights.ContainsKey($sid)) { $weights[$sid] = [double]$r.c } + } + } + default { + # inline: read numeric values keyed by spoke id from $Values + if ($Values) { + $pairs = if ($Values -is [hashtable]) { + $Values.GetEnumerator() + } + else { + $Values.PSObject.Properties | ForEach-Object { [PSCustomObject]@{ Key = $_.Name; Value = $_.Value } } + } + foreach ($p in $pairs) { + $k = [string]$p.Key + if ($weights.ContainsKey($k)) { $weights[$k] = [double]$p.Value } + } + } + } + } + + return $weights +} + +# -- Read cost maps (by resource id + by subscription) --------------------- +# One pass over the data source produces both: byResourceId (for the shared +# pool) and bySubscriptionId (for each spoke's own cost). +function Get-AllocationCostMaps { + param( + [string[]]$SubscriptionIds, + [object[]]$HubData + ) + + $byResource = @{} + $bySub = @{} + $currency = 'USD' + $source = 'LiveApi' + + $fromHub = ($HubData -and @($HubData).Count -gt 0) + + if ($fromHub) { + $source = 'FinOpsHub' + $props = $HubData[0].PSObject.Properties.Name + foreach ($row in $HubData) { + $rid = [string](Get-HubRowValue -Row $row -Names @('ResourceId', 'x_ResourceId', 'InstanceId') -Props $props) + $cost = [double](Get-HubRowValue -Row $row -Names @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost', 'Cost') -Props $props) + $sub = [string](Get-HubRowValue -Row $row -Names @('SubAccountId', 'SubscriptionId', 'x_SubscriptionId', 'SubscriptionGuid') -Props $props) + $cur = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency') -Props $props) + if ($cur) { $currency = $cur } + + # SubAccountId is a full path (/subscriptions/{guid}); normalize. + if ($sub -and $sub -match '/subscriptions/([0-9a-fA-F-]+)') { $sub = $Matches[1] } + + if ($rid) { + if (-not $byResource.ContainsKey($rid)) { $byResource[$rid] = 0.0 } + $byResource[$rid] += $cost + } + if ($sub) { + if (-not $bySub.ContainsKey($sub)) { $bySub[$sub] = 0.0 } + $bySub[$sub] += $cost + } + } + } + else { + foreach ($sub in ($SubscriptionIds | Select-Object -Unique)) { + if (-not $sub) { continue } + $body = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'ResourceId' }) + } + } | ConvertTo-Json -Depth 12 + + $path = "/subscriptions/$sub/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + try { + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + $cols = @($data.properties.columns.name) + $iCost = [array]::IndexOf($cols, 'Cost') + $iRes = [array]::IndexOf($cols, 'ResourceId') + $iCur = [array]::IndexOf($cols, 'Currency') + if (-not $bySub.ContainsKey($sub)) { $bySub[$sub] = 0.0 } + foreach ($row in @($data.properties.rows)) { + $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 } + $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } + if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] } + $bySub[$sub] += $amount + if ($rid) { + if (-not $byResource.ContainsKey($rid)) { $byResource[$rid] = 0.0 } + $byResource[$rid] += $amount + } + } + } + } + catch { + Write-Warning " Cost query failed for $sub : $($_.Exception.Message)" + } + } + } + + return [PSCustomObject]@{ + ByResource = $byResource + BySub = $bySub + Currency = $currency + Source = $source + } +} + +# -- Main: allocate shared cost across spokes ------------------------------ +function Get-SharedCostAllocation { + [CmdletBinding()] + param( + [Parameter()] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [string[]]$SharedResourceIds, + + [Parameter()] + [string]$SharedResourceGroup, + + [Parameter()] + [string[]]$Spokes, + + [Parameter()] + [string]$WeightingMethod = 'inline', + + [Parameter()] + [object]$WeightingValues, + + [Parameter()] + [string]$WorkspaceId, + + [Parameter()] + [int]$LookbackDays = 30, + + [Parameter()] + [string]$WeightingQuery, + + [Parameter()] + [double]$FixedRatio = 0.5, + + [Parameter()] + [object[]]$HubData + ) + + if ((-not $SharedResourceIds -or $SharedResourceIds.Count -eq 0) -and -not $SharedResourceGroup) { + return [PSCustomObject]@{ + HasData = $false + Note = 'Provide sharedResourceIds (e.g. the ExpressRoute gateway / firewall) or a sharedResourceGroup that holds them.' + } + } + if (-not $Spokes -or $Spokes.Count -eq 0) { + return [PSCustomObject]@{ + HasData = $false + Note = 'Provide spokes - the subscription IDs that share the hub resources.' + } + } + if ($FixedRatio -lt 0) { $FixedRatio = 0 } + if ($FixedRatio -gt 1) { $FixedRatio = 1 } + + $scanSubs = @($Subscriptions | ForEach-Object { $_.Id }) + if (-not $scanSubs -or $scanSubs.Count -eq 0) { $scanSubs = $Spokes } + + Write-Host " Resolving shared resources..." -ForegroundColor Cyan + $pool = Resolve-SharedCostPool -SubscriptionIds $scanSubs -ResourceIds $SharedResourceIds -ResourceGroup $SharedResourceGroup + if (-not $pool -or $pool.Count -eq 0) { + return [PSCustomObject]@{ + HasData = $false + Note = 'No shared resources matched the supplied sharedResourceIds / sharedResourceGroup in the scanned subscriptions.' + } + } + + $sharedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($p in $pool) { [void]$sharedIds.Add($p.Id) } + $hubSubs = @($pool | ForEach-Object { $_.SubscriptionId } | Select-Object -Unique) + + Write-Host " Reading cost (pool + spokes)..." -ForegroundColor Cyan + $costSubs = @($hubSubs + $Spokes | Select-Object -Unique) + $maps = Get-AllocationCostMaps -SubscriptionIds $costSubs -HubData $HubData + + # -- Size the shared pool from billed cost ---------------------------- + $poolTotal = 0.0 + $poolResources = @() + foreach ($p in $pool) { + $c = if ($maps.ByResource.ContainsKey($p.Id)) { [double]$maps.ByResource[$p.Id] } else { 0.0 } + $poolTotal += $c + $poolResources += [PSCustomObject]@{ + Name = $p.Name + Type = $p.Type + Cost = [math]::Round($c, 2) + } + } + + # -- Build the weighting vector --------------------------------------- + Write-Host " Building weighting ($WeightingMethod)..." -ForegroundColor Cyan + $weights = Get-SpokeWeighting -Spokes $Spokes -Method $WeightingMethod -Values $WeightingValues -SubscriptionIds $scanSubs -WorkspaceId $WorkspaceId -LookbackDays $LookbackDays -Query $WeightingQuery + $totalWeight = 0.0 + foreach ($w in $weights.Values) { $totalWeight += [double]$w } + + # -- Allocate ---------------------------------------------------------- + $nSpokes = $Spokes.Count + $fixedTotal = $poolTotal * $FixedRatio + $varTotal = $poolTotal * (1 - $FixedRatio) + + $allocations = @() + foreach ($s in $Spokes) { + $w = [double]$weights[$s] + $allocFixed = if ($nSpokes -gt 0) { $fixedTotal / $nSpokes } else { 0 } + $allocVar = if ($totalWeight -gt 0) { $varTotal * ($w / $totalWeight) } elseif ($nSpokes -gt 0) { $varTotal / $nSpokes } else { 0 } + $allocShared = $allocFixed + $allocVar + $ownCost = if ($maps.BySub.ContainsKey($s)) { [double]$maps.BySub[$s] } else { 0.0 } + + $allocations += [PSCustomObject]@{ + Spoke = $s + WeightUnits = [math]::Round($w, 2) + WeightPct = if ($totalWeight -gt 0) { [math]::Round(($w / $totalWeight) * 100, 1) } else { [math]::Round(100.0 / [math]::Max($nSpokes, 1), 1) } + AllocatedFixed = [math]::Round($allocFixed, 2) + AllocatedVar = [math]::Round($allocVar, 2) + AllocatedShared = [math]::Round($allocShared, 2) + OwnCost = [math]::Round($ownCost, 2) + SolutionCost = [math]::Round($ownCost + $allocShared, 2) + } + } + $allocations = @($allocations | Sort-Object SolutionCost -Descending) + + $notes = @() + if ($WeightingMethod -in @('equal', 'resourceCount')) { + $notes += "Weighting '$WeightingMethod' is a proxy estimate, not metered transfer. Use weightingMethod=trafficAnalytics (with workspaceId) or supply inline GB/TB per spoke for an exact split." + } + elseif ($totalWeight -le 0) { + if ($WeightingMethod -eq 'trafficAnalytics') { + $notes += 'Traffic Analytics returned no GB for any spoke; variable cost was split evenly. Verify the workspace has flow logs/Traffic Analytics enabled and that the query returns Spoke + GB columns.' + } + else { + $notes += 'No inline weighting values resolved for any spoke; variable cost was split evenly. Provide weightingValues keyed by spoke subscription id.' + } + } + if ($maps.Source -eq 'LiveApi') { + $notes += 'Costs are live Cost Management actuals (month-to-date). Per-spoke ExpressRoute attribution cannot come from billing - it relies on the weighting key.' + } + + # Ready-to-paste targets for set_cost_allocation_rule: each spoke's share of + # the whole pool as a whole percentage, already normalized to sum 100.00. + $ruleTargets = @() + if ($poolTotal -gt 0 -and @($allocations).Count -gt 0) { + $rtInput = @($allocations | ForEach-Object { @{ subscriptionId = $_.Spoke; allocatedShared = $_.AllocatedShared } }) + $rt = ConvertTo-AllocationPercentages -Targets $rtInput -TargetDimension 'SubscriptionId' + if ($rt.Ok) { + $ruleTargets = @($rt.Values | ForEach-Object { [PSCustomObject]@{ subscriptionId = $_.name; percentage = $_.percentage } }) + $notes += 'RuleTargets is ready to paste straight into set_cost_allocation_rule (targets); percentages already sum to 100.' + } + } + + return [PSCustomObject]@{ + HasData = $true + Period = 'MonthToDate' + Source = $maps.Source + Currency = $maps.Currency + WeightingMethod = $WeightingMethod + FixedRatio = $FixedRatio + SharedPool = [PSCustomObject]@{ + TotalCost = [math]::Round($poolTotal, 2) + Resources = @($poolResources | Sort-Object Cost -Descending) + } + Allocations = $allocations + RuleTargets = $ruleTargets + Note = ($notes -join ' ') + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 new file mode 100644 index 000000000..25bf6faa9 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 @@ -0,0 +1,123 @@ +########################################################################### +# GET-STORAGETIERADVICE.PS1 +# AZURE FINOPS MULTITOOL - Storage Tier Optimization +########################################################################### +# Purpose: Identify storage accounts with hot-tier blob containers that +# have not been accessed recently and would benefit from moving +# to Cool or Archive tier to reduce costs. +########################################################################### + +function Get-StorageTierAdvice { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + Write-Host " Scanning storage tier optimization opportunities..." -ForegroundColor Cyan + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $results = [System.Collections.Generic.List[PSCustomObject]]::new() + + # -- 1: Find all storage accounts on Hot default tier ----------------- + try { + $query = @" +resources +| where type =~ 'microsoft.storage/storageaccounts' +| where properties.accessTier =~ 'Hot' or isnull(properties.accessTier) +| project name, resourceGroup, subscriptionId, location, + kind, sku = sku.name, + accessTier = tostring(properties.accessTier), + creationTime = properties.creationTime, + blobCount = properties.primaryEndpoints.blob +"@ + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 + $hotAccounts = if ($result) { @($result.Data) } else { @() } + Write-Host " Hot-tier storage accounts: $($hotAccounts.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Storage account query failed: $($_.Exception.Message)" + $hotAccounts = @() + } + + # -- 2: For each hot account, check last access metrics --------------- + $token = (Get-AzAccessToken -ResourceUrl 'https://management.azure.com').Token + $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } + $now = (Get-Date).ToUniversalTime() + $thirtyDaysAgo = $now.AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ') + $nowStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ') + + foreach ($sa in $hotAccounts) { + $scope = "/subscriptions/$($sa.subscriptionId)/resourceGroups/$($sa.resourceGroup)/providers/Microsoft.Storage/storageAccounts/$($sa.name)" + try { + # Query transaction count (Blob service) over last 30 days + $metricUri = "https://management.azure.com$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Transactions×pan=$thirtyDaysAgo/$nowStr&aggregation=Total&interval=P30D" + $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop + $metricData = ($resp.Content | ConvertFrom-Json) + + $totalTx = 0 + if ($metricData.value -and $metricData.value.Count -gt 0) { + foreach ($ts in $metricData.value[0].timeseries) { + foreach ($dp in $ts.data) { + if ($dp.total) { $totalTx += $dp.total } + } + } + } + + # Also query used capacity + $capacityUri = "https://management.azure.com$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=BlobCapacity×pan=$thirtyDaysAgo/$nowStr&aggregation=Average&interval=P30D" + $capResp = Invoke-WebRequest -Uri $capacityUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction SilentlyContinue + $capacityBytes = 0 + if ($capResp) { + $capData = ($capResp.Content | ConvertFrom-Json) + if ($capData.value -and $capData.value.Count -gt 0) { + foreach ($ts in $capData.value[0].timeseries) { + foreach ($dp in $ts.data) { + if ($dp.average -and $dp.average -gt $capacityBytes) { $capacityBytes = $dp.average } + } + } + } + } + + $capacityGB = [math]::Round($capacityBytes / 1GB, 2) + $recommendation = $null + $estSavingsPct = 0 + + if ($totalTx -eq 0 -and $capacityGB -gt 0) { + $recommendation = 'Archive' + $estSavingsPct = 90 + } elseif ($totalTx -lt 100 -and $capacityGB -gt 0) { + $recommendation = 'Archive' + $estSavingsPct = 90 + } elseif ($totalTx -lt 1000 -and $capacityGB -gt 1) { + $recommendation = 'Cool' + $estSavingsPct = 50 + } + + if ($recommendation) { + [void]$results.Add([PSCustomObject]@{ + StorageAccount = $sa.name + ResourceGroup = $sa.resourceGroup + SubscriptionId = $sa.subscriptionId + Location = $sa.location + CurrentTier = if ($sa.accessTier) { $sa.accessTier } else { 'Hot (default)' } + SKU = $sa.sku + CapacityGB = $capacityGB + Transactions30d = $totalTx + Recommendation = $recommendation + EstSavingsPct = $estSavingsPct + }) + } + } catch { + # Metrics not available (classic account, no blob service, etc.) — skip + } + } + + Write-Host " Storage tier recommendations: $($results.Count)" -ForegroundColor Gray + + [PSCustomObject]@{ + Recommendations = @($results) + TotalHotAccounts = $hotAccounts.Count + Count = $results.Count + HasData = ($results.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 new file mode 100644 index 000000000..2f9849a77 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 @@ -0,0 +1,240 @@ +########################################################################### +# GET-TAGINVENTORY.PS1 +# AZURE FINOPS MULTITOOL - Tag Inventory Across the Tenant +########################################################################### +# Purpose: Use Azure Resource Graph to discover every tag name and value +# in use across all subscriptions, along with resource counts +# and resource types per tag. +# +# This is the "Understand" FinOps pillar - you can't allocate costs you +# can't see, and untagged resources are invisible to chargeback. +########################################################################### + +function Get-TagInventory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + + # -- Query 1: Tag names, values, and counts ------------------------- + try { + Write-Host " Scanning tag inventory via Resource Graph..." -ForegroundColor Cyan + $tagQuery = @" +resources +| union resourcecontainers +| mvexpand tags +| extend tagName = tostring(bag_keys(tags)[0]) +| extend tagValue = tostring(tags[tagName]) +| where isnotempty(tagName) +| summarize ResourceCount = count(), ResourceTypes = make_set(type) by tagName, tagValue +| order by tagName asc, ResourceCount desc +"@ + + $allResults = @() + $skipToken = $null + + do { + $result = Search-AzGraphSafe -Query $tagQuery -Subscription $subIds -First 1000 -SkipToken $skipToken + if (-not $result) { break } + $allResults += $result.Data + $skipToken = $result.SkipToken + } while ($skipToken) + + } catch { + Write-Warning "Tag inventory query failed: $($_.Exception.Message)" + $allResults = @() + } + + # -- Query 2: Untagged resource count (via REST to avoid runspace issues with single-row aggregates) + $untaggedCount = 0 + try { + $countBody = @{ + subscriptions = @($subIds) + query = "resources | where isnull(tags) or tags == '{}' | summarize UntaggedCount = count()" + options = @{ resultFormat = 'objectArray' } + } | ConvertTo-Json -Depth 5 + $countResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $countBody + if ($countResp.StatusCode -eq 200) { + $countRows = @(($countResp.Content | ConvertFrom-Json).data) + if ($countRows.Count -gt 0) { + $untaggedCount = [int]$countRows[0].UntaggedCount + } + } + } catch { + Write-Warning "Untagged resource count failed: $($_.Exception.Message)" + } + + # -- Query 4: Untagged resource details (paginate all) ---------------- + $untaggedResources = @() + try { + $untaggedDetailQuery = @" +resources +| where isnull(tags) or tags == '{}' +| project name, type, resourceGroup, subscriptionId, location +| order by type asc, name asc +"@ + $allUntagged = @() + $udSkipToken = $null + do { + $udResult = Search-AzGraphSafe -Query $untaggedDetailQuery -Subscription $subIds -First 1000 -SkipToken $udSkipToken + if (-not $udResult -or -not $udResult.Data) { break } + $allUntagged += $udResult.Data + $udSkipToken = $udResult.SkipToken + if ($allUntagged.Count % 2000 -eq 0) { + Write-Host " Loaded $($allUntagged.Count) untagged resources so far..." -ForegroundColor Gray + } + } while ($udSkipToken) + + if ($allUntagged.Count -gt 0) { + # Map subscription IDs to names + $subNameMap = @{} + foreach ($s in $Subscriptions) { $subNameMap[$s.Id] = $s.Name } + $untaggedResources = @($allUntagged | ForEach-Object { + [PSCustomObject]@{ + ResourceName = $_.name + ResourceType = $_.type + ResourceGroup = $_.resourceGroup + Subscription = if ($subNameMap.ContainsKey($_.subscriptionId)) { $subNameMap[$_.subscriptionId] } else { $_.subscriptionId } + Location = $_.location + } + }) + Write-Host " Total untagged resources loaded: $($untaggedResources.Count)" -ForegroundColor Cyan + } + } catch { + Write-Warning "Untagged resource detail query failed: $($_.Exception.Message)" + } + + # -- Query 3: Total resource count (via REST to avoid runspace issues with single-row aggregates) + $totalCount = 0 + try { + $totalBody = @{ + subscriptions = @($subIds) + query = "resources | summarize TotalCount = count()" + options = @{ resultFormat = 'objectArray' } + } | ConvertTo-Json -Depth 5 + $totalResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $totalBody + if ($totalResp.StatusCode -eq 200) { + $totalRows = @(($totalResp.Content | ConvertFrom-Json).data) + if ($totalRows.Count -gt 0) { + $totalCount = [int]$totalRows[0].TotalCount + } + } + } catch { + Write-Warning "Total resource count failed: $($_.Exception.Message)" + } + + # Fallback: the REST count endpoint returns table-format results in some + # tenants (no objectArray rows), leaving the count at 0. Re-derive via the + # Resource Graph cmdlet path, which is reliable where the tag query works. + if ($totalCount -eq 0) { + try { + $tcResult = Search-AzGraphSafe -Query "resources | summarize TotalCount = count()" -Subscription $subIds -First 1 + $tcRows = if ($tcResult) { @($tcResult.Data) } else { @() } + if ($tcRows.Count -gt 0 -and $null -ne $tcRows[0].TotalCount) { + $totalCount = [int]$tcRows[0].TotalCount + } + } catch { } + } + + # Fallback: derive counts from detail data if REST queries failed + if ($untaggedCount -eq 0 -and $untaggedResources.Count -gt 0) { + $untaggedCount = $untaggedResources.Count + Write-Host " Using detail query count as fallback: $untaggedCount untagged" -ForegroundColor Yellow + } + + # -- Build summary -------------------------------------------------- + $tagNames = @{} + foreach ($row in $allResults) { + $name = $row.tagName + if (-not $tagNames.ContainsKey($name)) { + $tagNames[$name] = @{ Values = @(); TotalResources = 0 } + } + $tagNames[$name].Values += [PSCustomObject]@{ + Value = $row.tagValue + ResourceCount = $row.ResourceCount + ResourceTypes = $row.ResourceTypes + } + $tagNames[$name].TotalResources += $row.ResourceCount + } + + # -- Query 5: Tag locations (which subscriptions + RGs each tag is on) + $tagLocations = @{} + try { + $subNameMap = @{} + foreach ($s in $Subscriptions) { $subNameMap[$s.Id] = $s.Name } + + $locQuery = @" +resources +| union resourcecontainers +| mvexpand tags +| extend tagName = tostring(bag_keys(tags)[0]) +| where isnotempty(tagName) +| summarize ResourceCount = count() by tagName, subscriptionId, resourceGroup +| order by tagName asc, ResourceCount desc +"@ + $locResults = @() + $locSkip = $null + do { + $locResult = Search-AzGraphSafe -Query $locQuery -Subscription $subIds -First 1000 -SkipToken $locSkip + if (-not $locResult) { break } + $locResults += $locResult.Data + $locSkip = $locResult.SkipToken + } while ($locSkip) + + foreach ($row in $locResults) { + $name = $row.tagName + if (-not $tagLocations.ContainsKey($name)) { + $tagLocations[$name] = [System.Collections.Generic.List[string]]::new() + } + $subName = if ($subNameMap.ContainsKey($row.subscriptionId)) { $subNameMap[$row.subscriptionId] } else { $row.subscriptionId } + $loc = "$subName / $($row.resourceGroup)" + if ($loc -notin $tagLocations[$name]) { + [void]$tagLocations[$name].Add($loc) + } + } + } catch { + Write-Warning "Tag location query failed: $($_.Exception.Message)" + } + + # Last-resort fallback: when the dedicated total query is unavailable (the + # REST endpoint returned no rows and the cmdlet path also came up empty), + # derive the total from the tagged + untagged populations so coverage is + # still meaningful instead of showing 0 tagged / 0 untagged. + if ($totalCount -eq 0) { + $taggedFromArg = 0 + try { + $tagCountBody = @{ + subscriptions = @($subIds) + query = "resources | where isnotnull(tags) and tags != '{}' | summarize TaggedCount = count()" + options = @{ resultFormat = 'objectArray' } + } | ConvertTo-Json -Depth 5 + $tagCountResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $tagCountBody + if ($tagCountResp.StatusCode -eq 200) { + $tagCountRows = @(($tagCountResp.Content | ConvertFrom-Json).data) + if ($tagCountRows.Count -gt 0) { $taggedFromArg = [int]$tagCountRows[0].TaggedCount } + } + } catch { } + if ($untaggedCount -eq 0 -and $untaggedResources.Count -gt 0) { $untaggedCount = $untaggedResources.Count } + if (($taggedFromArg + $untaggedCount) -gt 0) { + $totalCount = $taggedFromArg + $untaggedCount + } + } + + $taggedCount = [math]::Max(0, $totalCount - $untaggedCount) + $tagCoverage = if ($totalCount -gt 0) { [math]::Round(($taggedCount / $totalCount) * 100, 1) } else { 0 } + + return [PSCustomObject]@{ + TagNames = $tagNames + TagCount = $tagNames.Count + TagLocations = $tagLocations + TotalResources = $totalCount + TaggedCount = $taggedCount + UntaggedCount = $untaggedCount + TagCoverage = $tagCoverage + UntaggedResources = $untaggedResources + RawResults = $allResults + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 new file mode 100644 index 000000000..54e776c99 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 @@ -0,0 +1,170 @@ +########################################################################### +# GET-TAGRECOMMENDATIONS.PS1 +# AZURE FINOPS MULTITOOL - Tag Recommendations (MS Best Practices) +########################################################################### +# Purpose: Compare the customer's actual tags against Microsoft's +# recommended tagging strategy from the Cloud Adoption Framework. +# +# Reference: +# https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging +# https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/govern/guides/standard/prescriptive-guidance#resource-tagging +########################################################################### + +function Get-TagRecommendations { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$ExistingTags, # Keys = tag names currently in use + + [hashtable]$TagLocations = @{} # Keys = tag names, Values = list of "Sub / RG" strings + ) + + # Microsoft Cloud Adoption Framework recommended tags for FinOps allocation + # These 7 tags map directly to CAF categories and FinOps allocation needs + $recommendedTags = @( + [PSCustomObject]@{ + TagName = 'CostCenter' + Category = 'Accounting' + Purpose = 'Financial allocation - maps resources to internal cost centers for chargeback/showback' + Pillar = 'Understand' + Priority = 'Required' + Weight = 3 + Example = 'CostCenter: CC-12345' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'BusinessUnit' + Category = 'Ownership' + Purpose = 'Org-level chargeback - enables showback/chargeback at department level' + Pillar = 'Understand' + Priority = 'Required' + Weight = 3 + Example = 'BusinessUnit: Finance | Engineering | Marketing' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'ApplicationName' + Category = 'Functional' + Purpose = 'Product/service cost mapping - groups resources by the application they support' + Pillar = 'Understand' + Priority = 'Required' + Weight = 2 + Example = 'ApplicationName: HRPortal | ERP | WebFrontend' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'WorkloadName' + Category = 'Functional' + Purpose = 'Workload attribution - identifies the workload a resource belongs to' + Pillar = 'Understand' + Priority = 'Required' + Weight = 1 + Example = 'WorkloadName: PaymentProcessing | DataPipeline' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'OpsTeam' + Category = 'Ownership' + Purpose = 'Accountability for spend - which team owns and operates the resource' + Pillar = 'Understand' + Priority = 'Required' + Weight = 1 + Example = 'OpsTeam: Platform-Infra | App-TeamA | SRE' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'Criticality' + Category = 'Classification' + Purpose = 'Prioritization of spend - business impact level drives optimization boundaries' + Pillar = 'Optimize' + Priority = 'Required' + Weight = 1 + Example = 'Criticality: Mission-Critical | Business-Critical | Low' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'DataClassification' + Category = 'Classification' + Purpose = 'Compliance-driven allocation - data sensitivity determines governance requirements' + Pillar = 'Understand' + Priority = 'Required' + Weight = 1 + Example = 'DataClassification: Confidential | Public | Internal' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + ) + + # Check which recommended tags are present / missing + $existingNames = $ExistingTags.Keys | ForEach-Object { $_.ToLower() } + + $analysis = foreach ($rec in $recommendedTags) { + $found = $existingNames -contains $rec.TagName.ToLower() + + # Also check common variations + $variations = switch ($rec.TagName) { + 'CostCenter' { @('cost-center', 'costcenter', 'cost_center', 'cc') } + 'BusinessUnit' { @('bu', 'businessunit', 'business-unit', 'department', 'dept') } + 'ApplicationName' { @('applicationname', 'application', 'app', 'appname', 'app-name', 'workload') } + 'WorkloadName' { @('workloadname', 'workload', 'workload-name', 'workload_name') } + 'OpsTeam' { @('opsteam', 'ops-team', 'ops_team', 'operationsteam', 'team', 'owner', 'technicalowner') } + 'Criticality' { @('criticality', 'sla', 'tier', 'importance') } + 'DataClassification' { @('dataclassification', 'data-classification', 'data_classification', 'classification') } + default { @() } + } + $foundVariation = $existingNames | Where-Object { $_ -in $variations } | Select-Object -First 1 + + $status = if ($found) { 'Present' } + elseif ($foundVariation) { "Variation found: $foundVariation" } + else { 'Missing' } + + # Build location string from TagLocations + $matchedName = if ($found) { $rec.TagName } + elseif ($foundVariation) { $foundVariation } + else { $null } + + # Resolve original-case tag name from ExistingTags for accurate removal + $actualTagName = $null + if ($matchedName) { + $actualTagName = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $matchedName.ToLower() } | Select-Object -First 1 + if (-not $actualTagName) { $actualTagName = $matchedName } + } + + $locationStr = '' + if ($matchedName) { + # Case-insensitive lookup in TagLocations hashtable + $locKey = $TagLocations.Keys | Where-Object { $_.ToLower() -eq $matchedName.ToLower() } | Select-Object -First 1 + if ($locKey -and $TagLocations[$locKey]) { + $locs = @($TagLocations[$locKey]) + if ($locs.Count -le 3) { + $locationStr = $locs -join '; ' + } else { + $locationStr = ($locs[0..2] -join '; ') + " (+$($locs.Count - 3) more)" + } + } + } + + [PSCustomObject]@{ + TagName = $rec.TagName + ActualTagName = $actualTagName + Status = $status + Priority = $rec.Priority + Pillar = $rec.Pillar + Purpose = $rec.Purpose + Location = $locationStr + Example = $rec.Example + Reference = $rec.Reference + } + } + + $missingRequired = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Required' }) + $missingRecommended = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Recommended' }) + $present = @($analysis | Where-Object { $_.Status -ne 'Missing' }) + + return [PSCustomObject]@{ + Analysis = $analysis + MissingRequired = $missingRequired + MissingRecommended = $missingRecommended + Present = $present + CompliancePercent = [math]::Round(($present.Count / $analysis.Count) * 100, 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 new file mode 100644 index 000000000..c536c6ede --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 @@ -0,0 +1,123 @@ +########################################################################### +# GET-TENANTHIERARCHY.PS1 +# AZURE FINOPS MULTITOOL - Management Group & Subscription Hierarchy +########################################################################### +# Purpose: Retrieve the full management group tree with subscriptions +# nested under their parent groups. +########################################################################### + +function Get-TenantHierarchy { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [int]$TimeoutSeconds = 60 + ) + + try { + # Run in a background runspace with timeout to prevent UI freeze + $rs = [runspacefactory]::CreateRunspace() + $rs.Open() + $ps = [powershell]::Create() + $ps.Runspace = $rs + [void]$ps.AddScript({ + param($tid) + Get-AzManagementGroup -GroupId $tid -Expand -Recurse -ErrorAction Stop + }).AddArgument($TenantId) + + $asyncResult = $ps.BeginInvoke() + + # Wait for runspace — uses DispatcherFrame when WPF is loaded, else Start-Sleep + Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds + + if ($asyncResult.IsCompleted) { + $rootGroup = $ps.EndInvoke($asyncResult) + if ($ps.Streams.Error.Count -gt 0) { + throw $ps.Streams.Error[0].Exception + } + $ps.Dispose(); $rs.Close() + + if ($rootGroup) { + $actual = if ($rootGroup -is [array]) { $rootGroup[0] } else { $rootGroup } + $subMap = @{} + Build-SubMap -Group $actual -Map ([ref]$subMap) + return [PSCustomObject]@{ + RootGroup = $actual + SubscriptionMap = $subMap + } + } + } + else { + # Timed out — stop and fall through to fallback + $ps.Stop() + $ps.Dispose(); $rs.Close() + Write-Warning "Management group hierarchy timed out after $TimeoutSeconds seconds. Using flat subscription list." + } + + # Fallback + $subs = if ($Subscriptions) { @($Subscriptions) } else { + @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' }) + } + $fallbackRoot = [PSCustomObject]@{ + DisplayName = "Tenant Root" + Name = $TenantId + Children = @() + } + return [PSCustomObject]@{ + RootGroup = $fallbackRoot + SubscriptionMap = @{} + FlatSubs = $subs + } + } + catch { + # Lacking Microsoft.Management read/register access is expected for many + # scopes — fall back to a flat subscription list quietly instead of + # dumping a wall of authorization noise to the console. + $errMsg = $_.Exception.Message + if ($errMsg -match 'does not have authorization|Authorization_RequestDenied|register/action|RBACAccessDenied|Forbidden|\(403\)') { + Write-Verbose "Management group hierarchy not accessible (insufficient permissions); using flat subscription list." + } + else { + Write-Verbose "Management group hierarchy unavailable; using flat subscription list. ($errMsg)" + } + + $subs = if ($Subscriptions) { @($Subscriptions) } else { + @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' }) + } + $fallbackRoot = [PSCustomObject]@{ + DisplayName = "Tenant Root" + Name = $TenantId + Children = @() + } + + return [PSCustomObject]@{ + RootGroup = $fallbackRoot + SubscriptionMap = @{} + FlatSubs = $subs + } + } +} + +function Build-SubMap { + param( + [object]$Group, + [ref]$Map + ) + + if ($Group.Children) { + foreach ($child in $Group.Children) { + if ($child.Type -eq '/subscriptions') { + $Map.Value[$child.Name] = $Group.DisplayName + } + elseif ($child.Children) { + Build-SubMap -Group $child -Map $Map + } + } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 new file mode 100644 index 000000000..e70c00396 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -0,0 +1,378 @@ +########################################################################### +# GET-UNITECONOMICS.PS1 +# AZURE FINOPS MULTITOOL - Unit Economics ($/vCPU, $/GB) +########################################################################### +# Purpose: Compute FinOps unit-economics KPIs by dividing amortized cost +# by provisioned capacity: cost per vCPU (compute) and cost per +# GB (storage). Combines Cost Management amortized spend with +# Azure Resource Graph capacity counts. +########################################################################### +# Notes: +# - RBAC: Cost Management Reader (billing/MG scope) + Reader (ARG). +# - vCPU and RAM are exact when the Compute SKU capability lookup succeeds +# (authoritative vCPUs/MemoryGB per size); they fall back to a VM-size-name +# heuristic only when the SKUs API is unavailable for a region. +# - Storage GB combines provisioned managed-disk size (Resource Graph) and +# Storage-account used capacity (Azure Monitor UsedCapacity metric, one +# call per account); failures fall back to disk-only with a note. +# - Costs are month-to-date amortized, scoped to the selected subscriptions, +# grouped by meter category, with a per-subscription fallback when the +# management-group scope is not accessible. +########################################################################### + +# -- Helper: exact vCPU/RAM from Compute SKU capabilities ----------------- +# The Resource Graph 'resources' table does not expose vCPU/memory, so we +# query the Compute SKUs API per region (cached by location, follows +# nextLink) and build a vmSize -> {vCPU, MemGb} map. Returns $null when the +# size is not found so the caller can fall back to a name heuristic. +function Get-VmSizeCapability { + param( + [string]$SubId, + [string]$Location, + [string]$VmSize, + [hashtable]$Cache + ) + if (-not $Location -or -not $SubId) { return $null } + if (-not $Cache.ContainsKey($Location)) { + $map = @{} + try { + $next = "/subscriptions/$SubId/providers/Microsoft.Compute/skus?api-version=2021-07-01&`$filter=location eq '$Location'" + $pages = 0 + while ($next -and $pages -lt 6) { + $resp = Invoke-AzRestMethodWithRetry -Path $next -Method GET + if (-not $resp -or $resp.StatusCode -ne 200 -or -not $resp.Content) { break } + $json = $resp.Content | ConvertFrom-Json + foreach ($s in @($json.value)) { + if ($s.resourceType -ne 'virtualMachines') { continue } + if (-not $s.name -or $map.ContainsKey($s.name)) { continue } + $vc = 0; $mem = 0.0 + foreach ($c in @($s.capabilities)) { + if ($c.name -eq 'vCPUs') { [void][int]::TryParse([string]$c.value, [ref]$vc) } + elseif ($c.name -eq 'MemoryGB') { [void][double]::TryParse([string]$c.value, [ref]$mem) } + } + $map[$s.name] = @{ VCpu = $vc; MemGb = $mem } + } + $nl = $json.nextLink + $next = if ($nl) { ($nl -replace '^https?://[^/]+', '') } else { $null } + $pages++ + } + } + catch { } + $Cache[$Location] = $map + } + $m = $Cache[$Location] + if ($m -and $m.ContainsKey($VmSize)) { + return [PSCustomObject]@{ VCpu = [int]$m[$VmSize].VCpu; MemGb = [double]$m[$VmSize].MemGb } + } + return $null +} + +# -- Helper: accumulate amortized cost by meter category ------------------ +# Parses a Cost Management query response (grouped by MeterCategory) and adds +# compute vs storage spend into the supplied references. Returns $true when +# the response contained any rows (i.e. the query succeeded with data). +function Add-MeterCosts { + param( + [string]$Content, + [ref]$ComputeRef, + [ref]$StorageRef, + [ref]$CurrencyRef + ) + $any = $false + try { + $data = $Content | ConvertFrom-Json + if ($data.properties.rows) { + foreach ($row in $data.properties.rows) { + $any = $true + $amount = [double]$row[0] + $category = [string]$row[1] + if ($row.Count -ge 3 -and $row[2]) { $CurrencyRef.Value = [string]$row[2] } + switch -Wildcard ($category) { + 'Virtual Machines*' { $ComputeRef.Value += $amount } + 'Storage*' { $StorageRef.Value += $amount } + default { } + } + } + } + } + catch { } + return $any +} + +# -- Helper: storage account used capacity (blob/file/queue/table) -------- +# Managed disks are captured separately via Resource Graph. This adds the +# data-plane used capacity of Storage accounts via the Azure Monitor metrics +# API (UsedCapacity, account-level, emitted once per day). One metrics call +# per account; a failed account is skipped so it never breaks the scan. +# Returns used GB, or $null when the account inventory could not be read. +function Get-StorageAccountUsedGb { + param([string[]]$SubIds) + + $accounts = @() + try { + $saQuery = @" +resources +| where type =~ 'microsoft.storage/storageaccounts' +| project id +"@ + $result = Search-AzGraphSafe -Query $saQuery -Subscription $SubIds -First 1000 + $accounts = if ($result) { @($result.Data) } else { @() } + } + catch { + Write-Warning " Storage account inventory query failed: $($_.Exception.Message)" + return $null + } + + if ($accounts.Count -eq 0) { return 0.0 } + + # UsedCapacity is a daily metric; a 2-day window guarantees a data point. + $end = (Get-Date).ToUniversalTime() + $start = $end.AddDays(-2) + $timespan = "$($start.ToString('yyyy-MM-ddTHH:mm:ssZ'))/$($end.ToString('yyyy-MM-ddTHH:mm:ssZ'))" + + $totalBytes = 0.0 + $queried = 0 + foreach ($a in $accounts) { + if ($queried -ge 500) { break } # safety bound on call fan-out + $queried++ + try { + $path = "$($a.id)/providers/Microsoft.Insights/metrics?api-version=2018-01-01&metricnames=UsedCapacity&aggregation=Average&interval=P1D×pan=$timespan" + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method GET + if (-not $resp -or $resp.StatusCode -ne 200 -or -not $resp.Content) { continue } + $json = $resp.Content | ConvertFrom-Json + foreach ($metric in @($json.value)) { + foreach ($ts in @($metric.timeseries)) { + $points = @($ts.data) | Where-Object { $null -ne $_.average } + if ($points.Count -gt 0) { $totalBytes += [double]$points[-1].average } + } + } + } + catch { } + } + return [math]::Round($totalBytes / 1GB, 1) +} + +function Get-UnitEconomics { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions + ) + + Write-Host " Computing unit economics (per vCPU, per GB RAM, per GB disk)..." -ForegroundColor Cyan + + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + + # -- 1: Provisioned compute capacity (exact vCPU + RAM via Compute SKUs) -- + $totalVCpu = 0 + $totalMemGb = 0.0 + $vmCount = 0 + $vcpuExact = $true + $skuCache = @{} + try { + $vmQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| extend vmSize = tostring(properties.hardwareProfile.vmSize), loc = tostring(location), subId = tostring(subscriptionId) +| summarize cnt = count() by vmSize, loc, subId +"@ + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + $count = [int]$r.cnt + $vmCount += $count + $caps = Get-VmSizeCapability -SubId $r.subId -Location $r.loc -VmSize $r.vmSize -Cache $skuCache + if ($caps -and $caps.VCpu -gt 0) { + $totalVCpu += ($caps.VCpu * $count) + $totalMemGb += ($caps.MemGb * $count) + } + else { + # Fall back to the leading core digit in the size name. + $vcpuExact = $false + $cores = 1 + if ([string]$r.vmSize -match '(?i)[A-Z]+(\d+)') { $cores = [int]$matches[1] } + if ($cores -lt 1) { $cores = 1 } + $totalVCpu += ($cores * $count) + } + } + Write-Host " VMs: $vmCount | vCPUs: $totalVCpu | RAM: $([math]::Round($totalMemGb, 0)) GB" -ForegroundColor Gray + } + catch { + Write-Warning " vCPU query failed: $($_.Exception.Message)" + } + + # -- 2: Provisioned storage (managed disk GB) ------------------------- + $diskGb = 0 + try { + $diskQuery = @" +resources +| where type =~ 'microsoft.compute/disks' +| summarize totalGb = sum(toint(properties.diskSizeGB)) +"@ + $result = Search-AzGraphSafe -Query $diskQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + if ($rows.Count -gt 0 -and $null -ne $rows[0].totalGb) { $diskGb = [double]$rows[0].totalGb } + Write-Host " Provisioned disk: $diskGb GB" -ForegroundColor Gray + } + catch { + Write-Warning " Storage capacity query failed: $($_.Exception.Message)" + } + + # -- 2b: Storage account used capacity (blob/file/queue/table) -------- + $blobFileGb = 0.0 + $blobFileOk = $false + try { + $used = Get-StorageAccountUsedGb -SubIds $subIds + if ($null -ne $used) { + $blobFileGb = [double]$used + $blobFileOk = $true + Write-Host " Storage accounts used: $blobFileGb GB" -ForegroundColor Gray + } + } + catch { + Write-Warning " Storage account capacity query failed: $($_.Exception.Message)" + } + + # Total storage denominator = managed disks + storage-account used capacity. + $totalGb = $diskGb + $blobFileGb + + # -- 3: Amortized cost by meter category (sub-scoped, with fallback) -- + $computeCost = 0.0 + $storageCost = 0.0 + $currency = 'USD' + $costOk = $false + $costScope = 'none' + $mgFailed = $false + try { + $mgScopeId = Resolve-CostMgId -TenantId $TenantId + if ($mgScopeId) { + $dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'MeterCategory' }) + } + # Keep the single fast MG-scope call but scope it to the selected + # subscriptions so the cost numerator matches the capacity denominator. + $subFilter = Get-CostSubscriptionFilter -Subscriptions $Subscriptions + if ($subFilter) { $dataset['filter'] = $subFilter } + $body = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = $dataset + } | ConvertTo-Json -Depth 10 + + $path = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + + if ($resp -and $resp.StatusCode -in @(401, 403)) { + $mgFailed = $true + Set-MgCostScopeFailed + } + elseif ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencyRef ([ref]$currency)) { + $costOk = $true + $costScope = "mg:$mgScopeId" + } + } + } + else { + $mgFailed = $true + } + } + catch { + Write-Warning " Amortized cost query failed: $($_.Exception.Message)" + $mgFailed = $true + } + + # Per-subscription fallback when the MG scope is not accessible. This is + # the same pattern Get-CostData uses so unit economics is never silently $0. + if (-not $costOk -and $mgFailed) { + foreach ($sid in $subIds) { + try { + $body = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'MeterCategory' }) + } + } | ConvertTo-Json -Depth 10 + + $path = "/subscriptions/$sid/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencyRef ([ref]$currency)) { + $costOk = $true + $costScope = 'per-sub' + } + } + } + catch { + Write-Warning " Per-sub cost query failed for $sid : $($_.Exception.Message)" + } + } + } + + # -- 4: Derived KPIs -------------------------------------------------- + $costPerVCpu = if ($totalVCpu -gt 0) { [math]::Round($computeCost / $totalVCpu, 2) } else { 0 } + $costPerVm = if ($vmCount -gt 0) { [math]::Round($computeCost / $vmCount, 2) } else { 0 } + $costPerGb = if ($totalGb -gt 0) { [math]::Round($storageCost / $totalGb, 4) } else { 0 } + $costPerGbRam = if ($totalMemGb -gt 0) { [math]::Round($computeCost / $totalMemGb, 2) } else { 0 } + + $totalKnown = $computeCost + $storageCost + $computeSharePct = if ($totalKnown -gt 0) { [math]::Round(100 * $computeCost / $totalKnown, 1) } else { 0 } + $storageSharePct = if ($totalKnown -gt 0) { [math]::Round(100 * $storageCost / $totalKnown, 1) } else { 0 } + + $hasData = $costOk -and (($totalVCpu -gt 0) -or ($totalGb -gt 0)) + + # -- 5: Honest diagnostics so $0 is explained, not silent ------------- + $notes = @() + if (-not $costOk) { + $notes += 'No cost data returned - check Cost Management Reader at the management-group or subscription scope.' + } + if ($costOk -and $computeCost -eq 0 -and $vmCount -gt 0) { + $notes += 'VMs found but $0 compute MTD (newly created, deallocated, or fully reservation/savings-plan covered).' + } + if ($totalGb -eq 0 -and $vmCount -gt 0) { + $notes += 'No storage GB found - VMs may use ephemeral OS disks and no Storage accounts hold data.' + } + if (-not $blobFileOk) { + $notes += 'Storage-account used capacity unavailable (metrics not readable) - cost per GB reflects managed disks only.' + } + if (-not $vcpuExact) { + $notes += 'Some vCPU/RAM values approximated from VM size names (SKU capability lookup unavailable for a region).' + } + if ($notes.Count -eq 0) { + $notes += 'vCPU/RAM are exact (Compute SKU capabilities); storage GB combines managed disks and Storage-account used capacity.' + } + + return [PSCustomObject]@{ + HasData = $hasData + Currency = $currency + ComputeCost = [math]::Round($computeCost, 2) + StorageCost = [math]::Round($storageCost, 2) + ComputeSharePct = $computeSharePct + StorageSharePct = $storageSharePct + VmCount = $vmCount + TotalVCpu = $totalVCpu + TotalMemoryGb = [math]::Round($totalMemGb, 0) + DiskGb = [math]::Round($diskGb, 1) + BlobFileGb = [math]::Round($blobFileGb, 1) + TotalStorageGb = [math]::Round($totalGb, 1) + CostPerVCpu = $costPerVCpu + CostPerGbRam = $costPerGbRam + CostPerVm = $costPerVm + CostPerGb = $costPerGb + VCpuExact = $vcpuExact + BlobFileOk = $blobFileOk + CostScope = $costScope + Period = 'MonthToDate' + ScannedSubs = $Subscriptions.Count + Note = ($notes -join ' ') + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 new file mode 100644 index 000000000..085e574eb --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 @@ -0,0 +1,307 @@ +########################################################################### +# GET-USAGEPROPORTIONALALLOCATION.PS1 +# TELEMETRY-KEYED SHOWBACK FOR SHARED PLATFORMS (AKS / APIM / AOAI) +########################################################################### +# Purpose: Split the billed cost of a shared platform across SUB-RESOURCE +# consumers (k8s namespace, APIM product, OpenAI deployment) by a +# usage signal pulled from telemetry - for showback/chargeback. +# Author: Zac Larsen +# Date: Created for FinOps Multitool shared-cost allocation +# +# Description: +# Native Azure cost allocation rules can only key on SubscriptionId, +# ResourceGroupName or Tag. When the consumer you want to charge is a +# Kubernetes namespace, an APIM product/subscription, or an Azure OpenAI +# model deployment, there is NO billing dimension for it - so the only fair +# split is telemetry-driven. This tool reads a usage key from a Log Analytics +# workspace (Container Insights for AKS, workspace-based Application Insights +# for APIM token metrics, Azure Monitor metrics for OpenAI tokens) and +# apportions the shared pool cost accordingly. +# +# SHOWBACK GUARD: these consumers are NOT Azure billing dimensions, so the +# result is Mode=Showback, RuleTargets is always empty, and it can NEVER be +# written via set_cost_allocation_rule. It is for reporting / internal +# chargeback only. +# +# ── Parameters ────────────────────────────────────────────────── +# Preset aksNamespace | apimTokens | openAiTokens | custom +# WorkspaceId Log Analytics workspace GUID holding the telemetry +# SharedResourceIds Resource IDs whose billed cost forms the pool +# SharedResourceGroup Resource group holding the shared platform +# PoolAmount Explicit pool cost (use instead of resolving resources) +# LookbackDays Telemetry window (default 30) +# DimensionName Override the consumer dimension (e.g. APIM 'API ID') +# WeightingQuery Full KQL override - must return columns Consumer, Weight +# +# Prerequisites: +# - Az.OperationalInsights + read on the workspace +# - The platform must emit the telemetry (Container Insights / App Insights +# token metrics / AOAI diagnostic metrics) +# +# Usage: Get-UsageProportionalAllocation -Preset aksNamespace ` +# -SharedResourceGroup rg-aks -WorkspaceId +########################################################################### + +# -- Preset registry: each preset maps a shared platform to a usage key ---- +# Query templates use {lb} (lookback days) and {dim} (dimension name). Each +# query MUST return two columns: Consumer (string) and Weight (number). +function Get-TelemetryPreset { + param([string]$Name) + + $presets = @{ + aksNamespace = @{ + ConsumerDimension = 'KubernetesNamespace' + Source = 'Container Insights (Log Analytics)' + DefaultDimension = 'container.azm.ms/namespace' + Query = @' +InsightsMetrics +| where TimeGenerated >= ago({lb}d) +| where Name in ('cpuUsageNanoCores', 'memoryWorkingSetBytes') +| extend ns = tostring(parse_json(Tags)['container.azm.ms/namespace']) +| where isnotempty(ns) +| summarize cpuCores = avgif(Val, Name == 'cpuUsageNanoCores') / 1000000000.0, memGB = avgif(Val, Name == 'memoryWorkingSetBytes') / 1073741824.0 by ns +| extend Weight = cpuCores + (memGB / 4.0) +| project Consumer = ns, Weight +'@ + } + apimTokens = @{ + ConsumerDimension = 'ApimConsumer' + Source = 'Application Insights (workspace-based)' + DefaultDimension = 'Subscription Id' + Query = @' +AppMetrics +| where TimeGenerated >= ago({lb}d) +| where Name in ('Total Tokens', 'Tokens', 'TotalTokens', 'total_tokens') +| extend consumer = tostring(Properties['{dim}']) +| where isnotempty(consumer) +| summarize Weight = sum(Sum) by Consumer = consumer +'@ + } + openAiTokens = @{ + ConsumerDimension = 'OpenAIResource' + Source = 'Azure Monitor metrics (Cognitive Services)' + DefaultDimension = 'Resource' + Query = @' +AzureMetrics +| where TimeGenerated >= ago({lb}d) +| where ResourceProvider == 'MICROSOFT.COGNITIVESERVICES' +| where MetricName in ('ProcessedPromptTokens', 'GeneratedTokens', 'TokenTransaction', 'ProcessedInferenceTokens') +| summarize Weight = sum(Total) by Consumer = Resource +'@ + } + } + + if ($presets.ContainsKey($Name)) { return $presets[$Name] } + return $null +} + +# -- Generalized telemetry weighting provider ------------------------------ +function Get-TelemetryWeighting { + param( + [string]$WorkspaceId, + [string]$Preset = 'aksNamespace', + [int]$LookbackDays = 30, + [string]$Query, + [string]$DimensionName + ) + + $weights = @{} + $lb = if ($LookbackDays -gt 0) { $LookbackDays } else { 30 } + + $def = Get-TelemetryPreset -Name $Preset + if (-not $def -and -not $Query) { + return [PSCustomObject]@{ + Ok = $false + ConsumerDimension = 'Unknown' + Source = 'Unknown' + BillingWritable = $false + Weights = $weights + QueryUsed = $null + Note = "Unknown preset '$Preset'. Use aksNamespace, apimTokens, openAiTokens, or supply a weightingQuery returning Consumer, Weight." + } + } + + $dimName = if ($DimensionName) { $DimensionName } elseif ($def) { $def.DefaultDimension } else { '' } + $kql = if ($Query) { $Query } else { $def.Query } + $kql = $kql.Replace('{lb}', "$lb").Replace('{dim}', $dimName) + + $consumerDim = if ($def) { $def.ConsumerDimension } else { 'Custom' } + $source = if ($def) { $def.Source } else { 'Custom query (Log Analytics)' } + + if (-not $WorkspaceId) { + return [PSCustomObject]@{ + Ok = $false + ConsumerDimension = $consumerDim + Source = $source + BillingWritable = $false + Weights = $weights + QueryUsed = $kql + Note = 'workspaceId (the Log Analytics workspace GUID) is required to read telemetry.' + } + } + + $note = '' + try { + $qr = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $kql -ErrorAction Stop + foreach ($row in @($qr.Results)) { + $c = [string]$row.Consumer + if (-not $c) { continue } + $w = 0.0; [double]::TryParse([string]$row.Weight, [ref]$w) | Out-Null + if ($w -gt 0) { $weights[$c] = $w } + } + if ($weights.Count -eq 0) { + $note = 'Telemetry query returned no rows. Verify the workspace has the expected data and that the query returns Consumer + Weight columns.' + } + } + catch { + $note = "Telemetry query failed: $($_.Exception.Message)" + } + + return [PSCustomObject]@{ + Ok = ($weights.Count -gt 0) + ConsumerDimension = $consumerDim + Source = $source + BillingWritable = $false + Weights = $weights + QueryUsed = $kql + Note = $note + } +} + +# -- Main: telemetry-keyed showback split of a shared pool ----------------- +function Get-UsageProportionalAllocation { + [CmdletBinding()] + param( + [Parameter()] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [string[]]$SharedResourceIds, + + [Parameter()] + [string]$SharedResourceGroup, + + [Parameter()] + [double]$PoolAmount = 0, + + [Parameter()] + [string]$Preset = 'aksNamespace', + + [Parameter()] + [string]$WorkspaceId, + + [Parameter()] + [int]$LookbackDays = 30, + + [Parameter()] + [string]$DimensionName, + + [Parameter()] + [string]$WeightingQuery, + + [Parameter()] + [object[]]$HubData + ) + + if (-not $WorkspaceId) { + return [PSCustomObject]@{ + HasData = $false + Note = 'Provide workspaceId - the Log Analytics workspace GUID that holds the platform telemetry (Container Insights / App Insights).' + } + } + $haveResources = ($SharedResourceIds -and $SharedResourceIds.Count -gt 0) -or $SharedResourceGroup + if (-not $haveResources -and $PoolAmount -le 0) { + return [PSCustomObject]@{ + HasData = $false + Note = 'Provide the shared pool: sharedResourceIds / sharedResourceGroup (cost is resolved), or poolAmount (an explicit cost to split).' + } + } + + $scanSubs = @($Subscriptions | ForEach-Object { $_.Id }) + + # -- Size the shared pool --------------------------------------------- + $poolTotal = 0.0 + $poolResources = @() + $currency = 'USD' + $source = 'Explicit' + + if ($haveResources) { + Write-Host ' Resolving shared platform resources...' -ForegroundColor Cyan + $pool = Resolve-SharedCostPool -SubscriptionIds $scanSubs -ResourceIds $SharedResourceIds -ResourceGroup $SharedResourceGroup + if (@($pool).Count -gt 0) { + $hubSubs = @($pool | ForEach-Object { $_.SubscriptionId } | Select-Object -Unique) + $maps = Get-AllocationCostMaps -SubscriptionIds $hubSubs -HubData $HubData + $currency = $maps.Currency + $source = $maps.Source + foreach ($p in $pool) { + $c = if ($maps.ByResource.ContainsKey($p.Id)) { [double]$maps.ByResource[$p.Id] } else { 0.0 } + $poolTotal += $c + $poolResources += [PSCustomObject]@{ Name = $p.Name; Type = $p.Type; Cost = [math]::Round($c, 2) } + } + } + } + + if ($poolTotal -le 0 -and $PoolAmount -gt 0) { + $poolTotal = $PoolAmount + $source = 'Explicit' + } + + if ($poolTotal -le 0) { + return [PSCustomObject]@{ + HasData = $false + Note = 'The shared pool cost resolved to 0. Check the resource IDs / resource group, or pass poolAmount explicitly.' + } + } + + # -- Telemetry usage key ---------------------------------------------- + Write-Host " Reading telemetry usage ($Preset)..." -ForegroundColor Cyan + $tw = Get-TelemetryWeighting -WorkspaceId $WorkspaceId -Preset $Preset -LookbackDays $LookbackDays -Query $WeightingQuery -DimensionName $DimensionName + + $weights = $tw.Weights + $totalWeight = 0.0 + foreach ($v in $weights.Values) { $totalWeight += [double]$v } + + $allocations = @() + $notes = @() + if ($totalWeight -gt 0) { + foreach ($key in $weights.Keys) { + $w = [double]$weights[$key] + $pct = $w / $totalWeight + $allocations += [PSCustomObject]@{ + Consumer = $key + WeightUnits = [math]::Round($w, 4) + WeightPct = [math]::Round($pct * 100, 2) + AllocatedCost = [math]::Round($poolTotal * $pct, 2) + } + } + $allocations = @($allocations | Sort-Object AllocatedCost -Descending) + } + else { + $notes += 'No usage telemetry resolved, so the pool was not split. ' + $tw.Note + } + + $notes += "Showback only: $($tw.ConsumerDimension) is not an Azure billing dimension, so this allocation CANNOT be written as a native cost allocation rule (set_cost_allocation_rule). Use it for internal chargeback / reporting." + if ($tw.Note -and $totalWeight -gt 0) { $notes += $tw.Note } + + return [PSCustomObject]@{ + HasData = $true + Mode = 'Showback' + BillingWritable = $false + Preset = $Preset + ConsumerDimension = $tw.ConsumerDimension + Source = $tw.Source + CostSource = $source + Period = 'MonthToDate' + Currency = $currency + SharedPool = [PSCustomObject]@{ + TotalCost = [math]::Round($poolTotal, 2) + Resources = @($poolResources | Sort-Object Cost -Descending) + } + Allocations = $allocations + RuleTargets = @() + Note = ($notes -join ' ') + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 new file mode 100644 index 000000000..75e71cd88 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 @@ -0,0 +1,355 @@ +########################################################################### +# GET-VMCOSTBREAKDOWN.PS1 +# AZURE FINOPS MULTITOOL - Full VM Cost Decomposition +########################################################################### +# Purpose: Decompose a single virtual machine's total solution cost into +# its meter components - compute, OS/data disks, network egress, +# network infrastructure (public IP / NIC), backup/recovery, +# security (Defender), monitoring/extension agents, and licensing +# - instead of a single rolled-up number. +# +# Approach: A VM's billed cost is spread across several resource IDs (the +# VM, its managed disks, its public IPs, and bandwidth meters). +# We resolve the VM and its associated resources from Azure +# Resource Graph, then read cost grouped by ResourceId + meter +# and bucket each line item into a human-readable category. +# +# Sources: Live Cost Management API (default) OR the FinOps Hub / export +# when -HubData is injected by the MCP dispatcher (fast path, +# also carries consumed quantity so egress GB is exact). +# +# Notes: +# - RBAC: Cost Management Reader (sub scope) + Reader (ARG). +# - Bandwidth that Azure bills at subscription scope (no resource ID) +# cannot be attributed to one VM; it is reported as a caveat, not folded +# into the VM total. +########################################################################### + +# -- Meter -> category classifier ----------------------------------------- +# Buckets a billed line item into a VM-solution cost category using the +# meter category/subcategory (and resource type as a fallback for Hub rows +# where the meter category may be sparse). +function Get-VmMeterBucket { + param( + [string]$MeterCategory, + [string]$MeterSubCategory, + [string]$MeterName, + [string]$ResourceType + ) + + $cat = "$MeterCategory".Trim() + $sub = "$MeterSubCategory".Trim() + $mtr = "$MeterName".Trim() + $rt = "$ResourceType".Trim().ToLowerInvariant() + $blob = "$cat $sub $mtr" + + switch -Regex ($cat) { + '^(Bandwidth|Inter-Region|Content Delivery Network|Routing Preference)' { return 'Network egress' } + '^(Virtual Network|Load Balancer|VPN Gateway|ExpressRoute|IP Addresses|NAT Gateway|Application Gateway|Azure Firewall|Azure DNS)' { return 'Network infra' } + '^Storage' { return 'Disk / storage' } + '^(Backup|Azure Site Recovery|Recovery Services)' { return 'Backup / recovery' } + '(Defender|Security Center)' { return 'Security (Defender)' } + '^(Log Analytics|Azure Monitor|Application Insights)' { return 'Monitoring / agents' } + '^Virtual Machines Licenses' { return 'Licensing' } + '^Virtual Machines' { + if ($blob -match '(?i)\b(Windows|SQL|RHEL|SUSE|License)\b') { return 'Licensing' } + return 'Compute' + } + } + + # Fallback on resource type (Hub rows sometimes lack a meter category) + switch -Regex ($rt) { + 'microsoft\.compute/virtualmachines$' { return 'Compute' } + 'microsoft\.compute/disks$' { return 'Disk / storage' } + 'microsoft\.network/publicipaddresses$' { return 'Network infra' } + 'microsoft\.network/networkinterfaces$' { return 'Network infra' } + 'microsoft\.recoveryservices/' { return 'Backup / recovery' } + } + + if ($blob -match '(?i)data transfer|egress|bandwidth') { return 'Network egress' } + return 'Other' +} + +# -- Resolve a VM and its associated billable resources from ARG ---------- +# Returns $null when the VM cannot be located. Otherwise an object with the +# VM identity plus a lowercased HashSet of every resource ID whose cost +# rolls up into this VM's solution (VM, disks, NICs, public IPs). +function Resolve-VmAssociation { + param( + [string[]]$SubscriptionIds, + [string]$VmName, + [string]$ResourceId, + [string]$ResourceGroup + ) + + $where = if ($ResourceId) { + "id =~ '$ResourceId'" + } + elseif ($ResourceGroup) { + "name =~ '$VmName' and resourceGroup =~ '$ResourceGroup'" + } + else { + "name =~ '$VmName'" + } + + $vmQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where $where +| extend osDiskId = tostring(properties.storageProfile.osDisk.managedDisk.id) +| project id, name, resourceGroup, location, subscriptionId, + vmSize = tostring(properties.hardwareProfile.vmSize), + osDiskId, + dataDisks = properties.storageProfile.dataDisks, + nics = properties.networkProfile.networkInterfaces +"@ + + $res = Search-AzGraphSafe -Query $vmQuery -Subscription $SubscriptionIds -First 50 + $rows = if ($res) { @($res.Data) } else { @() } + if ($rows.Count -eq 0) { return $null } + + $ambiguous = $rows.Count -gt 1 + $vm = $rows[0] + + $assoc = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + [void]$assoc.Add([string]$vm.id) + if ($vm.osDiskId) { [void]$assoc.Add([string]$vm.osDiskId) } + + $nicIds = @() + foreach ($d in @($vm.dataDisks)) { + $did = [string]$d.managedDisk.id + if ($did) { [void]$assoc.Add($did) } + } + foreach ($n in @($vm.nics)) { + $nid = [string]$n.id + if ($nid) { $nicIds += $nid; [void]$assoc.Add($nid) } + } + + # Resolve public IPs attached to the VM's NICs + if ($nicIds.Count -gt 0) { + $nicList = ($nicIds | ForEach-Object { "'$_'" }) -join ',' + $pipQuery = @" +resources +| where type =~ 'microsoft.network/networkinterfaces' +| where id in~ ($nicList) +| mv-expand ipc = properties.ipConfigurations +| extend pip = tostring(ipc.properties.publicIPAddress.id) +| where isnotempty(pip) +| project pip +"@ + try { + $pipRes = Search-AzGraphSafe -Query $pipQuery -Subscription $SubscriptionIds -First 100 + foreach ($p in @($pipRes.Data)) { if ($p.pip) { [void]$assoc.Add([string]$p.pip) } } + } + catch { Write-Warning " Public IP resolution failed: $($_.Exception.Message)" } + } + + return [PSCustomObject]@{ + Id = [string]$vm.id + Name = [string]$vm.name + ResourceGroup = [string]$vm.resourceGroup + Location = [string]$vm.location + SubscriptionId = [string]$vm.subscriptionId + VmSize = [string]$vm.vmSize + Associated = $assoc + Ambiguous = $ambiguous + MatchCount = $rows.Count + } +} + +function Get-VmCostBreakdown { + [CmdletBinding()] + param( + [Parameter()] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [string]$VmName, + + [Parameter()] + [string]$ResourceId, + + [Parameter()] + [string]$ResourceGroup, + + [Parameter()] + [object[]]$HubData + ) + + if (-not $VmName -and -not $ResourceId) { + return [PSCustomObject]@{ + HasData = $false + Note = 'Provide a vmName (optionally with resourceGroup) or a full resourceId to decompose.' + } + } + + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + + Write-Host " Resolving VM and associated resources..." -ForegroundColor Cyan + $vm = Resolve-VmAssociation -SubscriptionIds $subIds -VmName $VmName -ResourceId $ResourceId -ResourceGroup $ResourceGroup + if (-not $vm) { + $target = if ($ResourceId) { $ResourceId } else { $VmName } + return [PSCustomObject]@{ + HasData = $false + Target = $target + Note = "No virtual machine found matching '$target' in the scanned subscriptions." + } + } + + # Bucket accumulators: category -> @{ Cost; Qty; Meters(set) } + $buckets = @{} + $addLine = { + param($Category, $Amount, $Qty, $MeterLabel) + if (-not $buckets.ContainsKey($Category)) { + $buckets[$Category] = @{ Cost = 0.0; Qty = 0.0; Meters = [System.Collections.Generic.HashSet[string]]::new() } + } + $buckets[$Category].Cost += [double]$Amount + $buckets[$Category].Qty += [double]$Qty + if ($MeterLabel) { [void]$buckets[$Category].Meters.Add([string]$MeterLabel) } + } + + $currency = 'USD' + $source = 'LiveApi' + $subLevelEgress = 0.0 # bandwidth billed with no resource ID (cannot attribute) + $fromHub = ($HubData -and @($HubData).Count -gt 0) + + if ($fromHub) { + # ---- FinOps Hub / export fast path (richest: carries quantity) --- + $source = 'FinOpsHub' + Write-Host " Decomposing from FinOps Hub export..." -ForegroundColor Cyan + $props = $HubData[0].PSObject.Properties.Name + + foreach ($row in $HubData) { + $rid = [string](Get-HubRowValue -Row $row -Names @('ResourceId', 'x_ResourceId', 'InstanceId') -Props $props) + if (-not $rid -or -not $vm.Associated.Contains($rid)) { continue } + + $cost = [double](Get-HubRowValue -Row $row -Names @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost', 'Cost') -Props $props) + $qty = [double](Get-HubRowValue -Row $row -Names @('ConsumedQuantity', 'Quantity', 'UsageQuantity') -Props $props) + $cur = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency') -Props $props) + if ($cur) { $currency = $cur } + + $mc = [string](Get-HubRowValue -Row $row -Names @('MeterCategory', 'x_SkuMeterCategory', 'ServiceName') -Props $props) + $ms = [string](Get-HubRowValue -Row $row -Names @('MeterSubCategory', 'x_SkuMeterSubcategory') -Props $props) + $mn = [string](Get-HubRowValue -Row $row -Names @('MeterName', 'x_SkuMeterName', 'Meter') -Props $props) + $rt = [string](Get-HubRowValue -Row $row -Names @('ResourceType', 'x_ResourceType', 'ConsumedService') -Props $props) + + $bucket = Get-VmMeterBucket -MeterCategory $mc -MeterSubCategory $ms -MeterName $mn -ResourceType $rt + $label = if ($ms) { "$mc / $ms" } elseif ($mc) { $mc } else { $rt } + & $addLine $bucket $cost $qty $label + } + } + else { + # ---- Live Cost Management API path ------------------------------- + Write-Host " Querying cost (Cost Management, MonthToDate)..." -ForegroundColor Cyan + $rgFilter = @{ dimensions = @{ name = 'ResourceGroupName'; operator = 'In'; values = @($vm.ResourceGroup) } } + $body = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + totalQty = @{ name = 'UsageQuantity'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'ResourceId' } + @{ type = 'Dimension'; name = 'MeterCategory' } + ) + filter = $rgFilter + } + } | ConvertTo-Json -Depth 12 + + $path = "/subscriptions/$($vm.SubscriptionId)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + try { + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + $cols = @($data.properties.columns.name) + $iCost = [array]::IndexOf($cols, 'Cost') + $iQty = [array]::IndexOf($cols, 'UsageQuantity') + $iRes = [array]::IndexOf($cols, 'ResourceId') + $iCat = [array]::IndexOf($cols, 'MeterCategory') + $iCur = [array]::IndexOf($cols, 'Currency') + + foreach ($row in @($data.properties.rows)) { + $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 } + $qty = if ($iQty -ge 0) { [double]$row[$iQty] } else { 0 } + $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } + $mc = if ($iCat -ge 0) { [string]$row[$iCat] } else { '' } + if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] } + + # Bandwidth often bills with an empty resource ID at sub + # scope - record it as an unattributable caveat. + if (-not $rid) { + if ((Get-VmMeterBucket -MeterCategory $mc) -eq 'Network egress') { $subLevelEgress += $amount } + continue + } + if (-not $vm.Associated.Contains($rid)) { continue } + + $bucket = Get-VmMeterBucket -MeterCategory $mc + & $addLine $bucket $amount $qty $mc + } + } + else { + $code = if ($resp) { $resp.StatusCode } else { 'no response' } + return [PSCustomObject]@{ + HasData = $false + VmName = $vm.Name + Note = "Cost Management query failed (HTTP $code). Need Cost Management Reader on the subscription." + } + } + } + catch { + return [PSCustomObject]@{ + HasData = $false + VmName = $vm.Name + Note = "Cost Management query error: $($_.Exception.Message)" + } + } + } + + # -- Assemble breakdown ------------------------------------------------ + $total = 0.0 + foreach ($b in $buckets.Values) { $total += $b.Cost } + + $breakdown = @( + $buckets.GetEnumerator() | ForEach-Object { + [PSCustomObject]@{ + Category = $_.Key + Cost = [math]::Round($_.Value.Cost, 2) + PctOfTotal = if ($total -gt 0) { [math]::Round(($_.Value.Cost / $total) * 100, 1) } else { 0 } + Quantity = [math]::Round($_.Value.Qty, 2) + Meters = @($_.Value.Meters) + } + } | Sort-Object Cost -Descending + ) + + $egressBucket = $buckets['Network egress'] + $egressQty = if ($egressBucket) { [math]::Round($egressBucket.Qty, 2) } else { 0 } + + $notes = @() + if ($vm.Ambiguous) { $notes += "$($vm.MatchCount) VMs matched '$VmName'; showing the first. Pass resourceId or resourceGroup to disambiguate." } + if ($subLevelEgress -gt 0) { $notes += "Excludes $([math]::Round($subLevelEgress,2)) $currency of bandwidth billed at subscription scope (no resource ID, not attributable to one VM)." } + if (-not $fromHub) { $notes += 'Egress quantity (GB) is exact only on the FinOps Hub path; call with dataSource=hub when an export exists.' } + + return [PSCustomObject]@{ + HasData = ($breakdown.Count -gt 0) + VmName = $vm.Name + ResourceId = $vm.Id + ResourceGroup = $vm.ResourceGroup + SubscriptionId = $vm.SubscriptionId + Location = $vm.Location + VmSize = $vm.VmSize + Currency = $currency + Period = 'MonthToDate' + Source = $source + TotalCost = [math]::Round($total, 2) + EgressGb = $egressQty + Breakdown = $breakdown + ResourcesIncluded = @($vm.Associated) + Note = if ($notes.Count -gt 0) { $notes -join ' ' } else { 'Full VM solution cost: compute + disks + network + extensions, month-to-date.' } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 new file mode 100644 index 000000000..8bbad1183 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 @@ -0,0 +1,256 @@ +########################################################################### +# INITIALIZE-SCANNER.PS1 +# AZURE FINOPS MULTITOOL - Authentication & Prerequisites +########################################################################### +# Purpose: Validate required Az modules, authenticate to Azure, and return +# tenant context for the scanner to operate against. +########################################################################### + +function Show-TenantPicker { + param([object[]]$Tenants) + + Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue + + $pickerXaml = @" + + + + + + + + + + +