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

FinOps Multitool Report

+
Generated: $timestamp  |  Subscriptions: $([System.Net.WebUtility]::HtmlEncode($subList))
+"@) + + # Summary cards + $errorCount = ($Modules | Where-Object { $_.Selected } | Where-Object { $Results.ContainsKey("_error_$($_.Fn)") }).Count + [void]$htmlSb.Append('
') + [void]$htmlSb.Append("
Total Findings
$totalFindings
") + [void]$htmlSb.Append("
Scans Run
$(($Modules | Where-Object { $_.Selected }).Count)
") + if ($errorCount -gt 0) { + [void]$htmlSb.Append("
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/README.md b/src/powershell/Private/FinOpsMultitool/README.md new file mode 100644 index 000000000..b327b8b96 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -0,0 +1,400 @@ +# FinOps multitool — Terminal UI (TUI) + +Interactive terminal interface for running FinOps scans against Azure subscriptions. No GUI dependencies — works in any terminal on Windows, macOS, and Linux. + +## Quick Start + +```powershell +# From the FinOpsMultitool directory +Import-Module .\FinOpsMultitool.psm1 +Invoke-FinOpsMultitool +``` + +Or target a specific subscription: + +```powershell +Invoke-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' +``` + +## Requirements + +| Requirement | Details | +| --------------------- | --------------------------------------------------------------- | +| PowerShell | 5.1+ (Windows) or 7+ (cross-platform) | +| Az modules | `Az.Accounts`, `Az.Resources`, `Az.ResourceGraph`, `Az.Storage` | +| Azure RBAC | Reader + Cost Management Reader on target scope | +| FinOps Hub (optional) | Storage Blob Data Reader on Hub storage account | + +Install Az modules if needed: + +```powershell +Install-Module Az.Accounts, Az.Resources, Az.ResourceGraph, Az.Storage -Scope CurrentUser +``` + +## How It Works + +### 1. Authentication + +On launch, the TUI checks for an existing `Az.Accounts` session. If you're not logged in, it prompts you to run `Connect-AzAccount`. If your account has access to multiple Azure AD tenants, a tenant picker appears so you can select which tenant to scan. It then discovers all accessible subscriptions and lets you select which ones to scan. + +### 2. Data Source Selection + +If a FinOps Hub is detected in any of your subscriptions, you'll be asked to choose a data source: + +| Source | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| **FinOps Hub** | Reads cost data from the FinOps Hub. Faster, no API throttling. Tag and cost-by-tag scans are instant. | +| **Cost Management API** | Queries the Cost Management REST API in real-time. Slower but always current. Hub tag data is still used when available. | +| **Resource Graph only** | Skips all cost APIs. Only runs scans that use Azure Resource Graph (orphaned resources, idle VMs, etc). | + +When the **FinOps Hub** source is chosen, the tool prefers the hub's **Kusto database** (Azure Data Explorer / Fabric, or a local ftklocal emulator) and pushes aggregation into the engine, returning only summarized results. This is the scalable path for large customer datasets — it never loads the raw cost rows into PowerShell. See [FinOps Hub data paths](#finops-hub-data-paths) below. The storage-export reader remains as a small-dataset fallback. + +### 3. Scan Selection + +Arrow-key driven menu to toggle individual scans on/off. All scans are selected by default except Billing Structure. + +| Key | Action | +| --------- | ------------------ | +| `↑` / `↓` | Navigate scan list | +| `Space` | Toggle scan on/off | +| `A` | Select all | +| `N` | Deselect all | +| `Enter` | Run selected scans | +| `Q` | Quit | + +### 4. Scan Execution + +Selected scans run sequentially with a progress bar. When a FinOps Hub is available, tag-related scans (Tag Inventory, Cost by Tag) use pre-loaded Hub data instead of API calls — completing in under a second. + +### 5. Results + +Results display inline with formatted tables, severity-colored guidance, and permission diagnostics. + +**Guidance system** — After each scan result, contextual FinOps guidance appears with severity-based coloring: + +| Icon | Color | Meaning | +| ----- | ------ | ---------------------------------- | +| `[!]` | Red | Critical finding — action required | +| `[~]` | Yellow | Warning — improvement recommended | +| `[+]` | Green | Healthy — good practices confirmed | + +Guidance includes FinOps Foundation best practices, actionable next steps, and links to Microsoft Learn documentation. + +**Dollar colorization** — All dollar amounts in results are highlighted in green for quick scanning. Budget rows are colored by risk severity (red for over budget, yellow for at risk, green for on track). + +**Permission diagnostics** — When a scan returns no data, the TUI explains why: + +- **Access denied** (403/401) — Shows the exact error, required RBAC role, scope, and API +- **No data** — Explains whether the module requires specific resources (e.g., "Returns empty if no budgets are configured") + +An optional CSV/JSON export saves to the output path. + +## Required Permissions + +Each scan module requires specific Azure RBAC roles. The TUI will tell you which role is needed if a scan fails due to missing permissions. + +| Category | Scans | Required Role | Scope | +| ------------ | ------------------------------------------------------------ | ------------------------ | ------------------- | +| Optimization | Orphaned Resources, Idle VMs, Storage Tier Advice, AHB | Reader | Subscription | +| Governance | Tag Inventory, Tag Recommendations, Policy Inventory/Recs | Reader | Subscription | +| Cost | Cost Data, Resource Costs, Cost by Tag, Cost Trend | Cost Management Reader | Subscription or MG | +| Commitments | Reservation Advice, Commitment Utilization, Savings Realized | Cost Management Reader | Subscription | +| Monitoring | Budget Status, Anomaly Alerts | Cost Management Reader | Subscription | +| Advisor | Optimization Advice | Reader | Subscription | +| Account | Billing Structure, Contract Info | Billing Reader | Billing Account | +| Hub (opt.) | All scans via Hub data | Storage Blob Data Reader | Hub Storage Account | + +## Available Scans + +### Optimization (Resource Graph) + +| Scan | What it finds | +| ------------------- | --------------------------------------------------------------------- | +| Orphaned Resources | Unattached disks, NICs, public IPs, NSGs | +| Idle VMs | VMs with <5% CPU over 30 days | +| Storage Tier Advice | Blob storage that could move to cooler tiers | +| AHB Opportunities | Windows/SQL VMs not using Azure Hybrid Benefit | +| Legacy Resources | Legacy/retiring SKUs (v1 VM families, unmanaged disks, Basic IPs/LBs) | + +### Governance + +| Scan | What it finds | +| ---------------------- | --------------------------------------------------------- | +| Tag Inventory | All tags across resources — names, values, coverage % | +| Tag Recommendations | Inconsistent casing, similar names, missing standard tags | +| Policy Inventory | Azure Policy assignments with scope and compliance | +| Policy Recommendations | Gaps in policy coverage for cost governance | + +### Cost Analysis + +| Scan | What it finds | +| -------------- | --------------------------------------------------------------------------------------------------- | +| Cost Data | Monthly spend per subscription | +| Resource Costs | Top resources by cost | +| Cost by Tag | Spend breakdown by tag key/value | +| Cost Trend | Month-over-month spend comparison | +| Unit Economics | Cost per vCPU, per GB RAM, per VM, and per GB stored (disk + blob/file, with compute/storage split) | + +### AI & ML + +| Scan | What it finds | +| ------------------- | -------------------------------------------------------------------------------------------------- | +| AI Workload Metrics | Detects AI workloads, then token consumption by model, AI spend, cost per 1K tokens, cost per call | + +### Commitments + +| Scan | What it finds | +| ---------------------- | ---------------------------------------- | +| Reservation Advice | RI purchase recommendations from Advisor | +| Commitment Utilization | RI and Savings Plan usage rates | +| Savings Realized | Actual savings from existing commitments | + +### Monitoring + +| Scan | What it finds | +| -------------- | --------------------------------- | +| Budget Status | Budget consumption vs. thresholds | +| Anomaly Alerts | Recent cost anomaly detections | + +### Sustainability + +| Scan | What it finds | +| -------------- | ------------------------------------------------------------------------------------------- | +| Carbon Metrics | Cloud carbon emissions, month-over-month change, 12-month trend, per-subscription breakdown | + +### Advisor & Account + +| Scan | What it finds | +| ------------------- | ---------------------------------------- | +| Optimization Advice | Azure Advisor cost recommendations | +| Billing Structure | Account hierarchy and enrollment details | +| Contract Info | Agreement type, offer, support plan | + +## FinOps KPI Coverage + +The scan modules map directly to [FinOps Foundation KPIs](https://www.finops.org/finops-kpis/). Each scan answers a KPI question directly, and the `finops-multitool` agent skill routes a natural-language question to the matching investigation. A few examples of question → output: + +### Percentage of Legacy Resource → legacy resources + +> "Which of my resources are running on legacy or retiring SKUs?" + +``` +Legacy / Retiring Resources — 47 found across 156 subscriptions + +By category: + Legacy v1 VM families 18 (Basic_A / Standard_A0-A7 / D / DS / G) + Unmanaged VHD disks 9 (migrate to managed disks) + HDD Standard_LRS ≥128GB 11 (upgrade to Premium SSD) + Basic SKU Public IPs 6 (retiring Sep 2025 → Standard) + Basic SKU Load Balancers 3 (retiring Sep 2025 → Standard) +``` + +Legacy % = 47 ÷ total resources in scope. + +### Cost per Gigabyte Stored / Hourly Cost per CPU Core → unit economics + +> "What's my cost per vCPU and per GB of storage this month?" + +``` +Unit Economics — Month to Date (USD) + +Compute $128,400 (75.7%) 312 VMs / 1,840 vCPU / 7,360 GB RAM +Storage $ 41,200 (24.3%) 126,400 GB (84,600 GB disk + 41,800 GB blob/file) + + Cost per vCPU $69.78 / month + Cost per GB RAM $17.45 / month + Cost per VM $411.54 / month + Cost per GB stored $0.326 / month +``` + +vCPU and RAM are exact (read from Compute SKU capabilities). Storage GB combines provisioned managed disks with Storage-account used capacity (Azure Monitor `UsedCapacity`). Cost is scoped to the selected subscriptions and falls back to per-subscription queries when the management-group scope is not accessible, so the section is never silently $0. Directly produces `Cost per GB Stored`; feeds `Hourly Cost per CPU Core` (÷ 730) and `Effective Avg Compute Cost per Core`. + +### Token Consumption / Cost per 1K Tokens / Cost per API Call → ai workloads + +> "What are my AI/LLM workloads costing per token and per request this month?" + +This scan is self-gating: a single Resource Graph query detects whether any AI workloads (Azure OpenAI, AI Services, Machine Learning, AI Search, GPU VMs) exist. Non-AI tenants skip the deep scan entirely, so the scan stays fast. When AI is present, it joins Azure Monitor token metrics to Cost Management spend over the same month-to-date window. + +``` +AI footprint — OpenAI/AIServices: 3 ML workspaces: 1 AI Search: 2 GPU VMs: 0 +Tokens (MTD): 412,800,000 total (288,100,000 in / 124,700,000 out) over 1,240,500 requests +AI spend (MTD): USD 3,910.42 | USD 0.0095 /1K tokens | USD 0.00315 /request + +Deployment PromptTokens GeneratedTokens TotalTokens PctOfTokens +gpt-4o 210,400,000 98,200,000 308,600,000 74.8 +gpt-4o-mini 77,700,000 26,500,000 104,200,000 25.2 +``` + +Produces `Token Consumption`, `Cost per 1K Tokens` (effective blended rate), and `Cost per API Call`; the per-model breakdown highlights where to shift traffic to cheaper SKUs or evaluate Provisioned Throughput Units (PTUs). + +Like the cost scans, this honors `dataSource` (`auto` / `hub` / `api`). When a readable FinOps Hub export covers the scope, AI spend and billed token volume are read straight from the export — no Azure Monitor or Cost Management calls. Cost per request is only available on the live API path, since request counts are not billed line items. + +### Carbon per Unit of Spend / Carbon Efficiency → carbon + +> "Show my cloud carbon footprint and how it changed month over month." + +``` +Carbon Emissions — latest available month: 2026-04 (data lags ~2 mo) + +Total emissions 18,420 kgCO2e +Previous month 20,110 kgCO2e +Change -1,690 kgCO2e (-8.4%) ↓ improving + +Top emitting subscriptions: + Production-East 00000000… 7,910 kgCO2e + Data-Platform a1b2c3d4… 4,330 kgCO2e +``` + +Combined with cost data, `Carbon per Unit of Spend` = total emissions ÷ monthly spend. + +### Commitment Utilization Score / % Discount Waste → commitment utilization + +> "How well are my reservations and savings plans being used?" + +``` +Commitment Utilization — trailing 30 days + +Reserved Instances 94.2% utilized ($3,120 unused) +Savings Plans 88.7% utilized ($1,540 unused) +Overall score 91.8% +``` + +`Commitment Utilization Score` = 91.8%; `% Commitment Discount Waste` = 100 − 91.8 = 8.2%. + +### % Costs from Untagged Resources → cost by tag + +> "How much of my spend is on untagged resources?" + +``` +Cost by Tag — Month to Date + +Tagged spend $612,300 (87.4%) +Untagged spend $ 88,200 (12.6%) ← KPI +``` + +`% Costs from Untagged Resources` = 12.6%. + +## FinOps Hub Integration + +When a Hub is detected, the tool reads FinOps Hub cost data. This enables: + +- **Instant tag scans** — Tag Inventory and Cost by Tag are answered from Hub data instead of querying the Cost Management API +- **No API throttling** — avoids Cost Management API rate limits +- **Richer tag data** — Hub data contains the full Tags per cost record, enabling accurate per-resource tag parsing +- **Forecast enrichment** — Hub data contains actuals only, so the TUI calls the Cost Management Forecast API to project full-month costs and adds them to Hub actuals (storage path) +- **Accurate tag coverage** — Hub only sees resources with cost data. The TUI queries Azure Resource Graph for the true total/untagged resource count and overrides the Hub-derived coverage percentage + +### FinOps Hub data paths + +The cost-family scans (Cost Data, Resource Costs, Cost by Tag) read from a FinOps Hub three ways, in priority order. The first two push aggregation **into the engine** and bring back only summarized results — they never load the raw cost rows into PowerShell, so they scale to large customer datasets (tens of GB / hundreds of millions of rows): + +| Path | When | How | +| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Kusto — online** | A deployed hub with an Azure Data Explorer / Fabric cluster | The cluster is discovered via Azure Resource Graph (`microsoft.kusto/clusters` tagged `ftk-tool == 'FinOps hubs'`), queried with a bearer token. Aggregation runs in KQL against the `Costs` function. | +| **Kusto — offline (ftklocal)** | Your own hardware / air-gapped: an [ftklocal](https://github.com/microsoft/finops-toolkit) Kusto emulator with the exports loaded into the local **Hub** database | Set `FINOPS_HUB_KUSTO_URI` (and optionally `FINOPS_HUB_KUSTO_DB`, default `Hub`). The local emulator is queried anonymously — same KQL, no auth. | +| **Storage export reader** | Small datasets, or when no Kusto cluster is available | Reads the hub's `ingestion` parquet / `msexports` CSV and aggregates in PowerShell. A convenience fallback, **not** the scalable path. | + +Selection is automatic: `FINOPS_HUB_KUSTO_URI` (if set) wins, else a discovered cluster, else the storage reader. To force the live Cost Management API instead, choose the **Cost Management API** source in the TUI. + +#### Environment variables + +| Variable | Effect | Default | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| `FINOPS_HUB_KUSTO_URI` | Kusto cluster query URI. An `https://...kusto.windows.net` cluster (token auth) or `http://localhost:` ftklocal emulator (anonymous). | unset (auto-discover) | +| `FINOPS_HUB_KUSTO_DB` | Hub database name. | `Hub` | + +Hub data is loaded once at startup and reused across all scans that need it. + +## Scripting (Non-Interactive) + +The scan modules can be called directly without the TUI: + +```powershell +Import-Module .\FinOpsMultitool.psm1 + +# Run a single scan +$tags = Get-TagInventory -Subscriptions $subs -TenantId $tid + +# Read Hub data and convert +$hubData = Read-FinOpsHubData -StorageAccountName 'myhub' -ResourceGroupName 'rg-hub' -Months 1 +$tagInventory = ConvertTo-TagInventoryFromHub -HubData $hubData +$costByTag = ConvertTo-CostByTagFromHub -HubData $hubData -ExistingTags $tagInventory.TagNames +``` + +## Write Safety (Remediation Tools) + +The remediation functions are read-only by default. Every write previews first, and enabling writes is a deliberate opt-in via `FINOPS_WRITE_MODE`. The gate lives in the functions themselves, so it applies to any caller — the TUI, a script, or anything that imports the module. + +### Modes — `FINOPS_WRITE_MODE` + +| Mode | Behavior | Use for | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| `ReadOnly` | **Default.** All write tools are blocked; read scans still work. Set `FINOPS_WRITE_MODE` to `Interactive` or `Enforced` to enable writes. | Locked-down or audit-only deployments (and the default) | +| `Interactive` | `apply=true` runs the change directly. A preview/token is offered but not required. The client (human or AI) is the gate. | Platform-agnostic AI chat — low friction, any client | +| `Enforced` | `apply=true` is **rejected** unless it carries the exact single-use token from that change's own dry-run preview (bound to a SHA-256 fingerprint, expires in 5 min). | Autonomous / unattended agents — server is the gate | + +Every write previews first: call the tool without `apply` to get the exact REST call, the resource evidence, and (in Enforced mode) a `confirmationToken` to pass back with `apply=true`. + +### Guardrails (enforced in every mode) + +These never depend on a well-behaved client. Configure via environment variables: + +| Variable | Effect | Default | +| ----------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `FINOPS_PROTECTED_TAGS` | Resources carrying any of these tag keys are never written to | `do-not-delete`, `DoNotDelete`, `lock`, `protected` | +| `FINOPS_PROTECTED_RGS` | Resource groups (supports `*` wildcards) that are off-limits | none | +| `FINOPS_PROTECTED_SUBS` | Subscriptions that are off-limits | none | +| `FINOPS_WRITE_MAX_IMPACT` | Block any single write whose estimated monthly $ impact exceeds this cap (`0` = no cap) | `0` | +| `FINOPS_WRITE_MAX_PER_WINDOW` | Max writes allowed per rolling window (blast-radius limit) | unlimited | +| `FINOPS_WRITE_WINDOW_MIN` | Length of that window in minutes | `60` | +| `FINOPS_AUDIT_LOG` | Path for the append-only audit log (every preview / apply / block is recorded as JSON) | `%LOCALAPPDATA%\FinOpsMultitool\finops-multitool-audit.log` | + +### Example — autonomous, locked-down server + +```json +{ + "servers": { + "finops-multitool": { + "type": "stdio", + "command": "pwsh", + "env": { + "FINOPS_WRITE_MODE": "Enforced", + "FINOPS_PROTECTED_RGS": "rg-prod-*,rg-shared", + "FINOPS_WRITE_MAX_PER_WINDOW": "5" + } + } + } +} +``` + +In this configuration an agent must preview each change, pass the matching token back, stay out of protected resource groups, and is capped at five writes per hour — all enforced by the server, not the client. + +## File Structure + +``` +FinOpsMultitool/ +├── README.md # This file +├── FinOpsMultitool.psm1 # Module loader (dot-sources all scan modules) +├── Invoke-FinOpsMultitool.ps1 # TUI entry point +├── modules/ +│ ├── helpers/ +│ │ ├── Read-FinOpsHubData.ps1 # Hub storage reader + converters (small-dataset path) +│ │ ├── Invoke-FOHubKustoQuery.ps1 # Hub Kusto REST transport (ADX/Fabric/ftklocal) +│ │ ├── Get-FOHubProvider.ps1 # Scalable hub provider (discovery + engine-side cost intents) +│ │ ├── Get-PlainAccessToken.ps1 # Token helper +│ │ ├── Invoke-AzRestMethodWithRetry.ps1 # REST retry logic +│ │ ├── Search-AzGraphSafe.ps1 # ARG query wrapper +│ │ ├── Confirm-WriteAction.ps1 # Write-safety policy gate (modes, guardrails, audit) +│ │ └── MgCostScope.ps1 # Management group scope state +│ ├── Initialize-Scanner.ps1 +│ ├── Get-CostData.ps1 +│ ├── Get-ResourceCosts.ps1 +│ ├── Get-TagInventory.ps1 +│ ├── Get-CostByTag.ps1 +│ ├── Get-OrphanedResources.ps1 +│ ├── Remove-OrphanedResource.ps1 # Write: delete orphaned resource (gated) +│ ├── Enable-HybridBenefit.ps1 # Write: enable Azure Hybrid Benefit (gated) +│ ├── Stop-IdleVm.ps1 # Write: deallocate idle VM (gated) +│ ├── Get-IdleVMs.ps1 +│ └── ... # One file per scan module +``` diff --git a/src/powershell/Private/FinOpsMultitool/assets/skeleton.pbit b/src/powershell/Private/FinOpsMultitool/assets/skeleton.pbit new file mode 100644 index 000000000..c915de039 Binary files /dev/null and b/src/powershell/Private/FinOpsMultitool/assets/skeleton.pbit differ diff --git a/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json new file mode 100644 index 000000000..115a27e25 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json @@ -0,0 +1,308 @@ +{ + "_comment": "FinOps Foundation KPI correlation catalog (Phase 1). Curated subset of https://www.finops.org/finops-kpis/ that the scan output can inform. Each entry maps one or more source tools to a KPI. 'compute' = the server can calculate a value; 'informational' = the scan relates to the KPI but the value needs the field below or external input. Keep this honest: never claim a value we cannot derive.", + "version": "1.0.0", + "learnMoreBase": "https://www.finops.org/finops-kpis/", + "kpis": [ + { + "id": "cost-per-gb-stored", + "name": "Cost per Gigabyte Stored", + "domain": "Quantify", + "definition": "Average cost per GB stored, influenced by storage tiers and data life-cycle management.", + "sourceTool": "scan_unit_economics", + "compute": true, + "field": "CostPerGb", + "unit": "per GB / month", + "plainLanguage": "What you pay for each GB of stored data this month.", + "exploreHint": "Run scan_storage_tier_advice to see if moving cold data to Cool or Archive lowers this." + }, + { + "id": "hourly-cost-per-cpu-core", + "name": "Hourly Cost per CPU Core", + "domain": "Quantify", + "definition": "Average cost per CPU core, giving unit-cost insight for compute.", + "sourceTool": "scan_unit_economics", + "compute": true, + "field": "CostPerVCpu", + "divideBy": 730, + "unit": "per vCPU / hour", + "plainLanguage": "Roughly what one CPU core costs you per hour.", + "exploreHint": "Run scan_optimization_advice or scan_idle_vms to find rightsizing that lowers this." + }, + { + "id": "effective-avg-compute-cost-per-core", + "name": "Effective Average Compute Cost per Core", + "domain": "Quantify", + "definition": "Average cost per core per month after amortizing unused commitment discounts.", + "sourceTool": "scan_unit_economics", + "compute": true, + "field": "CostPerVCpu", + "unit": "per vCPU / month", + "plainLanguage": "Average monthly cost of one CPU core across your fleet.", + "exploreHint": "Pair with scan_commitment_utilization to amortize unused reservation/savings-plan cost." + }, + { + "id": "pct-costs-untagged", + "name": "Percentage of Costs Associated with Untagged Resources", + "domain": "Understand", + "definition": "Percentage of cloud cost on resources missing a required tag, per your tagging policy.", + "sourceTool": "scan_cost_by_tag", + "compute": true, + "unit": "%", + "plainLanguage": "How much of your spend lands on resources that are not tagged.", + "exploreHint": "Run scan_tag_recommendations to see which CAF tags to backfill and where." + }, + { + "id": "pct-costs-unallocated", + "name": "Percentage of Costs Associated with Unallocated Resources", + "domain": "Understand", + "definition": "Percentage of spend that cannot be attributed to a team, project, or application.", + "sourceTool": "scan_cost_by_tag", + "compute": true, + "unit": "%", + "plainLanguage": "Share of your bill you cannot yet assign to an owner.", + "exploreHint": "Backfill allocation tags (Customer/project/CostCenter), then re-run scan_cost_by_tag." + }, + { + "id": "tagging-policy-compliant", + "name": "Percentage of Costs that are Tagging Policy Compliant", + "domain": "Manage", + "definition": "Share of cost on resources that satisfy your organizational tagging policy.", + "sourceTool": "scan_cost_by_tag", + "compute": true, + "unit": "%", + "plainLanguage": "How much of your spend is on properly tagged resources.", + "exploreHint": "Run scan_policy_recommendations to deploy tag-enforcement policy." + }, + { + "id": "commitment-utilization-score", + "name": "Commitment Utilization Score", + "domain": "Optimize", + "definition": "Burndown of pre-purchased commitment capacity against actual consumption. ~100% is healthy; lower signals shelfware.", + "sourceTool": "scan_commitment_utilization", + "compute": true, + "unit": "%", + "plainLanguage": "How much of your reserved/committed capacity you are actually using.", + "exploreHint": "Run scan_reservation_advice to right-size future commitments." + }, + { + "id": "pct-commitment-discount-waste", + "name": "Percentage of Commitment Discount Waste", + "domain": "Optimize", + "definition": "Percentage of commitment capacity not applied to on-demand spend (wasted).", + "sourceTool": "scan_commitment_utilization", + "compute": true, + "unit": "%", + "plainLanguage": "The slice of your reservations/savings plans you paid for but did not use.", + "exploreHint": "100% minus your utilization. Investigate underused commitments for renewal changes." + }, + { + "id": "pct-compute-covered-by-commitment", + "name": "Percent of Compute Spend Covered by Commitment Discounts", + "domain": "Optimize", + "definition": "Percentage of compute cost (excluding Spot) covered by commitment discounts.", + "sourceTool": "scan_savings_realized", + "compute": true, + "unit": "%", + "plainLanguage": "How much of your eligible spend rides on a discounted commitment (reservation or savings plan) versus full on-demand. Computed from amortized cost by pricing model, excluding Spot from the eligible base.", + "exploreHint": "Raise coverage by buying reservations or savings plans for steady-state workloads." + }, + { + "id": "percent-unused-resources", + "name": "Percent of Unused Resources", + "domain": "Optimize", + "definition": "Measure of unused resources such as unattached disks, idle NICs, and orphaned public IPs.", + "sourceTool": "scan_orphaned_resources", + "compute": true, + "unit": "count / cost", + "plainLanguage": "Resources you are paying for that nothing is using. Shown as the orphaned-resource count found by the scan.", + "exploreHint": "Run remediate_delete_orphaned_resource (gated) to clean them up." + }, + { + "id": "computational-waste", + "name": "Computational Waste Percentage", + "domain": "Optimize", + "definition": "Spend that provided zero business utility, e.g. idle resources before suspension or over-provisioning.", + "sourceTool": "scan_idle_vms", + "compute": true, + "unit": "count / cost", + "plainLanguage": "Compute you are running and paying for but barely using. Shown as the share of running VMs flagged idle or underutilized.", + "exploreHint": "Run remediate_deallocate_vm (gated, reversible) on confirmed idle VMs." + }, + { + "id": "percent-storage-frequent-tier", + "name": "Percent Storage on Frequent Access Tier", + "domain": "Optimize", + "definition": "Percentage of object storage held on a frequent (Hot) access tier.", + "sourceTool": "scan_storage_tier_advice", + "compute": false, + "unit": "%", + "plainLanguage": "How much of your storage sits on the most expensive Hot tier.", + "exploreHint": "Flagged Hot accounts with low activity are candidates for Cool or Archive." + }, + { + "id": "storage-decay-ratio", + "name": "Storage Decay Ratio", + "domain": "Optimize", + "definition": "Storage cost attributed to data not accessed within a set window (dark data).", + "sourceTool": "scan_storage_tier_advice", + "compute": false, + "unit": "%", + "plainLanguage": "Share of storage spend on data nobody has touched recently.", + "exploreHint": "Low-activity accounts in this scan approximate dark data; apply lifecycle policy." + }, + { + "id": "budget-burn-rate", + "name": "CSP Cloud Budget Burn Rate", + "domain": "Manage", + "definition": "The rate at which an organization is consuming its allocated cloud budget.", + "sourceTool": "scan_budget_status", + "compute": true, + "unit": "%", + "plainLanguage": "How fast you are spending against your budget this period. Shown as the average percent of budget consumed across all budgets.", + "exploreHint": "Risk levels in the scan flag budgets trending over. Adjust thresholds or spend." + }, + { + "id": "variance-budget-vs-actual", + "name": "Percentage Variance of Budgeted vs. Actual Spend", + "domain": "Manage", + "definition": "Difference between budgeted cost and actual cost incurred.", + "sourceTool": "scan_budget_status", + "compute": true, + "unit": "%", + "plainLanguage": "How far your actual spend is from what you planned. Shown as total actual vs total budgeted across all budgets.", + "exploreHint": "Compare budget amount vs actual in the scan rows." + }, + { + "id": "anomaly-detection-rate", + "name": "Anomaly Detection Rate", + "domain": "Manage", + "definition": "Frequency and cost impact of detected spending anomalies.", + "sourceTool": "scan_anomaly_alerts", + "compute": true, + "unit": "count", + "plainLanguage": "How often unusual cost spikes are being caught. Shown as a proxy: anomaly alerts triggered plus the number of detection rules configured (a true rate needs the count of anomalies that occurred, which Azure does not expose).", + "exploreHint": "Configure anomaly alert rules so spikes are caught proactively." + }, + { + "id": "effective-savings-rate", + "name": "Effective Savings Rate Percentage", + "domain": "Quantify", + "definition": "Return-on-investment metric across all commitment discounts and optimizations.", + "sourceTool": "scan_savings_realized", + "compute": true, + "unit": "%", + "plainLanguage": "How much you are actually saving versus paying full on-demand rates. Shown as realized monthly savings from RIs, Savings Plans and AHB (a true rate also needs total on-demand-equivalent spend).", + "exploreHint": "Realized savings from RIs, Savings Plans and AHB appear in this scan." + }, + { + "id": "pct-legacy-resource", + "name": "Percentage of Legacy Resource", + "domain": "Optimize", + "definition": "Percentage of resources running on older generation types vs modern equivalents.", + "sourceTool": "scan_legacy_resources", + "compute": false, + "unit": "%", + "plainLanguage": "How much of your estate runs on older, less efficient resource types.", + "exploreHint": "Modernizing to newer SKUs usually improves price-performance." + }, + { + "id": "carbon-per-unit-spend", + "name": "Carbon per Unit of Cloud Spend", + "domain": "Quantify", + "definition": "Carbon emissions (CO2e) per unit of cloud spend, ideally drillable by service category.", + "sourceTool": "scan_carbon", + "compute": false, + "unit": "CO2e / spend", + "plainLanguage": "How much carbon each dollar of cloud spend produces.", + "exploreHint": "Carbon-efficient regions and modern SKUs reduce this." + }, + { + "id": "token-consumption-metrics", + "name": "Token Consumption Metrics", + "domain": "Quantify", + "definition": "Cost of token-based models based on input/output token usage.", + "sourceTool": "scan_ai_workloads", + "compute": true, + "unit": "tokens / cost", + "plainLanguage": "How many tokens your AI workloads burn and what they cost.", + "exploreHint": "Prompt engineering and model choice move this number." + }, + { + "id": "cost-per-api-call", + "name": "Cost per API Call", + "domain": "Quantify", + "definition": "Average cost for each API call made to AI services.", + "sourceTool": "scan_ai_workloads", + "compute": true, + "unit": "per call", + "plainLanguage": "What each AI service request costs you on average.", + "exploreHint": "High per-call cost can signal an inefficient model or call pattern." + }, + { + "id": "pct-unallocated-shared-cost", + "name": "Percentage of Unallocated Shared Cost", + "domain": "Understand", + "definition": "Shared expenses that cannot be directly attributed to a specific team or project.", + "sourceTool": "scan_allocate_shared_cost", + "compute": false, + "unit": "%", + "plainLanguage": "Shared platform cost not yet split across its consumers.", + "exploreHint": "Use scan_allocate_shared_cost or scan_usage_allocation to split it." + }, + { + "id": "allocation-accuracy-index", + "name": "Allocation Accuracy Index (AAI)", + "domain": "Understand", + "definition": "Percentage of total cost directly and accurately attributed to responsible owners.", + "sourceTool": "scan_usage_allocation", + "compute": false, + "unit": "%", + "plainLanguage": "How reliably your costs map to the right team or project.", + "exploreHint": "Improve tagging and shared-cost rules to raise this." + }, + { + "id": "tco-per-workload", + "name": "Total Cost of Ownership per Workload", + "domain": "Quantify", + "definition": "Average full-lifecycle cost of running an individual workload (compute, storage, network, etc.).", + "sourceTool": "scan_vm_cost_breakdown", + "compute": false, + "unit": "cost / workload", + "plainLanguage": "The true all-in cost of one workload, not just its compute line.", + "exploreHint": "scan_vm_cost_breakdown decomposes a VM into all its billed meters." + }, + { + "id": "frequency-of-data-updates", + "name": "Frequency of Data Updates", + "domain": "Manage", + "definition": "Time between updates of cost data (e.g. since the last export refresh).", + "sourceTool": "detect_cost_data_source", + "compute": false, + "unit": "freshness date", + "plainLanguage": "How current the cost data behind your answers is.", + "exploreHint": "The Freshness date in this tool is your data-update recency." + }, + { + "id": "cost-visibility-delay", + "name": "Cost Visibility Delay", + "domain": "Manage", + "definition": "Time between a cost occurring and it being ingested, normalized, and visible.", + "sourceTool": "detect_cost_data_source", + "compute": false, + "unit": "days", + "plainLanguage": "The lag between spending money and seeing it in reports.", + "exploreHint": "Export-based reads are typically a day behind; live API is near-real-time." + }, + { + "id": "unified-cost-usage-visibility", + "name": "Unified Cost & Usage Visibility", + "domain": "Understand", + "definition": "Extent to which all relevant cost and usage sources are integrated into one reporting system.", + "sourceTool": "detect_cost_data_source", + "compute": false, + "unit": "%", + "plainLanguage": "How much of your estate is covered by one unified cost view.", + "exploreHint": "CoveragePct in this tool reflects how much of scope the hub/export covers." + } + ] +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 new file mode 100644 index 000000000..fc7f70e31 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Enable-HybridBenefit.ps1 @@ -0,0 +1,179 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# ENABLE-HYBRIDBENEFIT.PS1 +# AZURE FINOPS MULTITOOL - Enable Azure Hybrid Benefit on a VM +########################################################################### +# Purpose: Turn on Azure Hybrid Benefit (AHB) for a single VM found by +# scan_ahb_opportunities. AHB applies existing Windows Server / +# SQL licenses to cut compute licensing cost up to ~85%. +# +# Description: +# REVERSIBLE, savings-only write (you can set the license back to None). +# 1. Validates the resource id (Microsoft.Compute/virtualMachines only). +# 2. Reads the VM, detects OS, and picks the right licenseType +# (Windows -> Windows_Server) unless one is passed explicitly. +# 3. No-ops if AHB is already enabled. +# 4. Routes through the configurable write-safety gate (dry-run by +# default; token required only in Enforced mode). +# +# ── Parameters ────────────────────────────────────────────────────── +# ResourceId Full ARM resource ID of the VM +# LicenseType Override: Windows_Server | Windows_Client | +# RHEL_BYOS | SLES_BYOS (auto-detected if omitted) +# Apply Omitted = dry-run preview. Present = PATCH the VM. +# ConfirmationToken Required only in Enforced mode (from the preview) +# +# Usage: Enable-HybridBenefit -ResourceId [-Apply] +########################################################################### + +function Enable-HybridBenefit { + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Writes are gated by the explicit -Apply switch and routed through Resolve-WriteDecision (dry-run by default, mode/guardrail/confirmation-token enforcement, and audit logging).')] + param( + [Parameter(Mandatory)] + [string]$ResourceId, + + [Parameter()] + [ValidateSet('Windows_Server', 'Windows_Client', 'RHEL_BYOS', 'SLES_BYOS')] + [string]$LicenseType, + + [Parameter()] + [switch]$Apply, + + [Parameter()] + [string]$ConfirmationToken + ) + + $apiVersion = '2024-07-01' + + if ($ResourceId -notmatch '/providers/Microsoft\.Compute/virtualMachines/(?[^/]+)$') { + return [PSCustomObject]@{ + HasData = $false + Error = 'This tool only enables AHB on Microsoft.Compute/virtualMachines. Pass a VM resource ID.' + } + } + $vmName = $Matches.name + $path = "$ResourceId`?api-version=$apiVersion" + + # ---- Read the VM ---- + $getResp = Invoke-AzRestMethodWithRetry -Path $path -Method 'GET' + $getStatus = if ($getResp) { [int]$getResp.StatusCode } else { 0 } + if ($getStatus -eq 404) { + return [PSCustomObject]@{ HasData = $true; Applied = $false; ResourceId = $ResourceId; Note = 'VM not found.' } + } + if ($getStatus -lt 200 -or $getStatus -ge 300) { + $gErr = $null + if ($getResp -and $getResp.Content) { try { $gErr = ($getResp.Content | ConvertFrom-Json).error.message } catch { $gErr = $getResp.Content } } + return [PSCustomObject]@{ HasData = $false; Error = "Could not read the VM (HTTP $getStatus). $gErr"; ResourceId = $ResourceId } + } + + $vm = $null + try { $vm = $getResp.Content | ConvertFrom-Json -ErrorAction Stop } catch {} + $props = if ($vm) { $vm.properties } else { $null } + $osType = if ($props -and $props.storageProfile -and $props.storageProfile.osDisk) { [string]$props.storageProfile.osDisk.osType } else { '' } + $currentLicense = if ($props -and $props.licenseType) { [string]$props.licenseType } else { 'None' } + $location = if ($vm) { $vm.location } else { $null } + + # ---- Decide the target license type ---- + if (-not $LicenseType) { + if ($osType -ieq 'Windows') { $LicenseType = 'Windows_Server' } + elseif ($osType -ieq 'Linux') { + return [PSCustomObject]@{ + HasData = $false + Error = "VM '$vmName' is Linux. AHB for Linux requires the exact distro license (RHEL_BYOS or SLES_BYOS). Re-run with an explicit licenseType only if this VM is RHEL/SLES BYOS-eligible." + ResourceId = $ResourceId + CurrentLicense = $currentLicense + } + } + else { + return [PSCustomObject]@{ HasData = $false; Error = "Could not determine OS type for '$vmName'. Pass licenseType explicitly."; ResourceId = $ResourceId } + } + } + + # ---- Already enabled? No-op. ---- + if ($currentLicense -ieq $LicenseType) { + return [PSCustomObject]@{ + HasData = $true + Mode = 'NoOp' + Applied = $false + Note = "AHB already enabled on '$vmName' (licenseType=$currentLicense). Nothing to do." + ResourceId = $ResourceId + ResourceName = $vmName + } + } + + $subId = if ($ResourceId -match '/subscriptions/([^/]+)/') { $Matches[1] } else { $null } + $rg = if ($ResourceId -match '/resourceGroups/([^/]+)/') { $Matches[1] } else { $null } + $tagHash = @{} + if ($vm -and $vm.tags) { foreach ($t in $vm.tags.PSObject.Properties) { $tagHash[$t.Name] = $t.Value } } + + # ---- Write-safety gate (reversible, savings-only -> impact 0) ---- + $decision = Resolve-WriteDecision -ToolName 'remediate_enable_hybrid_benefit' -Operation 'EnableAHB' ` + -ResourceId $ResourceId -SubscriptionId $subId -ResourceGroup $rg -Tags $tagHash ` + -EstimatedMonthlyImpact 0 -Reversible $true -Apply:$Apply -ConfirmationToken $ConfirmationToken ` + -FingerprintExtra @{ licenseType = $LicenseType } + + if ($decision.Decision -eq 'Blocked') { + return [PSCustomObject]@{ + HasData = $false; Mode = 'Blocked'; Applied = $false; Error = $decision.Reason + GuardrailViolations = @($decision.GuardrailViolations); WriteMode = $decision.Mode + ResourceId = $ResourceId; ResourceName = $vmName + } + } + + $bodyJson = @{ properties = @{ licenseType = $LicenseType } } | ConvertTo-Json -Depth 5 + + if ($decision.Decision -eq 'Preview') { + return [PSCustomObject]@{ + HasData = $true + Mode = 'DryRun' + Applied = $false + WriteMode = $decision.Mode + Warning = "PREVIEW ONLY - the VM was not changed. This is REVERSIBLE and reduces licensing cost. $($decision.Reason)" + Method = 'PATCH' + Uri = "$(Get-FinOpsArmEndpoint)$path" + ResourceId = $ResourceId + ResourceName = $vmName + Location = $location + OsType = $osType + CurrentLicense = $currentLicense + NewLicense = $LicenseType + RequestBody = ($bodyJson | ConvertFrom-Json) + ConfirmationToken = $decision.ConfirmationToken + RequiresToken = $decision.RequiresToken + NextStep = if ($decision.RequiresToken) { + 'Enforced mode: re-run with apply=true AND confirmationToken=.' + } + else { 'Re-run remediate_enable_hybrid_benefit with apply=true to enable AHB.' } + } + } + + # ---- Proceed: PATCH the VM ---- + Write-Host " Enabling Azure Hybrid Benefit ($LicenseType) on '$vmName'..." -ForegroundColor Yellow + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method 'PATCH' -Payload $bodyJson + $status = if ($resp) { [int]$resp.StatusCode } else { 0 } + $ok = $status -in @(200, 201, 202) + $errMsg = $null + if (-not $ok) { + $errMsg = "PATCH returned $status." + if ($resp -and $resp.Content) { try { $eb = ($resp.Content | ConvertFrom-Json); if ($eb.error.message) { $errMsg += " $($eb.error.message)" } } catch {} } + } + + return [PSCustomObject]@{ + HasData = $true + Mode = 'Apply' + Applied = $ok + WriteMode = $decision.Mode + StatusCode = $status + Warning = if ($ok) { "AHB enabled ($LicenseType). Reversible - set licenseType back to None to undo." } else { $null } + Error = $errMsg + Method = 'PATCH' + Uri = "$(Get-FinOpsArmEndpoint)$path" + ResourceId = $ResourceId + ResourceName = $vmName + CurrentLicense = $currentLicense + NewLicense = $LicenseType + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 new file mode 100644 index 000000000..86c80ec03 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 @@ -0,0 +1,113 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-AHBOPPORTUNITIES.PS1 +# AZURE FINOPS MULTITOOL - Azure Hybrid Benefit Gap Detection +########################################################################### +# Purpose: Use Resource Graph to find VMs and SQL resources that are NOT +# using Azure Hybrid Benefit (AHB) but could be. AHB saves up +# to 85% on Windows Server and SQL Server licensing costs. +# +# Eligible resources: +# - Windows VMs without licenseType = 'Windows_Server' +# - SQL Server VMs without licenseType = 'AHUB' +# - SQL Databases/Managed Instances without licenseType = 'BasePrice' +# +# Reference: https://learn.microsoft.com/en-us/azure/azure-sql/azure-hybrid-benefit +########################################################################### + +function Get-AHBOpportunities { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + + # -- Windows VMs without AHB ---------------------------------------- + $windowsVMs = @() + try { + Write-Host " Scanning Windows VMs for AHB eligibility..." -ForegroundColor Cyan + $vmQuery = @" +resources +| where type == 'microsoft.compute/virtualmachines' +| where properties.storageProfile.osDisk.osType =~ 'Windows' +| where isempty(properties.licenseType) or (properties.licenseType !~ 'Windows_Server' and properties.licenseType !~ 'Windows_Client') +| project name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + currentLicense = coalesce(tostring(properties.licenseType), 'None'), + osType = tostring(properties.storageProfile.imageReference.offer) +| order by subscriptionId asc, name asc +"@ + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $windowsVMs = if ($result) { @($result.Data) } else { @() } + } catch { + Write-Warning "Windows VM AHB scan failed: $($_.Exception.Message)" + } + + # -- Estimate per-VM AHB monthly savings (per-SKU Windows license premium) -- + if ($windowsVMs.Count -gt 0 -and (Get-Command Get-AhbVmRates -ErrorAction SilentlyContinue)) { + foreach ($vm in $windowsVMs) { + $est = $null + $rates = Get-AhbVmRates -VmSize $vm.vmSize -Region $vm.location + if ($rates) { $est = [math]::Round($rates.HourlyPremium * 730, 2) } + $vm | Add-Member -NotePropertyName estMonthlySavings -NotePropertyValue $est -Force + } + } + + # -- SQL Server VMs without AHB ------------------------------------- + $sqlVMs = @() + try { + Write-Host " Scanning SQL Server VMs for AHB eligibility..." -ForegroundColor Cyan + $sqlVMQuery = @" +resources +| where type == 'microsoft.sqlvirtualmachine/sqlvirtualmachines' +| where isempty(properties.sqlServerLicenseType) or properties.sqlServerLicenseType !~ 'AHUB' +| project name, resourceGroup, subscriptionId, location, + currentLicense = coalesce(tostring(properties.sqlServerLicenseType), 'None'), + sqlEdition = tostring(properties.sqlImageSku) +| order by subscriptionId asc, name asc +"@ + $result = Search-AzGraphSafe -Query $sqlVMQuery -Subscription $subIds -First 1000 + $sqlVMs = if ($result) { @($result.Data) } else { @() } + } catch { + Write-Warning "SQL VM AHB scan failed: $($_.Exception.Message)" + } + + # -- SQL Databases without AHB -------------------------------------- + $sqlDBs = @() + try { + Write-Host " Scanning SQL Databases for AHB eligibility..." -ForegroundColor Cyan + $sqlDBQuery = @" +resources +| where type == 'microsoft.sql/servers/databases' +| where sku.tier != 'Free' and name != 'master' +| where isempty(properties.licenseType) or properties.licenseType !~ 'BasePrice' +| project name, resourceGroup, subscriptionId, location, + currentLicense = coalesce(tostring(properties.licenseType), 'LicenseIncluded'), + sku = strcat(tostring(sku.tier), ' / ', tostring(sku.name)), + maxSizeGB = tolong(properties.maxSizeBytes) / 1073741824 +| order by subscriptionId asc, name asc +"@ + $result = Search-AzGraphSafe -Query $sqlDBQuery -Subscription $subIds -First 1000 + $sqlDBs = if ($result) { @($result.Data) } else { @() } + } catch { + Write-Warning "SQL Database AHB scan failed: $($_.Exception.Message)" + } + + # -- Summary -------------------------------------------------------- + $totalOpportunities = $windowsVMs.Count + $sqlVMs.Count + $sqlDBs.Count + $ahbVMSavings = ($windowsVMs | Where-Object { $_.estMonthlySavings } | Measure-Object -Property estMonthlySavings -Sum).Sum + if (-not $ahbVMSavings) { $ahbVMSavings = 0 } + + return [PSCustomObject]@{ + WindowsVMs = $windowsVMs + SQLVMs = $sqlVMs + SQLDatabases = $sqlDBs + TotalOpportunities = $totalOpportunities + EstMonthlyVMSavings = [math]::Round($ahbVMSavings, 2) + Summary = "Found $($windowsVMs.Count) Windows VMs, $($sqlVMs.Count) SQL VMs, $($sqlDBs.Count) SQL DBs eligible for AHB" + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 new file mode 100644 index 000000000..ed1deb7cd --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -0,0 +1,365 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-AIWORKLOADMETRICS.PS1 +# AZURE FINOPS MULTITOOL - AI/LLM Workload KPIs (token economics) +########################################################################### +# Purpose: Detect AI workloads up front with one cheap ARG query, and - +# only when AI is present - pull historical token usage from +# Azure Monitor metrics and join it to Cost Management spend to +# compute AI/LLM unit-economics KPIs (cost per 1K tokens, cost +# per request, token volume by model). +########################################################################### +# Notes: +# - Gate-first: if no AI footprint exists, the module returns after a +# single ARG call (~50ms) so non-AI tenants pay almost nothing. +# - Token metrics: ProcessedPromptTokens (input), GeneratedTokens +# (output), TokenTransaction (total inference), AzureOpenAIRequests +# (call count), split by ModelDeploymentName. Window is month-to-date +# so it aligns 1:1 with the MonthToDate amortized cost query. +# - RBAC: Reader (ARG + Azure Monitor metrics) + Cost Management Reader +# (billing/MG scope) to map tokens to spend. +# - KPIs covered: Token Consumption, Cost per API Call, Cost per 1K +# tokens (effective rate). Cost per Inference is a request-count proxy. +# - Export path: when -HubData (FOCUS rows from a FinOps Hub export) is +# supplied, AI spend and billed token volume are read from the export +# instead of the Monitor + Cost Management APIs - zero live cost calls. +# Request counts are not billed line items, so cost-per-request is only +# available on the live API path. +########################################################################### + +function Get-AIWorkloadMetrics { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [object[]]$HubData + ) + + Write-Host " Detecting AI workloads..." -ForegroundColor Cyan + + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + + # -- 0: Cheap presence gate (one ARG call) ---------------------------- + # Count AI resources by type/kind. If nothing is found we bail out + # immediately so non-AI tenants never run the expensive metrics + cost + # loop below. + $openAiAccounts = [System.Collections.Generic.List[PSCustomObject]]::new() + $aiServiceCount = 0 + $mlWorkspaceCount = 0 + $searchCount = 0 + $gpuVmCount = 0 + + try { + $gateQuery = @" +resources +| extend lkind = tolower(tostring(kind)) +| where type =~ 'microsoft.cognitiveservices/accounts' + or type =~ 'microsoft.machinelearningservices/workspaces' + or type =~ 'microsoft.search/searchservices' + or (type =~ 'microsoft.compute/virtualmachines' + and tostring(properties.hardwareProfile.vmSize) matches regex @'(?i)^Standard_N') +| project id, name, type, lkind, subscriptionId, location +"@ + $result = Search-AzGraphSafe -Query $gateQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + + foreach ($r in $rows) { + switch -Wildcard ([string]$r.type) { + 'microsoft.cognitiveservices/accounts' { + # OpenAI + AIServices accounts expose token metrics. + if ($r.lkind -match 'openai' -or $r.lkind -match 'aiservices') { + [void]$openAiAccounts.Add([PSCustomObject]@{ + Id = [string]$r.id + Name = [string]$r.name + SubscriptionId = [string]$r.subscriptionId + Location = [string]$r.location + }) + } + else { $aiServiceCount++ } + } + 'microsoft.machinelearningservices/workspaces' { $mlWorkspaceCount++ } + 'microsoft.search/searchservices' { $searchCount++ } + 'microsoft.compute/virtualmachines' { $gpuVmCount++ } + } + } + } + catch { + Write-Warning " AI detection query failed: $($_.Exception.Message)" + } + + $footprint = [PSCustomObject]@{ + OpenAIAccounts = $openAiAccounts.Count + AIServices = $aiServiceCount + MLWorkspaces = $mlWorkspaceCount + SearchServices = $searchCount + GpuVmCount = $gpuVmCount + } + + $anyAI = ($openAiAccounts.Count + $aiServiceCount + $mlWorkspaceCount + $searchCount + $gpuVmCount) -gt 0 + if (-not $anyAI) { + Write-Host " No AI workloads detected - skipping AI KPIs." -ForegroundColor Gray + return [PSCustomObject]@{ + HasData = $false + AIFootprint = $footprint + ScannedSubs = $Subscriptions.Count + Note = 'No AI workloads detected in the scanned subscriptions.' + } + } + + Write-Host (" AI footprint - OpenAI/AIServices: {0} ML: {1} Search: {2} GPU VMs: {3}" -f ` + ($openAiAccounts.Count + $aiServiceCount), $mlWorkspaceCount, $searchCount, $gpuVmCount) -ForegroundColor Gray + + # -- 1: Historical token usage (Azure Monitor metrics) ---------------- + # Month-to-date so it lines up with the MonthToDate cost query. Daily + # interval keeps the payload to ~30 datapoints per account. + $now = (Get-Date).ToUniversalTime() + $monthStart = (Get-Date -Year $now.Year -Month $now.Month -Day 1 -Hour 0 -Minute 0 -Second 0).ToUniversalTime() + $fromStr = $monthStart.ToString('yyyy-MM-ddTHH:mm:ssZ') + $toStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ') + + $modelTokens = @{} # deployment -> @{ Prompt; Generated; Total } + $acctTokens = @{} # resourceId(lower) -> @{ Tokens; Requests } + $totalPrompt = 0.0 + $totalGen = 0.0 + $totalTokens = 0.0 + $totalReq = 0.0 + $metricsOk = $false + + $aiCost = 0.0 + $currency = 'USD' + $costByAcct = @{} # resourceId(lower) -> cost + $costOk = $false + + # -- Export path: derive tokens + cost from a FinOps Hub export ------- + # When FOCUS rows are supplied we read AI spend and billed token volume + # straight from the export - no Azure Monitor and no Cost Management API + # calls. Request counts are not billed line items, so cost-per-request + # is left unavailable on this path. + $fromHub = $false + $hubApprox = $false + if ($HubData -and @($HubData).Count -gt 0) { + $agg = ConvertTo-AIHubAggregates -HubData $HubData + if ($agg -and ($agg.HasCost -or $agg.HasTokens)) { + $fromHub = $true + $hubApprox = $agg.Approximate + $modelTokens = $agg.ModelTokens + $acctTokens = $agg.AcctTokens + $costByAcct = $agg.CostByAcct + $totalPrompt = $agg.TotalPrompt + $totalGen = $agg.TotalGen + $totalTokens = $agg.TotalTokens + $aiCost = $agg.AICost + $currency = $agg.Currency + $metricsOk = $agg.HasTokens + $costOk = $agg.HasCost + Write-Host (" Derived from FinOps Hub export: {0} token rows, {1} {2} AI spend" -f ` + $agg.RowCount, [math]::Round($agg.AICost, 2), $agg.Currency) -ForegroundColor Gray + } + } + + if (-not $fromHub -and $openAiAccounts.Count -gt 0) { + $token = $null + $armBase = Get-FinOpsArmEndpoint + try { $token = (Get-AzAccessToken -ResourceUrl $armBase).Token } catch { } + + if ($token) { + $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } + $metricNames = 'ProcessedPromptTokens,GeneratedTokens,TokenTransaction,AzureOpenAIRequests' + $idx = 0 + $acctCount = $openAiAccounts.Count + + foreach ($acct in $openAiAccounts) { + $idx++ + if ($acctCount -gt 5 -and ($idx -eq 1 -or $idx % [math]::Max(1, [int]($acctCount / 5)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Reading AI token metrics ($idx/$acctCount accounts)..." + } + } + + $acctKey = $acct.Id.ToLowerInvariant() + if (-not $acctTokens.ContainsKey($acctKey)) { + $acctTokens[$acctKey] = @{ Name = $acct.Name; Tokens = 0.0; Requests = 0.0 } + } + + # Split by deployment so per-model KPIs are available. The + # filter literal needs a single-quoted segment to keep the + # '$filter' token and the '*' from being touched by PowerShell. + $filterSeg = '&$filter=' + [uri]::EscapeDataString("ModelDeploymentName eq '*'") + $metricUri = "$armBase$($acct.Id)/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=$metricNames×pan=$fromStr/$toStr&aggregation=Total&interval=P1D$filterSeg" + + try { + $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 20 -ErrorAction Stop + $data = $resp.Content | ConvertFrom-Json + $metricsOk = $true + + foreach ($metric in $data.value) { + $mName = $metric.name.value + foreach ($ts in $metric.timeseries) { + # Deployment name comes from the splitting dimension. + $deployment = 'unknown' + if ($ts.metadatavalues) { + foreach ($mv in $ts.metadatavalues) { + if ($mv.name.value -match '(?i)ModelDeploymentName') { $deployment = [string]$mv.value } + } + } + $sum = 0.0 + foreach ($dp in $ts.data) { if ($null -ne $dp.total) { $sum += [double]$dp.total } } + + if (-not $modelTokens.ContainsKey($deployment)) { + $modelTokens[$deployment] = @{ Prompt = 0.0; Generated = 0.0; Total = 0.0 } + } + switch ($mName) { + 'ProcessedPromptTokens' { $modelTokens[$deployment].Prompt += $sum; $totalPrompt += $sum; $acctTokens[$acctKey].Tokens += $sum } + 'GeneratedTokens' { $modelTokens[$deployment].Generated += $sum; $totalGen += $sum; $acctTokens[$acctKey].Tokens += $sum } + 'TokenTransaction' { $modelTokens[$deployment].Total += $sum; $totalTokens += $sum } + 'AzureOpenAIRequests' { $totalReq += $sum; $acctTokens[$acctKey].Requests += $sum } + } + } + } + } + catch { + # Metrics unavailable for this account (no usage yet, or + # not an OpenAI-capable kind) - skip it. + } + } + } + } + + # When TokenTransaction is sparse, fall back to prompt + generated. + if ($totalTokens -le 0) { $totalTokens = $totalPrompt + $totalGen } + + # -- 2: Map tokens to AI spend (Cost Management) ---------------------- + # Skipped entirely on the export path - spend already came from the Hub. + if (-not $fromHub) { + try { + $mgScopeId = Resolve-CostMgId -TenantId $TenantId + if ($mgScopeId) { + $rtFilter = @{ dimensions = @{ name = 'ResourceType'; operator = 'In'; values = @('microsoft.cognitiveservices/accounts') } } + $subFilter = Get-CostSubscriptionFilter -Subscriptions $Subscriptions + $filterNode = if ($subFilter) { @{ and = @($subFilter, $rtFilter) } } else { $rtFilter } + + $body = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'ResourceId' }) + filter = $filterNode + } + } | ConvertTo-Json -Depth 12 + + $path = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $cdata = $resp.Content | ConvertFrom-Json + if ($cdata.properties.rows) { + $costOk = $true + # Column order follows properties.columns; resolve indices. + $cols = @($cdata.properties.columns.name) + $iCost = [array]::IndexOf($cols, 'Cost') + $iRes = [array]::IndexOf($cols, 'ResourceId') + $iCur = [array]::IndexOf($cols, 'Currency') + foreach ($row in $cdata.properties.rows) { + $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { [double]$row[0] } + $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } + if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] } + $aiCost += $amount + if ($rid) { $costByAcct[$rid.ToLowerInvariant()] = $amount } + } + } + } + } + } + catch { + Write-Warning " AI cost query failed: $($_.Exception.Message)" + } + } + + # -- 3: KPIs ---------------------------------------------------------- + $costPer1kTokens = if ($totalTokens -gt 0) { [math]::Round(($aiCost / $totalTokens) * 1000, 4) } else { 0 } + $costPerRequest = if ($totalReq -gt 0) { [math]::Round($aiCost / $totalReq, 4) } else { 0 } + + $byModel = @( + $modelTokens.GetEnumerator() | ForEach-Object { + $mt = if ($_.Value.Total -gt 0) { $_.Value.Total } else { $_.Value.Prompt + $_.Value.Generated } + [PSCustomObject]@{ + Deployment = $_.Key + PromptTokens = [long]$_.Value.Prompt + GeneratedTokens = [long]$_.Value.Generated + TotalTokens = [long]$mt + PctOfTokens = if ($totalTokens -gt 0) { [math]::Round(($mt / $totalTokens) * 100, 1) } else { 0 } + } + } | Sort-Object TotalTokens -Descending + ) + + $byAccount = @( + $acctTokens.GetEnumerator() | ForEach-Object { + $c = if ($costByAcct.ContainsKey($_.Key)) { $costByAcct[$_.Key] } else { 0.0 } + $tk = $_.Value.Tokens + [PSCustomObject]@{ + Name = $_.Value.Name + ResourceId = $_.Key + Tokens = [long]$tk + Requests = [long]$_.Value.Requests + Cost = [math]::Round($c, 2) + CostPer1KTokens = if ($tk -gt 0) { [math]::Round(($c / $tk) * 1000, 4) } else { 0 } + } + } | Sort-Object Cost -Descending + ) + + $hasData = $anyAI + + if ($fromHub) { + $approxNote = if ($hubApprox) { ' Token volume is approximate where the billing unit could not be parsed.' } else { '' } + if ($metricsOk -and $costOk) { + $note = "AI spend and billed token volume read from the FinOps Hub export (no live API calls). Cost per request is unavailable on the export path - request counts are not billed line items.$approxNote" + } + elseif ($costOk) { + $note = "AI spend read from the FinOps Hub export, but the export had no token-metered Azure OpenAI usage to price.$approxNote" + } + else { + $note = "AI footprint detected; the FinOps Hub export had no Cognitive Services spend in the period.$approxNote" + } + } + elseif ($metricsOk -and $costOk) { + $note = 'Token volume is month-to-date from Azure Monitor; cost is month-to-date amortized.' + } + elseif ($metricsOk) { + $note = 'Token volume available, but AI cost could not be read (Cost Management Reader needed to compute cost per token).' + } + elseif ($openAiAccounts.Count -gt 0) { + $note = 'AI accounts detected but no token usage in the current month (or metrics access unavailable).' + } + else { + $note = 'AI footprint detected (ML/Search/GPU); no token-metered Azure OpenAI usage to price.' + } + + return [PSCustomObject]@{ + HasData = $hasData + AIFootprint = $footprint + TotalPromptTokens = [long]$totalPrompt + TotalGeneratedTokens = [long]$totalGen + TotalTokens = [long]$totalTokens + TotalRequests = [long]$totalReq + TotalAICost = [math]::Round($aiCost, 2) + Currency = $currency + CostPer1KTokens = $costPer1kTokens + CostPerRequest = $costPerRequest + ByModel = $byModel + ByAccount = $byAccount + Period = 'MonthToDate' + Source = if ($fromHub) { 'FinOpsHub' } else { 'API' } + ScannedSubs = $Subscriptions.Count + Note = $note + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 new file mode 100644 index 000000000..b8149695c --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-AHBVMSAVINGSRATIO.PS1 +# AZURE FINOPS MULTITOOL - Per-SKU Azure Hybrid Benefit Savings Ratio +########################################################################### +# Purpose: Compute the real Azure Hybrid Benefit savings for a VM size by +# comparing the Windows and Linux pay-as-you-go rates from the +# Azure Retail Prices API. AHB removes the Windows Server license +# premium, so the post-AHB cost is the Linux-equivalent rate. +# +# Description: +# 1. Queries the public Retail Prices API for the SKU + region +# 2. Picks the Windows and Linux Consumption meters (skips Spot / Low Priority) +# 3. Get-AhbVmRates returns Windows/Linux rates, the remaining-cost ratio, +# and the hourly Windows license premium (Windows rate - Linux rate) +# 4. Get-AhbVmSavingsRatio returns just the ratio (falls back to 0.6 / ~40% off) +# 5. Caches per SKU+region +# +# The values are size-specific: small / burstable SKUs carry a different Windows +# license premium than a flat 40% assumption. +# +# -- Parameters ---------------------------------------------------- +# VmSize ARM SKU name, e.g. Standard_D4as_v6 +# Region ARM region name, e.g. eastus +# +# Prerequisites: +# - Outbound HTTPS to prices.azure.com (no auth required) +# +# Usage: Get-AhbVmRates -VmSize 'Standard_D4as_v6' -Region 'eastus' +########################################################################### + +function Get-AhbVmRates { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$VmSize, + [Parameter(Mandatory)][string]$Region + ) + + if (-not $script:AhbRateCache) { $script:AhbRateCache = @{} } + if ([string]::IsNullOrWhiteSpace($VmSize) -or [string]::IsNullOrWhiteSpace($Region)) { return $null } + + $key = "$VmSize|$Region".ToLowerInvariant() + if ($script:AhbRateCache.ContainsKey($key)) { return $script:AhbRateCache[$key] } + + $result = $null + try { + $filter = "armRegionName eq '$Region' and armSkuName eq '$VmSize' and priceType eq 'Consumption' and serviceName eq 'Virtual Machines'" + $url = "https://prices.azure.com/api/retail/prices?`$filter=$([uri]::EscapeDataString($filter))" + $resp = Invoke-RestMethod -Uri $url -Method GET -TimeoutSec 20 + $items = @($resp.Items) | Where-Object { + $_.unitPrice -gt 0 -and + $_.skuName -notmatch 'Spot|Low Priority' -and + $_.meterName -notmatch 'Spot|Low Priority' + } + $win = $items | Where-Object { $_.productName -match 'Windows' } | Select-Object -First 1 + $lin = $items | Where-Object { $_.productName -notmatch 'Windows' } | Select-Object -First 1 + if ($win -and $lin -and [double]$win.unitPrice -gt 0) { + $w = [double]$win.unitPrice + $l = [double]$lin.unitPrice + if ($l -gt 0 -and $l -lt $w) { + $result = [PSCustomObject]@{ + WindowsRate = $w + LinuxRate = $l + Ratio = [math]::Round($l / $w, 4) + HourlyPremium = [math]::Round($w - $l, 5) + } + } + } + } catch { + Write-Warning " AHB rate lookup failed for ${VmSize}/${Region}: $($_.Exception.Message)" + } + + $script:AhbRateCache[$key] = $result + return $result +} + +function Get-AhbVmSavingsRatio { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$VmSize, + [Parameter(Mandatory)][string]$Region + ) + + $rates = Get-AhbVmRates -VmSize $VmSize -Region $Region + if ($rates) { return $rates.Ratio } + return 0.6 # ~40% off fallback when live rates are unavailable +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 new file mode 100644 index 000000000..df4915a94 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 @@ -0,0 +1,159 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-ANOMALYALERTS.PS1 +# AZURE FINOPS MULTITOOL - Cost Management Anomaly & Budget Alerts +########################################################################### +# Purpose: Query Azure Cost Management for triggered alerts (anomaly, +# budget, forecast) and configured anomaly alert rules +# (InsightAlert scheduled actions) across all subscriptions. +########################################################################### + +function Get-AnomalyAlerts { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $subCount = $Subscriptions.Count + Write-Host " Querying anomaly & budget alerts ($subCount subs)..." -ForegroundColor Cyan + + $triggeredAlerts = [System.Collections.Generic.List[PSCustomObject]]::new() + $configuredRules = [System.Collections.Generic.List[PSCustomObject]]::new() + + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying anomaly alerts ($i/$subCount subs)..." + } + } + + # -- Triggered Cost Management alerts -- + try { + $alertPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/alerts?api-version=2023-09-01" + $resp = Invoke-AzRestMethodWithRetry -Path $alertPath -Method GET + + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + if ($data.value) { + foreach ($alert in $data.value) { + $p = $alert.properties + $def = if ($p.definition) { $p.definition } else { @{} } + $det = if ($p.details) { $p.details } else { @{} } + + $alertType = if ($def.type) { $def.type } else { 'Unknown' } + $category = if ($def.category) { $def.category } else { '' } + $criteria = if ($def.criteria) { $def.criteria } else { '' } + $status = if ($p.status) { $p.status } else { 'Unknown' } + + $amount = if ($det.amount) { [math]::Round([double]$det.amount, 2) } else { 0 } + $currentSpend = if ($det.currentSpend) { [math]::Round([double]$det.currentSpend, 2) } else { 0 } + $unit = if ($det.unit) { $det.unit } else { 'USD' } + + # Cost Management names alerts with a GUID. Derive a human + # label: prefer the alert description, then the related + # budget/scope leaf (costEntityId), then a Category/Type + # composite, and only fall back to the GUID as a last resort. + $description = if ($p.description) { [string]$p.description } else { '' } + $costEntityId = if ($p.costEntityId) { [string]$p.costEntityId } else { '' } + $relatedTo = if ($costEntityId) { ($costEntityId -split '/')[-1] } else { '' } + $alertLabel = + if ($description) { $description } + elseif ($relatedTo) { "$relatedTo ($alertType)" } + else { + $composite = (@($category, $alertType) | Where-Object { $_ -and $_ -ne 'Unknown' }) -join ' ' + if ($composite) { $composite } else { $alert.name } + } + + $contacts = @() + if ($det.contactEmails) { $contacts += @($det.contactEmails) } + if ($det.contactRoles) { $contacts += @($det.contactRoles) } + + $createdAt = '' + if ($p.creationTime) { + try { $createdAt = ([datetime]$p.creationTime).ToString('yyyy-MM-dd') } catch { $createdAt = $p.creationTime } + } + + [void]$triggeredAlerts.Add([PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + AlertName = $alert.name + AlertLabel = $alertLabel + RelatedTo = $relatedTo + Description = $description + AlertType = $alertType + Category = $category + Criteria = $criteria + Status = $status + Amount = $amount + CurrentSpend = $currentSpend + Unit = $unit + Contacts = (($contacts | Select-Object -Unique) -join ', ') + CreatedAt = $createdAt + }) + } + } + } + } catch { + Write-Warning " Alert query failed for $($sub.Name): $($_.Exception.Message)" + } + + # -- Configured anomaly alert rules (InsightAlert scheduled actions) -- + try { + $saPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/scheduledActions?api-version=2023-03-01" + $resp = Invoke-AzRestMethodWithRetry -Path $saPath -Method GET + + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + if ($data.value) { + $insightActions = @($data.value | Where-Object { $_.kind -eq 'InsightAlert' }) + foreach ($sa in $insightActions) { + $p = if ($sa.properties) { $sa.properties } else { @{} } + + $toEmails = '' + if ($p.notification -and $p.notification.to) { + $toEmails = ($p.notification.to -join ', ') + } + + $nextRun = '' + if ($p.nextRunTime) { + try { $nextRun = ([datetime]$p.nextRunTime).ToString('yyyy-MM-dd') } catch { $nextRun = $p.nextRunTime } + } + + [void]$configuredRules.Add([PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + RuleName = $sa.name + DisplayName = if ($p.displayName) { $p.displayName } else { $sa.name } + Status = if ($p.status) { $p.status } else { 'Unknown' } + Scope = if ($p.scope) { $p.scope } else { "/subscriptions/$($sub.Id)" } + ToEmails = $toEmails + NextRunTime = $nextRun + }) + } + } + } + } catch { + Write-Warning " Scheduled action query failed for $($sub.Name): $($_.Exception.Message)" + } + } + + $anomalyCount = @($triggeredAlerts | Where-Object { $_.AlertType -match 'Anomaly' }).Count + $activeCount = @($triggeredAlerts | Where-Object { $_.Status -eq 'Active' }).Count + $budgetCount = @($triggeredAlerts | Where-Object { $_.AlertType -match 'Budget' }).Count + + return [PSCustomObject]@{ + TriggeredAlerts = @($triggeredAlerts) + ConfiguredRules = @($configuredRules) + TotalAlerts = $triggeredAlerts.Count + AnomalyAlertCount = $anomalyCount + ActiveAlertCount = $activeCount + BudgetAlertCount = $budgetCount + ConfiguredRuleCount = $configuredRules.Count + HasData = ($triggeredAlerts.Count -gt 0 -or $configuredRules.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 new file mode 100644 index 000000000..6282f274f --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 @@ -0,0 +1,130 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-BILLINGACCOUNT.PS1 +# DISCOVER BILLING ACCOUNTS + COST ALLOCATION ELIGIBILITY +########################################################################### +# Purpose: Read-only lookup of billing accounts you can see, their agreement +# type, whether they support cost allocation rules, and whether you +# can reach the rules endpoint (Cost Management Contributor signal). +# Date: Created for FinOps Multitool shared-cost allocation +# +# Description: +# Lists Microsoft.Billing billing accounts visible to the signed-in identity +# and, for each, reports: +# 1. Billing account id (the path segment set_cost_allocation_rule needs) +# 2. Agreement type (EA / MCA / MPA / MOSA) +# 3. Eligibility - cost allocation rules require EA or MCA +# 4. Optional access probe - GETs the costAllocationRules endpoint so you +# learn before an apply whether you have read access there (a 403 means +# you lack Cost Management Contributor at that scope) +# +# This tool is READ-ONLY. It never writes or changes anything in Azure. +# +# ── Parameters ────────────────────────────────────────────────── +# BillingAccountId Optional filter - return only this billing account +# ProbeAccess When true (default), probe the rules endpoint per account +# +# Prerequisites: +# - Az.Accounts connected (Connect-AzAccount). Billing Reader is enough to list. +# +# Usage: Get-BillingAccount +########################################################################### + +function Get-BillingAccount { + param( + [Parameter()] + [string]$BillingAccountId, + + [Parameter()] + [bool]$ProbeAccess = $true + ) + + $billingApi = '2020-05-01' + $ruleApi = '2025-03-01' + + Write-Host ' Listing billing accounts...' -ForegroundColor Cyan + $listPath = "/providers/Microsoft.Billing/billingAccounts?api-version=$billingApi" + $resp = Invoke-AzRestMethodWithRetry -Path $listPath -Method 'GET' + + $status = if ($resp) { [int]$resp.StatusCode } else { 0 } + if ($status -ne 200) { + $msg = "Could not list billing accounts (HTTP $status)." + if ($resp -and $resp.Content) { + try { $e = $resp.Content | ConvertFrom-Json -ErrorAction Stop; if ($e.error.message) { $msg += " $($e.error.message)" } } catch { } + } + return [PSCustomObject]@{ + HasData = $false + Accounts = @() + Note = "$msg Ensure you are signed in (Connect-AzAccount) and have at least Billing Reader." + } + } + + $payload = $null + try { $payload = $resp.Content | ConvertFrom-Json -ErrorAction Stop } catch { } + $raw = @() + if ($payload -and $payload.value) { $raw = @($payload.value) } + + if ($BillingAccountId) { + $raw = @($raw | Where-Object { $_.name -eq $BillingAccountId }) + } + + if ($raw.Count -eq 0) { + return [PSCustomObject]@{ + HasData = $false + Accounts = @() + Note = if ($BillingAccountId) { "No billing account named '$BillingAccountId' is visible to this identity." } else { 'No billing accounts are visible to this identity.' } + } + } + + $eligibleTypes = @('EnterpriseAgreement', 'MicrosoftCustomerAgreement') + $accounts = @() + + foreach ($ba in $raw) { + $name = [string]$ba.name + $agreement = [string]$ba.properties.agreementType + $eligible = $eligibleTypes -contains $agreement + + $eligNote = switch ($agreement) { + 'EnterpriseAgreement' { 'Supported (EA enrollment).' } + 'MicrosoftCustomerAgreement' { 'Supported (MCA).' } + 'MicrosoftPartnerAgreement' { 'Not supported - CSP/partner agreements cannot use cost allocation rules.' } + 'MicrosoftOnlineServicesProgram' { 'Not supported - pay-as-you-go / MOSA cannot use cost allocation rules.' } + default { "Unknown agreement type '$agreement' - verify cost allocation support." } + } + + $access = $null + if ($ProbeAccess -and $eligible) { + $probePath = "/providers/Microsoft.Billing/billingAccounts/$name/providers/Microsoft.CostManagement/costAllocationRules?api-version=$ruleApi" + $pr = Invoke-AzRestMethodWithRetry -Path $probePath -Method 'GET' + $ps = if ($pr) { [int]$pr.StatusCode } else { 0 } + $access = switch ($ps) { + 200 { 'Ok - you can read cost allocation rules here (write needs Cost Management Contributor).' } + 403 { 'Forbidden - you lack access at this scope. Cost Management Contributor is required to write rules.' } + 404 { 'Not found - the cost allocation provider is not enabled or the account does not expose it.' } + default { "HTTP $ps when reading the rules endpoint." } + } + } + + $accounts += [PSCustomObject]@{ + Id = $name + DisplayName = [string]$ba.properties.displayName + AgreementType = $agreement + AccountStatus = [string]$ba.properties.accountStatus + Eligible = $eligible + EligibilityNote = $eligNote + RulesAccess = $access + } + } + + $hasNext = if ($payload.PSObject.Properties['nextLink'] -and $payload.nextLink) { $true } else { $false } + $note = 'Use Id as billingAccountId in set_cost_allocation_rule. Only Eligible=true accounts (EA/MCA) support cost allocation rules.' + if ($hasNext) { $note += ' More accounts exist beyond this page (nextLink present).' } + + return [PSCustomObject]@{ + HasData = $true + Accounts = @($accounts | Sort-Object -Property @{ Expression = 'Eligible'; Descending = $true }, 'DisplayName') + Note = $note + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 new file mode 100644 index 000000000..a7bc2d51a --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 @@ -0,0 +1,204 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-BILLINGSTRUCTURE.PS1 +# AZURE FINOPS MULTITOOL - Billing Profiles, Invoice Sections & Cost Allocation +########################################################################### +# Purpose: Retrieve billing account structure (profiles, invoice sections) +# and any configured cost allocation rules. Requires Billing Reader +# on the billing account for full data; falls back gracefully. +########################################################################### + +function Get-BillingStructure { + [CmdletBinding()] + param( + [object[]]$Subscriptions + ) + + Write-Host " Querying billing structure..." -ForegroundColor Cyan + + $billingAccounts = @() + $billingProfiles = @() + $invoiceSections = @() + $costAllocationRules = @() + + # -- Resolve billing account IDs linked to scanned subscriptions ----- + # Query ALL scanned subscriptions (not just 5) to build a complete set + # of billing accounts that belong to this tenant/scan scope. + $tenantBillingAccountNames = @{} + if ($Subscriptions) { + foreach ($sub in $Subscriptions) { + try { + $biPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Billing/billingInfo/default?api-version=2024-04-01" + $biResp = Invoke-AzRestMethodWithRetry -Path $biPath -Method GET + if ($biResp.StatusCode -eq 200) { + $biResult = ($biResp.Content | ConvertFrom-Json) + $baId = $biResult.properties.billingAccountId + if ($baId) { + # Normalize: extract the account name portion after /billingAccounts/ + $baName = ($baId -replace '(?i).*/billingAccounts/', '').Trim('/') + if ($baName) { $tenantBillingAccountNames[$baName] = $true } + } + } + } catch { } + } + Write-Host " Resolved $($tenantBillingAccountNames.Count) billing account(s) linked to scanned subscriptions." -ForegroundColor Cyan + } + + # -- Step 1: Get Billing Accounts ----------------------------------- + try { + $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" + $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET + if ($baResp.StatusCode -eq 200) { + $baResult = ($baResp.Content | ConvertFrom-Json) + if ($baResult.value) { + foreach ($ba in $baResult.value) { + # Filter to billing accounts associated with scanned subscriptions + if ($tenantBillingAccountNames.Count -gt 0 -and -not $tenantBillingAccountNames.ContainsKey($ba.name)) { + continue + } + # If no billing account names resolved at all, skip rather than showing everything + if ($tenantBillingAccountNames.Count -eq 0) { + Write-Warning " Could not resolve any billing account IDs from subscriptions — skipping billing account: $($ba.properties.displayName)" + continue + } + $props = $ba.properties + $billingAccounts += [PSCustomObject]@{ + AccountId = $ba.name + DisplayName = $props.displayName + AgreementType = $props.agreementType + AccountType = $props.accountType + AccountStatus = $props.accountStatus + FullId = $ba.id + } + } + } + } else { + Write-Warning " Billing accounts returned HTTP $($baResp.StatusCode)" + } + } catch { + Write-Warning " Billing accounts query failed: $($_.Exception.Message)" + } + + # -- Step 2: Get Billing Profiles (MCA only) ------------------------ + foreach ($ba in $billingAccounts) { + if ($ba.AgreementType -notin @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { + continue + } + try { + $bpPath = "$($ba.FullId)/billingProfiles?api-version=2024-04-01" + $bpResp = Invoke-AzRestMethodWithRetry -Path $bpPath -Method GET + if ($bpResp.StatusCode -eq 200) { + $bpResult = ($bpResp.Content | ConvertFrom-Json) + if ($bpResult.value) { + foreach ($bp in $bpResult.value) { + $bpProps = $bp.properties + $billingProfiles += [PSCustomObject]@{ + ProfileId = $bp.name + DisplayName = $bpProps.displayName + BillingAccount = $ba.DisplayName + Currency = $bpProps.currency + InvoiceDay = $bpProps.invoiceDay + Status = $bpProps.status + FullId = $bp.id + } + + # -- Step 3: Invoice Sections per Profile ------- + try { + $isPath = "$($bp.id)/invoiceSections?api-version=2024-04-01" + $isResp = Invoke-AzRestMethodWithRetry -Path $isPath -Method GET + if ($isResp.StatusCode -eq 200) { + $isResult = ($isResp.Content | ConvertFrom-Json) + if ($isResult.value) { + foreach ($section in $isResult.value) { + $sProps = $section.properties + $invoiceSections += [PSCustomObject]@{ + SectionId = $section.name + DisplayName = $sProps.displayName + BillingProfile = $bpProps.displayName + BillingAccount = $ba.DisplayName + State = $sProps.state + SystemId = $sProps.systemId + FullId = $section.id + } + } + } + } + } catch { + Write-Warning " Invoice sections query failed for profile $($bpProps.displayName): $($_.Exception.Message)" + } + } + } + } + } catch { + Write-Warning " Billing profiles query failed: $($_.Exception.Message)" + } + } + + # -- Step 4: EA Departments & Enrollment Accounts (EA only) --------- + $eaDepartments = @() + foreach ($ba in $billingAccounts) { + if ($ba.AgreementType -ne 'EnterpriseAgreement') { continue } + try { + $deptPath = "$($ba.FullId)/departments?api-version=2024-04-01" + $deptResp = Invoke-AzRestMethodWithRetry -Path $deptPath -Method GET + if ($deptResp.StatusCode -eq 200) { + $deptResult = ($deptResp.Content | ConvertFrom-Json) + if ($deptResult.value) { + foreach ($dept in $deptResult.value) { + $dProps = $dept.properties + $eaDepartments += [PSCustomObject]@{ + DepartmentId = $dept.name + DisplayName = $dProps.displayName + BillingAccount = $ba.DisplayName + CostCenter = $dProps.costCenter + Status = $dProps.status + } + } + } + } + } catch { + Write-Warning " EA departments query failed: $($_.Exception.Message)" + } + } + + # -- Step 5: Cost Allocation Rules ---------------------------------- + foreach ($ba in $billingAccounts) { + try { + $carPath = "$($ba.FullId)/providers/Microsoft.CostManagement/costAllocationRules?api-version=2023-11-01" + $carResp = Invoke-AzRestMethodWithRetry -Path $carPath -Method GET + if ($carResp.StatusCode -eq 200) { + $carResult = ($carResp.Content | ConvertFrom-Json) + if ($carResult.value) { + foreach ($rule in $carResult.value) { + $rProps = $rule.properties + $costAllocationRules += [PSCustomObject]@{ + RuleName = $rProps.name + Description = $rProps.description + Status = $rProps.status + BillingAccount = $ba.DisplayName + SourceCount = if ($rProps.details.sourceResources) { $rProps.details.sourceResources.Count } else { 0 } + TargetCount = if ($rProps.details.targetResources) { $rProps.details.targetResources.Count } else { 0 } + CreatedDate = $rProps.createdDate + UpdatedDate = $rProps.updatedDate + } + } + } + } elseif ($carResp.StatusCode -ne 404) { + Write-Warning " Cost allocation rules returned HTTP $($carResp.StatusCode)" + } + } catch { + Write-Warning " Cost allocation rules query failed: $($_.Exception.Message)" + } + } + + return [PSCustomObject]@{ + BillingAccounts = $billingAccounts + BillingProfiles = $billingProfiles + InvoiceSections = $invoiceSections + EADepartments = $eaDepartments + CostAllocationRules = $costAllocationRules + HasBillingAccess = ($billingAccounts.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 new file mode 100644 index 000000000..95f444743 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -0,0 +1,367 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-BUDGETSTATUS.PS1 +# AZURE FINOPS MULTITOOL - Budget vs. Actual Comparison +########################################################################### +# Purpose: Query Azure Budgets (Consumption API) for each subscription to +# show configured budget amount vs current spend. Highlights +# subscriptions at risk of overrun. +########################################################################### + +function Get-BudgetStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions, + + [Parameter()] + $CostData # Existing cost data keyed by subscription ID + ) + + # Guard: extract hashtable if pipeline pollution wrapped it in an array + if ($CostData -and $CostData -isnot [hashtable]) { + $CostData = @($CostData | Where-Object { $_ -is [hashtable] })[-1] + } + if (-not $CostData) { $CostData = @{} } + + $subCount = $Subscriptions.Count + Write-Host " Querying budget status ($subCount subs)..." -ForegroundColor Cyan + + $budgets = [System.Collections.Generic.List[PSCustomObject]]::new() + $subsWithBudget = 0 + $subsWithoutBudget = 0 + $sampled = $false + + # -- For large tenants, sample first to see if budgets exist -------- + $subsToQuery = $Subscriptions + if ($subCount -gt 50) { + $sampleSize = [math]::Min(10, $subCount) + Write-Host " Large tenant: sampling $sampleSize of $subCount subs for budgets..." -ForegroundColor Yellow + $sampleSubs = $Subscriptions | Select-Object -First $sampleSize + $sampleHits = 0 + foreach ($sub in $sampleSubs) { + try { + $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01" + $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET + if ($resp.StatusCode -eq 200) { + $sampleBudgets = ($resp.Content | ConvertFrom-Json).value + if ($sampleBudgets -and $sampleBudgets.Count -gt 0) { $sampleHits++ } + } + } + catch { } + } + + if ($sampleHits -eq 0) { + Write-Host " No budgets found in sample of $sampleSize subs - skipping remaining" -ForegroundColor Yellow + $sampled = $true + $subsWithoutBudget = $subCount + $subsToQuery = @() # Skip the main loop + } + else { + Write-Host " Budgets found in sample ($sampleHits/$sampleSize), querying all $subCount subs..." -ForegroundColor Cyan + } + } + + $i = 0 + foreach ($sub in $subsToQuery) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying budgets ($i/$subCount subs)..." + } + } + try { + $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01" + $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET + + if ($resp.StatusCode -eq 200) { + $data = ($resp.Content | ConvertFrom-Json) + if ($data.value -and $data.value.Count -gt 0) { + $subsWithBudget++ + foreach ($budget in $data.value) { + $bp = $budget.properties + $amount = [math]::Round([double]$bp.amount, 2) + $timeGrain = $bp.timeGrain + $category = $bp.category + + # Current spend from our existing cost data + $actualSpend = 0 + $forecast = 0 + $spendKnown = ($CostData -and $CostData.ContainsKey($sub.Id)) + if ($spendKnown) { + $actualSpend = [math]::Round($CostData[$sub.Id].Actual, 2) + $forecast = [math]::Round($CostData[$sub.Id].Forecast, 2) + } + + # Calculate % used + $pctUsed = if ($amount -gt 0) { [math]::Round(($actualSpend / $amount) * 100, 1) } else { 0 } + $pctForecast = if ($amount -gt 0) { [math]::Round(($forecast / $amount) * 100, 1) } else { 0 } + + # Risk level + # 'Unknown' means we could not read current spend for this + # subscription, so we must NOT claim the budget is On Track. + # 'Over Budget' means actual spend has already exceeded the budget. + # 'Forecast Over' means actual is still within budget but the + # month-end forecast is projected to exceed it (early warning). + $risk = if (-not $spendKnown) { 'Unknown' } + elseif ($pctUsed -gt 100) { 'Over Budget' } + elseif ($pctForecast -gt 100) { 'Forecast Over' } + elseif ($pctForecast -gt 90) { 'At Risk' } + elseif ($pctForecast -gt 75) { 'Watch' } + else { 'On Track' } + + # Notification thresholds and contacts + $thresholds = @() + $contactEmails = @() + $contactRoles = @() + if ($bp.notifications) { + foreach ($notif in $bp.notifications.PSObject.Properties) { + $np = $notif.Value + $thresholds += "$($np.threshold)% ($($np.operator))" + if ($np.contactEmails) { $contactEmails += @($np.contactEmails) } + if ($np.contactRoles) { $contactRoles += @($np.contactRoles) } + } + } + + # Extract tag filters from budget filter property + $tagFilters = @() + if ($bp.filter -and $bp.filter.tags) { + foreach ($tagProp in $bp.filter.tags.PSObject.Properties) { + $tagKey = $tagProp.Name + $tagVals = @() + if ($tagProp.Value -and $tagProp.Value.values) { + $tagVals = @($tagProp.Value.values) + } + $tagFilters += "$tagKey=$($tagVals -join '|')" + } + } + if ($bp.filter -and $bp.filter.dimensions) { + foreach ($dimProp in $bp.filter.dimensions.PSObject.Properties) { + if ($dimProp.Name -match '^Tag') { + $dimName = $dimProp.Name -replace '^Tag', '' + $dimVals = if ($dimProp.Value.values) { @($dimProp.Value.values) } else { @() } + $tagFilters += "$dimName=$($dimVals -join '|')" + } + } + } + $tagFilterStr = $tagFilters -join '; ' + + [void]$budgets.Add([PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + BudgetName = $budget.name + Amount = $amount + TimeGrain = $timeGrain + Category = $category + ActualSpend = $actualSpend + Forecast = $forecast + PctUsed = $pctUsed + PctForecast = $pctForecast + Risk = $risk + Thresholds = ($thresholds -join ', ') + ContactEmails = (($contactEmails | Select-Object -Unique) -join ', ') + ContactRoles = (($contactRoles | Select-Object -Unique) -join ', ') + TagFilter = $tagFilterStr + Currency = if ($CostData -and $CostData.ContainsKey($sub.Id)) { $CostData[$sub.Id].Currency } else { 'USD' } + }) + } + } + else { + $subsWithoutBudget++ + } + } + else { + $subsWithoutBudget++ + } + } + catch { + Write-Warning " Budget query failed for $($sub.Name): $($_.Exception.Message)" + $subsWithoutBudget++ + } + } + + # Count risk levels + # OverBudgetCount = actual spend already exceeded budget (urgent / red). + # AtRiskCount = forecast-driven warnings (projected over, or trending high). + $overBudget = @($budgets | Where-Object { $_.Risk -eq 'Over Budget' }).Count + $atRisk = @($budgets | Where-Object { $_.Risk -in @('Forecast Over', 'At Risk') }).Count + + return [PSCustomObject]@{ + Budgets = @($budgets) + TotalBudgets = $budgets.Count + SubsWithBudget = $subsWithBudget + SubsWithoutBudget = $subsWithoutBudget + OverBudgetCount = $overBudget + AtRiskCount = $atRisk + HasData = ($budgets.Count -gt 0) + Sampled = $sampled + BudgetCoverage = if ($Subscriptions.Count -gt 0) { + [math]::Round(($subsWithBudget / $Subscriptions.Count) * 100, 1) + } + else { 0 } + } +} + +function Get-BudgetHistory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Budgets, + + [Parameter()] + [int]$MonthsBack = 6, + + # Optional Cost Trend result (from Get-CostTrend). When supplied, its + # already-fetched per-subscription monthly spend is reused instead of + # re-querying the (throttle-prone) Cost Management Query API. + [Parameter()] + [object]$CostTrend + ) + + if (-not $Budgets -or $Budgets.Count -eq 0) { return @() } + + $history = [System.Collections.Generic.List[PSCustomObject]]::new() + + # Build a sub -> { 'yyyy-MM' = cost } lookup from Cost Trend so Budget + # History can avoid hitting Cost Management again. Cost Trend already + # fetched the last 6 months of monthly spend per subscription — exactly the + # data Budget History needs — so reusing it eliminates the redundant, + # 429-prone per-sub queries that were leaving history empty under throttle. + $trendBySub = @{} + if ($CostTrend -and $CostTrend.BySubscription) { + foreach ($k in @($CostTrend.BySubscription.Keys)) { + $lookup = @{} + foreach ($m in $CostTrend.BySubscription[$k]) { + if ($m.MonthDate -is [datetime]) { + $lookup[$m.MonthDate.ToString('yyyy-MM')] = [math]::Round([double]$m.Cost, 2) + } + } + if ($lookup.Count -gt 0) { $trendBySub[$k] = $lookup } + } + } + + # Group budgets by subscription to minimize API calls + $bySubId = $Budgets | Group-Object SubscriptionId + + foreach ($subGroup in $bySubId) { + $subId = $subGroup.Name + $subName = $subGroup.Group[0].Subscription + + # Prefer reusing Cost Trend data (zero extra API calls). Fall back to a + # live per-sub Cost Management query only when trend data is missing. + $monthlyCosts = $null + if ($trendBySub.ContainsKey($subId)) { + $monthlyCosts = $trendBySub[$subId] + } + else { + # Query monthly costs for this sub over the last N months + $startDate = (Get-Date).AddMonths(-$MonthsBack).ToString('yyyy-MM-01') + $endDate = (Get-Date -Day 1).AddDays(-1).ToString('yyyy-MM-dd') # Last day of previous month + + $body = @{ + type = 'ActualCost' + timeframe = 'Custom' + timePeriod = @{ from = $startDate; to = $endDate } + dataset = @{ + granularity = 'Monthly' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + } | ConvertTo-Json -Depth 10 + + $costPath = "/subscriptions/$subId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + try { + $resp = Invoke-AzRestMethodWithRetry -Path $costPath -Method POST -Payload $body + if ($resp.StatusCode -ne 200) { continue } + + $result = ($resp.Content | ConvertFrom-Json) + if (-not $result.properties -or -not $result.properties.rows) { continue } + + # Parse columns + $cols = $result.properties.columns + $costIdx = -1; $dateIdx = -1; $currIdx = -1 + for ($i = 0; $i -lt $cols.Count; $i++) { + $n = $cols[$i].name.ToLower() + if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'pretaxcost') { $costIdx = $i } + elseif ($n -match 'billingmonth|usagedate') { $dateIdx = $i } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $i } + } + if ($costIdx -eq -1) { $costIdx = 0 } + if ($dateIdx -eq -1) { $dateIdx = 1 } + if ($currIdx -eq -1) { $currIdx = 2 } + + # Build month → cost lookup + $monthlyCosts = @{} + foreach ($row in $result.properties.rows) { + $cost = [math]::Round([double]$row[$costIdx], 2) + $rawDate = $row[$dateIdx] + # Parse date robustly — API may return a [datetime], a YYYYMMDD + # integer (e.g. 20260101), or a locale-formatted string + # (e.g. "1/1/2026 12:00:00 AM"). Avoid substring slicing, which + # mangles non-ISO date formats and zeroes out actual spend. + $parsed = $null + if ($rawDate -is [datetime]) { + $parsed = $rawDate + } + else { + $dateStr = "$rawDate" + $digitsOnly = $dateStr -replace '[^0-9]', '' + if ($digitsOnly.Length -eq 8 -and $dateStr -notmatch '[/\-:]') { + $parsed = [datetime]::ParseExact($digitsOnly, 'yyyyMMdd', $null) + } + else { + try { $parsed = [datetime]::Parse($dateStr) } catch { continue } + } + } + $monthKey = $parsed.ToString('yyyy-MM') + $monthlyCosts[$monthKey] = $cost + } + } + catch { + Write-Warning " Budget history query failed for $subName : $($_.Exception.Message)" + continue + } + } + + if (-not $monthlyCosts -or $monthlyCosts.Count -eq 0) { continue } + + # Now create history rows per budget per month + foreach ($budget in $subGroup.Group) { + $budgetAmount = [double]$budget.Amount + # For quarterly/annual budgets, pro-rate to monthly equivalent + $monthlyAmount = switch ($budget.TimeGrain) { + 'Quarterly' { [math]::Round($budgetAmount / 3, 2) } + 'Annually' { [math]::Round($budgetAmount / 12, 2) } + default { $budgetAmount } + } + + for ($m = $MonthsBack; $m -ge 1; $m--) { + $monthDate = (Get-Date).AddMonths(-$m) + $monthKey = $monthDate.ToString('yyyy-MM') + $monthLabel = $monthDate.ToString('MMM yyyy') + $actual = if ($monthlyCosts.ContainsKey($monthKey)) { $monthlyCosts[$monthKey] } else { 0 } + $pctUsed = if ($monthlyAmount -gt 0) { [math]::Round(($actual / $monthlyAmount) * 100, 1) } else { 0 } + $status = if ($pctUsed -gt 100) { 'Over' } + elseif ($pctUsed -gt 90) { 'Near Limit' } + else { 'Under' } + + [void]$history.Add([PSCustomObject]@{ + Subscription = $subName + BudgetName = $budget.BudgetName + Month = $monthLabel + MonthSort = $monthKey + BudgetAmount = $monthlyAmount + ActualSpend = $actual + PctUsed = $pctUsed + Status = $status + Currency = $budget.Currency + }) + } + } + } + + return @($history | Sort-Object Subscription, BudgetName, MonthSort) +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 new file mode 100644 index 000000000..f8de8d949 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 @@ -0,0 +1,224 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-CARBONMETRICS.PS1 +# AZURE FINOPS MULTITOOL - Carbon Emissions (Sustainability) +########################################################################### +# Purpose: Query the Azure Carbon Optimization service for greenhouse-gas +# emissions (kgCO2e) across subscriptions: latest-month total, +# month-over-month change, and a monthly trend. Supports FinOps +# sustainability KPIs (carbon footprint, emissions trend). +########################################################################### +# Notes: +# - Endpoint: POST /providers/Microsoft.Carbon/carbonEmissionReports +# (tenant scope), api-version 2025-04-01. subscriptionList goes in the +# body (max 100 per call, lowercase ids) so we batch in chunks of 100. +# - Emissions data lags ~2 months and is only available for months that +# have been published. We probe recent month windows and walk back +# until the service returns data, then degrade gracefully if none. +# - RBAC: Reader (or Carbon Optimization Reader) on each subscription. +########################################################################### + +function Get-CarbonMetrics { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $apiVersion = '2025-04-01' + $carbonPath = "/providers/Microsoft.Carbon/carbonEmissionReports?api-version=$apiVersion" + $scopeList = @('Scope1', 'Scope2', 'Scope3') + + $subCount = $Subscriptions.Count + Write-Host " Querying carbon emissions ($subCount subs)..." -ForegroundColor Cyan + + # All subscription ids, lowercased, batched into <=100 per request. + $allIds = @($Subscriptions | ForEach-Object { ([string]$_.Id).ToLower() }) + $batches = [System.Collections.Generic.List[object]]::new() + for ($b = 0; $b -lt $allIds.Count; $b += 100) { + $end = [math]::Min($b + 99, $allIds.Count - 1) + [void]$batches.Add(@($allIds[$b..$end])) + } + + # -- Resolve an available emissions window ---------------------------- + # Carbon data publishes ~2 months in arrears. Probe candidate "latest" + # months (current-1 .. current-4, first of month) using the first + # subscription batch; the first window that returns data is reused for + # every batch. Returns $null when no recent window has data. + $firstOfMonth = Get-Date -Day 1 -Hour 0 -Minute 0 -Second 0 + $window = $null + foreach ($lag in 1..4) { + $endMonth = $firstOfMonth.AddMonths(-$lag) + $startMonth = $endMonth.AddMonths(-11) + $probeBody = @{ + reportType = 'OverallSummaryReport' + subscriptionList = $batches[0] + carbonScopeList = $scopeList + dateRange = @{ + start = $startMonth.ToString('yyyy-MM-dd') + end = $endMonth.ToString('yyyy-MM-dd') + } + } | ConvertTo-Json -Depth 6 + + try { + $probe = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $probeBody + } + catch { + $probe = $null + } + + if ($probe -and $probe.StatusCode -eq 200) { + $window = @{ Start = $startMonth; End = $endMonth } + break + } + + # 404/400 here usually means "no data for that window" or the + # provider isn't registered/available - try an earlier window. + } + + if (-not $window) { + Write-Host " No carbon emissions data available (provider unavailable or no published months)." -ForegroundColor DarkGray + return [PSCustomObject]@{ + HasData = $false + TotalEmissionsKg = 0 + PreviousMonthKg = 0 + ChangeRatio = 0 + ChangeValueKg = 0 + LatestMonth = $null + MonthlyTrend = @() + BySubscription = @() + Unit = 'kgCO2e' + ScannedSubs = $subCount + Note = 'Carbon Optimization returned no data. Requires Reader on subscriptions; emissions publish ~2 months in arrears.' + } + } + + $startStr = $window.Start.ToString('yyyy-MM-dd') + $endStr = $window.End.ToString('yyyy-MM-dd') + + $totalLatest = 0.0 + $totalPrevious = 0.0 + $monthlyTotals = @{} # key 'yyyy-MM' -> kgCO2e + $bySub = [System.Collections.Generic.List[PSCustomObject]]::new() + + $batchNo = 0 + foreach ($batch in $batches) { + $batchNo++ + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying carbon emissions (batch $batchNo/$($batches.Count))..." + } + + # -- Overall summary (headline latest + previous month) ----------- + $overallBody = @{ + reportType = 'OverallSummaryReport' + subscriptionList = $batch + carbonScopeList = $scopeList + dateRange = @{ start = $startStr; end = $endStr } + } | ConvertTo-Json -Depth 6 + + try { + $resp = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $overallBody + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + foreach ($row in @($data.value)) { + $latest = if ($null -ne $row.latestMonthEmissions) { [double]$row.latestMonthEmissions } else { 0 } + $previous = if ($null -ne $row.previousMonthEmissions) { [double]$row.previousMonthEmissions } else { 0 } + $totalLatest += $latest + $totalPrevious += $previous + } + } + } + catch { + Write-Warning " Carbon overall query failed (batch $batchNo): $($_.Exception.Message)" + } + + # -- Per-subscription summary (ItemDetails by subscription) ------- + $itemBody = @{ + reportType = 'ItemDetailsReport' + subscriptionList = $batch + carbonScopeList = $scopeList + categoryType = 'Subscription' + orderBy = 'LatestMonthEmissions' + sortDirection = 'Desc' + pageSize = 100 + dateRange = @{ start = $endStr; end = $endStr } + } | ConvertTo-Json -Depth 6 + + try { + $resp = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $itemBody + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + foreach ($row in @($data.value)) { + $subId = if ($row.itemName) { [string]$row.itemName } else { '' } + $subObj = $Subscriptions | Where-Object { ([string]$_.Id).ToLower() -eq $subId.ToLower() } | Select-Object -First 1 + $latest = if ($null -ne $row.latestMonthEmissions) { [double]$row.latestMonthEmissions } else { 0 } + [void]$bySub.Add([PSCustomObject]@{ + Subscription = if ($subObj) { $subObj.Name } else { $subId } + SubscriptionId = $subId + EmissionsKg = [math]::Round($latest, 3) + }) + } + } + } + catch { + Write-Warning " Carbon per-subscription query failed (batch $batchNo): $($_.Exception.Message)" + } + + # -- Monthly trend (12-month series) ------------------------------ + $monthlyBody = @{ + reportType = 'MonthlySummaryReport' + subscriptionList = $batch + carbonScopeList = $scopeList + dateRange = @{ start = $startStr; end = $endStr } + } | ConvertTo-Json -Depth 6 + + try { + $resp = Invoke-AzRestMethodWithRetry -Path $carbonPath -Method POST -Payload $monthlyBody + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + foreach ($row in @($data.value)) { + $month = '' + if ($row.date) { try { $month = ([datetime]$row.date).ToString('yyyy-MM') } catch { $month = [string]$row.date } } + elseif ($row.month) { $month = [string]$row.month } + if (-not $month) { continue } + $val = if ($null -ne $row.totalCarbonEmission) { [double]$row.totalCarbonEmission } + elseif ($null -ne $row.carbonEmission) { [double]$row.carbonEmission } + elseif ($null -ne $row.latestMonthEmissions) { [double]$row.latestMonthEmissions } + else { 0 } + if (-not $monthlyTotals.ContainsKey($month)) { $monthlyTotals[$month] = 0.0 } + $monthlyTotals[$month] += $val + } + } + } + catch { + Write-Warning " Carbon monthly query failed (batch $batchNo): $($_.Exception.Message)" + } + } + + $trend = @( + $monthlyTotals.Keys | Sort-Object | ForEach-Object { + [PSCustomObject]@{ Month = $_; EmissionsKg = [math]::Round($monthlyTotals[$_], 3) } + } + ) + + $changeValue = $totalLatest - $totalPrevious + $changeRatio = if ($totalPrevious -ne 0) { [math]::Round(($changeValue / $totalPrevious) * 100, 1) } else { 0 } + + $hasData = ($totalLatest -gt 0) -or ($trend.Count -gt 0) -or ($bySub.Count -gt 0) + + return [PSCustomObject]@{ + HasData = $hasData + TotalEmissionsKg = [math]::Round($totalLatest, 3) + PreviousMonthKg = [math]::Round($totalPrevious, 3) + ChangeValueKg = [math]::Round($changeValue, 3) + ChangeRatio = $changeRatio + LatestMonth = $window.End.ToString('yyyy-MM') + MonthlyTrend = $trend + BySubscription = @($bySub | Sort-Object EmissionsKg -Descending) + Unit = 'kgCO2e' + ScannedSubs = $subCount + Note = if ($hasData) { $null } else { 'No emissions returned for the available window.' } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 new file mode 100644 index 000000000..35d0d6eef --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 @@ -0,0 +1,309 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-COMMITMENTUTILIZATION.PS1 +# AZURE FINOPS MULTITOOL - RI & Savings Plan Utilization +########################################################################### +# Purpose: Query existing reservation and savings plan utilization to show +# how well current commitments are being used. This answers the +# CFO question: "Are we wasting what we already bought?" +########################################################################### + +function Get-CommitmentUtilization { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions, + + [Parameter()] + [string]$AgreementType + ) + + Write-Host " Querying commitment utilization..." -ForegroundColor Cyan + + $reservations = @() + $savingsPlans = @() + $subIds = $Subscriptions | ForEach-Object { $_.Id } + + # Set to $true if a reservation/savings-plan query is forbidden (401/403) + # rather than simply returning no commitments. + $accessDenied = $false + + # -- Step 0 (MCA/MPA): Resolve billing profiles for this tenant ----- + # Under MCA, reservations and savings plans are scoped to the billing + # profile, NOT the subscription. Subscription-level Consumption API + # calls return empty for MCA agreements. + $billingProfileIds = @() + if ($AgreementType -in @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { + Write-Host " MCA/MPA detected — resolving billing profiles..." -ForegroundColor Cyan + # Discover billing account IDs linked to scanned subscriptions + $tenantBillingAccountIds = @{} + foreach ($sub in @($Subscriptions | Select-Object -First 10)) { + try { + $biPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Billing/billingInfo/default?api-version=2024-04-01" + $biResp = Invoke-AzRestMethodWithRetry -Path $biPath -Method GET + if ($biResp.StatusCode -eq 200) { + $biResult = ($biResp.Content | ConvertFrom-Json) + $baId = $biResult.properties.billingAccountId + if ($baId) { $tenantBillingAccountIds[$baId] = $true } + } + } catch { } + } + + # Get billing profiles for those accounts + try { + $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" + $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET + if ($baResp.StatusCode -eq 200) { + $baResult = ($baResp.Content | ConvertFrom-Json) + foreach ($ba in $baResult.value) { + if ($tenantBillingAccountIds.Count -gt 0 -and -not $tenantBillingAccountIds.ContainsKey($ba.id)) { + # Normalize — try matching on name portion only + $baNamePortion = $ba.id -replace '.*/billingAccounts/', '' + $matched = $false + foreach ($k in $tenantBillingAccountIds.Keys) { + $kName = $k -replace '.*/billingAccounts/', '' + if ($kName -eq $baNamePortion) { $matched = $true; break } + } + if (-not $matched) { continue } + } + if ($ba.properties.agreementType -notin @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { continue } + try { + $bpPath = "$($ba.id)/billingProfiles?api-version=2024-04-01" + $bpResp = Invoke-AzRestMethodWithRetry -Path $bpPath -Method GET + if ($bpResp.StatusCode -eq 200) { + $bpResult = ($bpResp.Content | ConvertFrom-Json) + foreach ($bp in $bpResult.value) { $billingProfileIds += $bp.id } + } + } catch { } + } + } + } catch { + Write-Warning " Billing profile resolution failed: $($_.Exception.Message)" + } + Write-Host " Found $($billingProfileIds.Count) billing profile(s) for MCA commitment queries." -ForegroundColor Cyan + } + + # -- Step 1: Get all reservations and their utilization -------------- + # For MCA: query at billing-profile scope first + if ($billingProfileIds.Count -gt 0) { + foreach ($bpId in $billingProfileIds) { + try { + $summaryPath = "$bpId/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'" + $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET + if ($resp.StatusCode -eq 200) { + $data = ($resp.Content | ConvertFrom-Json) + if ($data.value) { + foreach ($item in $data.value) { + $p = $item.properties + $reservations += [PSCustomObject]@{ + ReservationOrderId = $p.reservationOrderId + ReservationId = $p.reservationId + SkuName = $p.skuName + Kind = $p.kind + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1) + MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1) + ReservedHours = $p.reservedHours + UsedHours = $p.usedHours + UsageDate = $p.usageDate + } + } + } + } + elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Reservation query at billing profile scope failed: $($_.Exception.Message)" + } + } + } + + # For EA / fallback: query at subscription scope + if ($reservations.Count -eq 0) { + foreach ($sub in $Subscriptions | Select-Object -First 10) { + try { + $summaryPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'" + $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET + if ($resp.StatusCode -eq 200) { + $data = ($resp.Content | ConvertFrom-Json) + if ($data.value) { + foreach ($item in $data.value) { + $p = $item.properties + $reservations += [PSCustomObject]@{ + ReservationOrderId = $p.reservationOrderId + ReservationId = $p.reservationId + SkuName = $p.skuName + Kind = $p.kind + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + MinUtilization = [math]::Round([double]$p.minUtilizationPercentage, 1) + MaxUtilization = [math]::Round([double]$p.maxUtilizationPercentage, 1) + ReservedHours = $p.reservedHours + UsedHours = $p.usedHours + UsageDate = $p.usageDate + } + } + break # Got data from one sub, don't repeat + } + } + elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Reservation summaries query failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Step 2: Try the Reservation Orders API at billing scope -- + if ($reservations.Count -eq 0) { + try { + $roPath = "/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $roPath -Method GET + if ($resp.StatusCode -eq 200) { + $data = ($resp.Content | ConvertFrom-Json) + if ($data.value) { + foreach ($order in $data.value) { + $op = $order.properties + if ($op.reservations) { + foreach ($ri in $op.reservations) { + # Get utilization summary for each reservation + try { + $utilPath = "$($ri.id)/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'" + $utilResp = Invoke-AzRestMethodWithRetry -Path $utilPath -Method GET + if ($utilResp.StatusCode -eq 200) { + $utilData = ($utilResp.Content | ConvertFrom-Json) + if ($utilData.value -and $utilData.value.Count -gt 0) { + $latest = $utilData.value | Select-Object -Last 1 + $up = $latest.properties + $reservations += [PSCustomObject]@{ + ReservationOrderId = $order.name + ReservationId = $ri.id.Split('/')[-1] + SkuName = $op.displayProvisioningState + Kind = $op.billingScopeId + AvgUtilization = [math]::Round([double]$up.avgUtilizationPercentage, 1) + MinUtilization = [math]::Round([double]$up.minUtilizationPercentage, 1) + MaxUtilization = [math]::Round([double]$up.maxUtilizationPercentage, 1) + ReservedHours = $up.reservedHours + UsedHours = $up.usedHours + UsageDate = $up.usageDate + } + } + } + } catch { } + } + } + } + } + } + elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Reservation orders query failed: $($_.Exception.Message)" + } + } + + # -- Step 3: Savings Plans utilization via Benefit Utilization Summaries -- + # For MCA: query at billing-profile scope first + if ($billingProfileIds.Count -gt 0 -and $savingsPlans.Count -eq 0) { + foreach ($bpId in $billingProfileIds) { + try { + $spPath = "$bpId/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly" + $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET + if ($spResp.StatusCode -eq 200) { + $spData = ($spResp.Content | ConvertFrom-Json) + if ($spData.value) { + foreach ($item in $spData.value) { + $p = $item.properties + if ($p.benefitType -eq 'SavingsPlan') { + $savingsPlans += [PSCustomObject]@{ + BenefitId = $p.benefitOrderId + BenefitType = $p.benefitType + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + UsageDate = $p.usageDate + } + } + } + } + } + elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Savings plan query at billing profile scope failed: $($_.Exception.Message)" + } + } + } + + # Fallback: subscription scope (EA, PAYG, etc.) + if ($savingsPlans.Count -eq 0) { + try { + foreach ($sub in $Subscriptions | Select-Object -First 5) { + $spPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&filter=properties/usageDate ge '$(((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd'))'&grain=Monthly" + $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET + if ($spResp.StatusCode -eq 200) { + $spData = ($spResp.Content | ConvertFrom-Json) + if ($spData.value) { + foreach ($item in $spData.value) { + $p = $item.properties + if ($p.benefitType -eq 'SavingsPlan') { + $savingsPlans += [PSCustomObject]@{ + BenefitId = $p.benefitOrderId + BenefitType = $p.benefitType + AvgUtilization = [math]::Round([double]$p.avgUtilizationPercentage, 1) + UsageDate = $p.usageDate + } + } + } + if ($savingsPlans.Count -gt 0) { break } + } + } + elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Savings plan utilization query failed: $($_.Exception.Message)" + } + } + + # -- Step 4: Calculate summary stats -- + $riAvgUtil = 0 + $riCount = $reservations.Count + if ($riCount -gt 0) { + $riAvgUtil = [math]::Round(($reservations | Measure-Object -Property AvgUtilization -Average).Average, 1) + } + + $spAvgUtil = 0 + $spCount = $savingsPlans.Count + if ($spCount -gt 0) { + $spAvgUtil = [math]::Round(($savingsPlans | Measure-Object -Property AvgUtilization -Average).Average, 1) + } + + $underutilized = @($reservations | Where-Object { $_.AvgUtilization -lt 80 }) + + $denied = ($accessDenied -and $riCount -eq 0 -and $spCount -eq 0) + + # Human-readable summary so the zeros below are never mistaken for + # "no commitments / all healthy" when the real cause is no access. + $note = if ($denied) { + 'Access denied reading reservation/savings-plan utilization (needs Cost Management Reader / billing-scope access). The zero counts below reflect missing access, NOT confirmed absence of commitments.' + } + elseif ($riCount -eq 0 -and $spCount -eq 0) { + 'No reservations or savings plans found in scope.' + } + else { + "$riCount reservation(s) avg $riAvgUtil% util; $spCount savings plan(s) avg $spAvgUtil% util." + } + + return [PSCustomObject]@{ + Reservations = $reservations + SavingsPlans = $savingsPlans + RICount = $riCount + SPCount = $spCount + RIAvgUtilization = $riAvgUtil + SPAvgUtilization = $spAvgUtil + UnderutilizedRIs = $underutilized + HasData = ($riCount -gt 0 -or $spCount -gt 0) + AccessDenied = $denied + Note = $note + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 new file mode 100644 index 000000000..99eea7466 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 @@ -0,0 +1,158 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-CONTRACTINFO.PS1 +# AZURE FINOPS MULTITOOL - Billing Account & Contract Type Detection +########################################################################### +# Purpose: Detect the customer's Azure contract type (EA, MCA, PAYGO, CSP) +# and return billing account details. +# +# Contract Types: +# EnterpriseAgreement Enterprise Agreement (EA) +# MicrosoftCustomerAgreement Microsoft Customer Agreement (MCA) +# MicrosoftOnlineServicesProgram Pay-As-You-Go (PAYGO / MOSP) +# MicrosoftPartnerAgreement CSP / Partner (MPA) +# +# Reference: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/view-all-accounts +########################################################################### + +function Get-ContractInfo { + [CmdletBinding()] + param( + [Parameter()] + [object[]]$Subscriptions + ) + + $inferredAgreement = $null + $inferredFriendly = $null + + # -- Step 1: Detect agreement type from subscription quotaId --------- + # QuotaId is always scoped to the correct tenant when using passed subs + $subsToCheck = if ($Subscriptions) { @($Subscriptions | Select-Object -First 3) } else { @() } + if ($subsToCheck.Count -eq 0) { + try { $subsToCheck = @(Get-AzSubscription -ErrorAction SilentlyContinue | Select-Object -First 3) } catch { } + } + + foreach ($sub in $subsToCheck) { + try { + $subPath = "/subscriptions/$($sub.Id)?api-version=2022-12-01" + $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method GET + if ($subResp.StatusCode -eq 200) { + $subDetail = ($subResp.Content | ConvertFrom-Json) + $quotaId = $subDetail.properties.subscriptionPolicies.quotaId + + $mapped = switch -Regex ($quotaId) { + 'EnterpriseAgreement' { @{ Agreement = 'EnterpriseAgreement'; Friendly = 'Enterprise Agreement (EA)' } } + 'MCSFree|MSDN|Visual' { @{ Agreement = 'MSDN'; Friendly = 'Visual Studio / MSDN' } } + 'PayAsYouGo|PAYG' { @{ Agreement = 'MicrosoftOnlineServicesProgram'; Friendly = 'Pay-As-You-Go (PAYGO)' } } + 'Sponsored' { @{ Agreement = 'Sponsored'; Friendly = 'Azure Sponsored' } } + 'CSP' { @{ Agreement = 'MicrosoftPartnerAgreement'; Friendly = 'CSP / Partner Agreement' } } + 'Internal' { @{ Agreement = 'Internal'; Friendly = 'Microsoft Internal' } } + 'MCA' { @{ Agreement = 'MicrosoftCustomerAgreement'; Friendly = 'Microsoft Customer Agreement (MCA)' } } + 'FreeTrial' { @{ Agreement = 'FreeTrial'; Friendly = 'Free Trial' } } + 'AAD' { @{ Agreement = 'AAD'; Friendly = 'Azure AD Subscription' } } + 'MSAZR' { @{ Agreement = 'MicrosoftOnlineServicesProgram'; Friendly = 'Pay-As-You-Go (PAYGO)' } } + default { @{ Agreement = $quotaId; Friendly = $quotaId } } + } + + if ($mapped) { + $inferredAgreement = $mapped.Agreement + $inferredFriendly = $mapped.Friendly + Write-Host " QuotaId detected: $quotaId -> $inferredFriendly" -ForegroundColor Green + break + } + } + } catch { } + } + + # -- Step 2: Try billing accounts API, filtered by inferred type ----- + try { + $response = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" -Method GET + if (-not $response -or -not $response.Content) { throw "Billing accounts API returned no content (HTTP $($response.StatusCode))" } + $result = ($response.Content | ConvertFrom-Json) + + if ($result.value -and $result.value.Count -gt 0) { + $matchedAccount = $null + + if ($result.value.Count -eq 1) { + # Single billing account in scope - unambiguous. + $matchedAccount = $result.value[0] + } + else { + # Multiple billing accounts are visible across every tenant the + # signed-in identity can reach. Picking by agreement type alone + # can surface an account from an unrelated tenant, so confirm the + # account actually owns one of the scanned subscriptions before + # trusting it. Prefer candidates matching the inferred agreement. + $scanIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($sc in $subsToCheck) { if ($sc.Id) { [void]$scanIds.Add([string]$sc.Id) } } + + $candidates = if ($inferredAgreement) { + @($result.value | Where-Object { $_.properties.agreementType -eq $inferredAgreement }) + } else { @() } + if (-not $candidates -or $candidates.Count -eq 0) { $candidates = @($result.value) } + + foreach ($cand in $candidates) { + if ($scanIds.Count -eq 0) { break } + try { + $bsPath = "/providers/Microsoft.Billing/billingAccounts/$($cand.name)/billingSubscriptions?api-version=2024-04-01" + $bsResp = Invoke-AzRestMethodWithRetry -Path $bsPath -Method GET + if ($bsResp -and $bsResp.StatusCode -eq 200 -and $bsResp.Content) { + $bsData = ($bsResp.Content | ConvertFrom-Json) + $owns = $false + foreach ($bs in @($bsData.value)) { + $bsSubId = if ($bs.properties.subscriptionId) { [string]$bs.properties.subscriptionId } else { [string]$bs.name } + if ($scanIds.Contains($bsSubId)) { $owns = $true; break } + } + if ($owns) { $matchedAccount = $cand; break } + } + } catch { } + } + # No account could be confirmed to own the scanned subscription - + # fall through to the subscription-accurate quotaId inference. + } + + if (-not $matchedAccount) { throw "No billing account confirmed for the scanned subscription" } + + $props = $matchedAccount.properties + $friendlyType = switch ($props.agreementType) { + 'EnterpriseAgreement' { 'Enterprise Agreement (EA)' } + 'MicrosoftCustomerAgreement' { 'Microsoft Customer Agreement (MCA)' } + 'MicrosoftOnlineServicesProgram' { 'Pay-As-You-Go (PAYGO)' } + 'MicrosoftPartnerAgreement' { 'CSP / Partner Agreement (MPA)' } + default { $props.agreementType } + } + + return @([PSCustomObject]@{ + AccountName = $props.displayName + AccountId = $matchedAccount.name + AgreementType = $props.agreementType + FriendlyType = $friendlyType + AccountStatus = $props.accountStatus + Currency = if ($props.soldTo) { $props.soldTo.country } else { 'Unknown' } + }) + } + } catch { + Write-Warning "Billing account query failed: $($_.Exception.Message)" + } + + # -- Step 3: Return quotaId-based inference if billing API failed ---- + if ($inferredAgreement) { + $subName = if ($subsToCheck.Count -gt 0) { $subsToCheck[0].Name } else { 'Unknown' } + return @([PSCustomObject]@{ + AccountName = "Inferred from subscription: $subName" + AccountId = if ($subsToCheck.Count -gt 0) { $subsToCheck[0].Id } else { '' } + AgreementType = $inferredAgreement + FriendlyType = $inferredFriendly + AccountStatus = 'Active' + Currency = 'Unknown' + }) + } + + return @([PSCustomObject]@{ + AccountName = 'Unknown' + AgreementType = 'Unknown' + FriendlyType = 'Could not detect (assign Billing Reader for accurate detection)' + }) +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 new file mode 100644 index 000000000..60504f7ee --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 @@ -0,0 +1,517 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-COSTBYTAG.PS1 +# AZURE FINOPS MULTITOOL - Cost Breakdown by Tag +########################################################################### +# Purpose: For each CAF allocation tag (CostCenter, BusinessUnit, +# etc.), query Cost Management to show how spend distributes +# across tag values. If no meaningful tags exist, fall back +# to cost-by-subscription so the user still sees a breakdown. +# +# This is the "Understand" pillar - cost allocation and showback. +########################################################################### + +function Get-CostByTag { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [hashtable]$ExistingTags, + + # No -RestrictToSelected here: this scan queries each subscription + # individually, so it is always scoped to $Subscriptions. + [Parameter()] + [object[]]$Subscriptions + ) + + # Tags we want to break cost down by (in priority order — matches CAF allocation tags) + $targetTags = @('CostCenter', 'BusinessUnit', 'ApplicationName', 'WorkloadName', 'OpsTeam', 'Criticality', 'DataClassification') + + # Also check variations + $variations = @{ + 'CostCenter' = @('cost-center', 'costcenter', 'cost_center', 'cc') + 'BusinessUnit' = @('bu', 'businessunit', 'business-unit', 'department', 'dept') + 'ApplicationName' = @('applicationname', 'application', 'app', 'appname', 'app-name') + 'WorkloadName' = @('workloadname', 'workload', 'workload-name', 'workload_name') + 'OpsTeam' = @('opsteam', 'ops-team', 'ops_team', 'owner', 'technicalowner') + 'Criticality' = @('criticality', 'sla', 'tier', 'importance') + 'DataClassification' = @('dataclassification', 'data-classification', 'data_classification', 'classification') + } + + $existingKeys = if ($ExistingTags) { $ExistingTags.Keys | ForEach-Object { $_.ToLower() } } else { @() } + $tagsToQuery = @() + + foreach ($tag in $targetTags) { + # Check exact match first + $match = $existingKeys | Where-Object { $_ -eq $tag.ToLower() } | Select-Object -First 1 + if ($match) { + # Find the properly-cased version from existing tags + $properCase = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $match } | Select-Object -First 1 + $tagsToQuery += $properCase + continue + } + + # Check variations + if ($variations.ContainsKey($tag)) { + $varMatch = $existingKeys | Where-Object { $_ -in $variations[$tag] } | Select-Object -First 1 + if ($varMatch) { + $properCase = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $varMatch } | Select-Object -First 1 + $tagsToQuery += $properCase + } + } + } + + # Also include any additional existing tags not already in the list + # Sorted by resource coverage (descending) so the most-used tags survive any cap + if ($ExistingTags) { + $alreadyLower = $tagsToQuery | ForEach-Object { $_.ToLower() } + $systemPrefixes = @('hidden-', 'ms-resource-', 'aks-managed-', 'kubernetes.io', 'displayname') + # Exact-match system/auto-generated tags (Azure Policy, Monitor, Automanage, etc.) + $systemExact = @( + 'action', 'automanage', 'alertrulecreatedwithalertsrecommendations', + 'createdby', 'createddate', 'createdtime', 'createdon', + 'environment-type', 'intune-deployed', 'policyassignmentname', + 'statuschangedate', 'vmsize', 'offer', 'publisher', 'sku' + ) + $extras = @() + foreach ($key in $ExistingTags.Keys) { + if ($key.ToLower() -in $alreadyLower) { continue } + $skip = $false + if ($key.ToLower() -in $systemExact) { $skip = $true } + if (-not $skip) { + foreach ($prefix in $systemPrefixes) { + if ($key.ToLower().StartsWith($prefix)) { $skip = $true; break } + } + } + if (-not $skip) { + $coverage = if ($ExistingTags[$key].TotalResources) { $ExistingTags[$key].TotalResources } else { 0 } + $extras += [PSCustomObject]@{ Name = $key; Coverage = $coverage } + } + } + $extras = $extras | Sort-Object Coverage -Descending + foreach ($e in $extras) { $tagsToQuery += $e.Name } + } + + # Skip tags with very low resource coverage (< 3 resources tagged) + # These produce mostly "(untagged)" results and waste API calls + if ($ExistingTags -and $tagsToQuery.Count -gt 8) { + $cafTags = $tagsToQuery | Select-Object -First ([math]::Min($tagsToQuery.Count, 7)) + $extraTags = $tagsToQuery | Select-Object -Skip 7 + $filteredExtras = @() + foreach ($t in $extraTags) { + $tagInfo = if ($ExistingTags.ContainsKey($t)) { $ExistingTags[$t] } else { $null } + $count = if ($tagInfo -and $tagInfo.TotalResources) { $tagInfo.TotalResources } else { 0 } + if ($count -ge 3) { $filteredExtras += $t } + } + $skipped = $tagsToQuery.Count - $cafTags.Count - $filteredExtras.Count + $tagsToQuery = $cafTags + $filteredExtras + if ($skipped -gt 0) { + Write-Host " Skipped $skipped low-coverage tags (< 3 resources) to reduce API calls" -ForegroundColor Yellow + } + } + + $results = @{} + + # Helper: normalize an ARG tags bag (PSCustomObject or hashtable) into a + # plain hashtable of tagKey -> tagValue. + function ConvertTo-TagHash { + param($Tags) + $h = @{} + if (-not $Tags) { return $h } + if ($Tags -is [System.Collections.IDictionary]) { + foreach ($k in $Tags.Keys) { $h[[string]$k] = [string]$Tags[$k] } + } + else { + foreach ($p in $Tags.PSObject.Properties) { $h[[string]$p.Name] = [string]$p.Value } + } + return $h + } + + # === Build resource -> tag maps via Azure Resource Graph ============= + # The reliable, throttle-resistant way to allocate cost by tag is to query + # cost grouped by ResourceId (one cheap, universally-supported call per + # subscription) and join it client-side against each resource's tags. ARG + # supplies that join data without hammering the Cost Management API. We + # capture resource-level tags plus resource-group-level tags (used as a + # fallback when a resource carries no tag of its own but its RG does). + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + $resTagMap = @{} # lowercased resourceId -> @{ tagKey = tagValue } + $rgTagMap = @{} # lowercased resourceGroup id -> @{ tagKey = tagValue } + try { + Write-Host " Building resource -> tag map via Resource Graph..." -ForegroundColor Cyan + $resTagQuery = "resources | where isnotempty(tags) | project id, tags" + $skip = $null + do { + $r = Search-AzGraphSafe -Query $resTagQuery -Subscription $subIds -First 1000 -SkipToken $skip + if (-not $r -or -not $r.Data) { break } + foreach ($row in $r.Data) { + if ($row.id) { $resTagMap[([string]$row.id).ToLower()] = ConvertTo-TagHash $row.tags } + } + $skip = $r.SkipToken + } while ($skip) + + $rgTagQuery = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups' | where isnotempty(tags) | project id, tags" + $skip = $null + do { + $r = Search-AzGraphSafe -Query $rgTagQuery -Subscription $subIds -First 1000 -SkipToken $skip + if (-not $r -or -not $r.Data) { break } + foreach ($row in $r.Data) { + if ($row.id) { $rgTagMap[([string]$row.id).ToLower()] = ConvertTo-TagHash $row.tags } + } + $skip = $r.SkipToken + } while ($skip) + + Write-Host " Mapped tags for $($resTagMap.Count) resources, $($rgTagMap.Count) resource groups" -ForegroundColor Gray + } + catch { + Write-Warning "Resource tag map build failed: $($_.Exception.Message). Cost-by-tag will rely on whatever map data was collected." + } + + # Helper: parse a ResourceId-grouped Cost Management response into rows of + # @{ ResourceId; Cost; Currency }. Rows with an empty ResourceId represent + # non-resource charges (reservations, marketplace, refunds/credits). + function Parse-ResourceIdRows { + param($ResponseContent) + $parsed = [System.Collections.Generic.List[PSCustomObject]]::new() + $result = ($ResponseContent | ConvertFrom-Json) + if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) { + return $parsed + } + $cols = $result.properties.columns + $costIdx = -1; $ridIdx = -1; $currIdx = -1 + for ($i = 0; $i -lt $cols.Count; $i++) { + $n = $cols[$i].name.ToLower() + if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $i } + elseif ($n -eq 'resourceid') { $ridIdx = $i } + } + if ($costIdx -eq -1) { $costIdx = 0 } + if ($ridIdx -eq -1) { $ridIdx = 1 } + if ($currIdx -eq -1) { $currIdx = if ($cols.Count -ge 3) { 2 } else { -1 } } + + foreach ($row in $result.properties.rows) { + $cost = [math]::Round([double]$row[$costIdx], 2) + $rid = if ($ridIdx -ge 0 -and $ridIdx -lt $row.Count -and $row[$ridIdx]) { [string]$row[$ridIdx] } else { '' } + $currency = if ($currIdx -ge 0 -and $currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' } + [void]$parsed.Add([PSCustomObject]@{ ResourceId = $rid; Cost = $cost; Currency = $currency }) + } + return $parsed + } + + # Helper: parse Cost Management query response using column headers + function Parse-CostRows { + param($ResponseContent) + $parsed = [System.Collections.Generic.List[PSCustomObject]]::new() + $result = ($ResponseContent | ConvertFrom-Json) + if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) { + return $parsed + } + # Build column index map from response + $cols = $result.properties.columns + $costIdx = -1; $tagIdx = -1; $currIdx = -1 + for ($i = 0; $i -lt $cols.Count; $i++) { + $n = $cols[$i].name.ToLower() + if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $i } + elseif ($n -eq 'tagvalue') { $tagIdx = $i } + } + # Fallback: if TagValue column not found, pick first String column that isn't TagKey or Currency + if ($tagIdx -eq -1) { + for ($i = 0; $i -lt $cols.Count; $i++) { + if ($cols[$i].type -eq 'String' -and $i -ne $currIdx -and $cols[$i].name.ToLower() -ne 'tagkey') { $tagIdx = $i; break } + } + } + # Final positional fallback + if ($costIdx -eq -1) { $costIdx = 0 } + if ($tagIdx -eq -1) { $tagIdx = if ($cols.Count -ge 4) { 2 } else { 1 } } + if ($currIdx -eq -1) { $currIdx = if ($cols.Count -ge 4) { 3 } else { 2 } } + + foreach ($row in $result.properties.rows) { + $cost = [math]::Round([double]$row[$costIdx], 2) + $value = if ($row[$tagIdx]) { $row[$tagIdx] } else { '(untagged)' } + $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' } + [void]$parsed.Add([PSCustomObject]@{ TagValue = $value; Cost = $cost; Currency = $currency }) + } + return $parsed + } + + # Helper: parse batched TagKey+TagValue response into per-tag results + function Parse-BatchedCostRows { + param($ResponseContent) + $perTag = @{} + $result = ($ResponseContent | ConvertFrom-Json) + if (-not $result.properties -or -not $result.properties.rows -or $result.properties.rows.Count -eq 0) { + return $perTag + } + $cols = $result.properties.columns + $costIdx = -1; $keyIdx = -1; $valIdx = -1; $currIdx = -1 + for ($i = 0; $i -lt $cols.Count; $i++) { + $n = $cols[$i].name.ToLower() + if ($n -eq 'cost' -or $n -eq 'totalcost' -or $n -match 'precost|pretaxcost') { $costIdx = $i } + elseif ($n -eq 'tagkey') { $keyIdx = $i } + elseif ($n -eq 'tagvalue') { $valIdx = $i } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $i } + } + if ($costIdx -eq -1) { $costIdx = 0 } + if ($keyIdx -eq -1) { $keyIdx = 1 } + if ($valIdx -eq -1) { $valIdx = 2 } + if ($currIdx -eq -1) { $currIdx = 3 } + + foreach ($row in $result.properties.rows) { + $tagKey = if ($row[$keyIdx]) { $row[$keyIdx] } else { '' } + $tagVal = if ($row[$valIdx]) { $row[$valIdx] } else { '(untagged)' } + $cost = [math]::Round([double]$row[$costIdx], 2) + $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' } + if (-not $perTag.ContainsKey($tagKey)) { + $perTag[$tagKey] = [System.Collections.Generic.List[PSCustomObject]]::new() + } + [void]$perTag[$tagKey].Add([PSCustomObject]@{ TagValue = $tagVal; Cost = $cost; Currency = $currency }) + } + return $perTag + } + + # Helper: Fire multiple REST calls in parallel using the shared runspace pool. + # Each call handles its own 429 retry internally, so pool slots may block + # briefly on throttle but other slots continue processing. + function Invoke-ParallelRestCalls { + param( + [array]$Calls, # Array of @{ Path; Body; SubId; SubName } + [int]$TimeoutSeconds = 90 + ) + $pendingJobs = [System.Collections.Generic.List[hashtable]]::new() + foreach ($call in $Calls) { + $ps = [powershell]::Create() + $ps.RunspacePool = $script:RunspacePool + [void]$ps.AddScript({ + param($path, $payload) + for ($attempt = 0; $attempt -le 3; $attempt++) { + $params = @{ Path = $path; Method = 'POST'; ErrorAction = 'Stop' } + if ($payload) { $params['Payload'] = $payload } + try { + $r = Invoke-AzRestMethod @params + if ($r.StatusCode -ne 429) { + $hdrs = @{} + if ($r.Headers) { foreach ($k in $r.Headers.Keys) { $hdrs[$k] = $r.Headers[$k] } } + return [PSCustomObject]@{ StatusCode = $r.StatusCode; Content = $r.Content; Headers = $hdrs } + } + # 429 — parse Retry-After or exponential backoff + $retryAfter = 10 + if ($r.Headers -and $r.Headers['Retry-After']) { + $parsed = 0 + if ([int]::TryParse($r.Headers['Retry-After'], [ref]$parsed)) { $retryAfter = [math]::Max($parsed, 5) } + } + else { $retryAfter = [math]::Min(10 * [math]::Pow(2, $attempt), 60) } + Start-Sleep -Seconds $retryAfter + } + catch { + return [PSCustomObject]@{ StatusCode = 0; Content = "{`"error`":{`"message`":`"$($_.Exception.Message)`"}}"; Headers = @{} } + } + } + return [PSCustomObject]@{ StatusCode = 429; Content = '{"error":{"message":"Rate limited after retries"}}'; Headers = @{} } + }).AddArgument($call.Path).AddArgument($call.Body) + $async = $ps.BeginInvoke() + [void]$pendingJobs.Add(@{ PS = $ps; Async = $async; Call = $call; Result = $null }) + } + + # Poll until all complete or timeout, keeping WPF UI responsive + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline) { + $allDone = $true + foreach ($job in $pendingJobs) { + if ($null -ne $job.Result) { continue } + if ($job.Async.IsCompleted) { + try { + $raw = $job.PS.EndInvoke($job.Async) + $resp = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null } + if (-not $resp) { $resp = [PSCustomObject]@{ StatusCode = 0; Content = '{}'; Headers = @{} } } + if ($null -eq $resp.Content) { $resp = [PSCustomObject]@{ StatusCode = $resp.StatusCode; Content = '{}'; Headers = @{} } } + $job.Result = $resp + } + catch { + $job.Result = [PSCustomObject]@{ StatusCode = 0; Content = '{}'; Headers = @{} } + } + $job.PS.Dispose() + } + else { $allDone = $false } + } + if ($allDone) { break } + Wait-WithDispatcher -Milliseconds 50 + } + + # Cleanup any timed-out jobs + foreach ($job in $pendingJobs) { + if ($null -eq $job.Result) { + try { $job.PS.Stop() } catch { } + $job.PS.Dispose() + $job.Result = [PSCustomObject]@{ StatusCode = 408; Content = '{"error":{"message":"Timeout"}}'; Headers = @{} } + } + } + return $pendingJobs + } + + # === Query cost grouped by ResourceId, one call per subscription ====== + # ResourceId grouping is supported at subscription scope across EA, MCA and + # pay-as-you-go (unlike TagKey/TagValue, which 400/408s on most sub types). + # This collapses the old tags x subs x timeframes call matrix down to a + # single call per subscription, then attributes each resource's cost to its + # tag values client-side. The scan is built to COMPLETE even when some subs + # fail: a failed subscription is recorded and skipped, never thrown. + $usedTimeframe = 'MonthToDate' + $timeframes = @('MonthToDate', 'Custom') + + # Per-tag aggregation: tagName -> (tagValue -> accumulated cost) + $tagAgg = @{} + foreach ($t in $tagsToQuery) { $tagAgg[$t] = @{} } + $currencySeen = 'USD' + $subsQueried = 0 + $subsFailed = 0 + $grandTotal = 0.0 + + if (-not $Subscriptions -or $Subscriptions.Count -eq 0) { + Write-Host " No subscriptions available for cost-by-tag." -ForegroundColor Yellow + } + elseif ($tagsToQuery.Count -eq 0) { + Write-Host " No allocation tags found to break cost down by." -ForegroundColor Yellow + } + else { + foreach ($tf in $timeframes) { + # MonthToDate first; only fall back to last month (Custom) when MTD + # produced no cost at all (e.g. the first day of a new billing month). + if ($tf -eq 'Custom' -and $grandTotal -gt 0) { break } + if ($tf -eq 'Custom') { + Write-Host " MonthToDate returned no cost - retrying with last month..." -ForegroundColor Yellow + foreach ($t in $tagsToQuery) { $tagAgg[$t] = @{} } + $subsQueried = 0; $subsFailed = 0; $grandTotal = 0.0 + } + + $bodyObj = @{ + type = 'ActualCost' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @( @{ type = 'Dimension'; name = 'ResourceId' } ) + } + } + if ($tf -eq 'Custom') { + $lastMonthStart = (Get-Date).AddMonths(-1).ToString('yyyy-MM-01') + $lastMonthEnd = (Get-Date -Day 1).AddDays(-1).ToString('yyyy-MM-dd') + $bodyObj['timeframe'] = 'Custom' + $bodyObj['timePeriod'] = @{ from = $lastMonthStart; to = $lastMonthEnd } + } + else { + $bodyObj['timeframe'] = $tf + } + $body = $bodyObj | ConvertTo-Json -Depth 10 + + # Fan out across subscriptions in capped batches so a large tenant + # never floods the Cost Management API. Each call self-retries 429. + $batchSize = 8 + $subList = @($Subscriptions) + for ($offset = 0; $offset -lt $subList.Count; $offset += $batchSize) { + $upper = [math]::Min($offset + $batchSize - 1, $subList.Count - 1) + $slice = @($subList[$offset..$upper]) + $calls = @() + foreach ($sub in $slice) { + $calls += @{ + Path = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + Body = $body + SubId = $sub.Id + SubName = $sub.Name + } + } + $done = [math]::Min($offset + $slice.Count, $subList.Count) + Write-Host " Cost-by-tag: subscriptions $($offset + 1)-$done of $($subList.Count) ($tf)..." -ForegroundColor Cyan + $jobs = Invoke-ParallelRestCalls -Calls $calls -TimeoutSeconds 120 + foreach ($pj in $jobs) { + $subResp = $pj.Result + if ($subResp.StatusCode -eq 200) { + $subsQueried++ + $rows = Parse-ResourceIdRows -ResponseContent $subResp.Content + foreach ($row in $rows) { + $cost = $row.Cost + $grandTotal += $cost + if ($row.Currency) { $currencySeen = $row.Currency } + + if ([string]::IsNullOrWhiteSpace($row.ResourceId)) { + foreach ($t in $tagsToQuery) { + $cur = [double]$tagAgg[$t]['(non-resource charges)'] + $tagAgg[$t]['(non-resource charges)'] = $cur + $cost + } + continue + } + + $ridLower = $row.ResourceId.ToLower() + $tags = $resTagMap[$ridLower] + if (-not $tags -or $tags.Count -eq 0) { + $pIdx = $ridLower.IndexOf('/providers/') + if ($pIdx -gt 0) { + $rgId = $ridLower.Substring(0, $pIdx) + $tags = $rgTagMap[$rgId] + } + } + + foreach ($t in $tagsToQuery) { + $val = $null + if ($tags -and $tags.Count -gt 0) { + foreach ($k in $tags.Keys) { + if ($k -ieq $t) { $val = $tags[$k]; break } + } + } + if ([string]::IsNullOrWhiteSpace($val)) { $val = '(untagged resources)' } + $cur = [double]$tagAgg[$t][$val] + $tagAgg[$t][$val] = $cur + $cost + } + } + } + elseif ($subResp.StatusCode -eq 403) { + $subsFailed++ + $script:costAccessIssue = 'MCA' + } + elseif ($subResp.StatusCode -eq 400) { + $subsFailed++ + $errBody = try { ($subResp.Content | ConvertFrom-Json).error.message } catch { '' } + if ($errBody -match 'AO View Charges') { $script:costAccessIssue = 'EA' } + } + else { + $subsFailed++ + } + } + } + + $usedTimeframe = $tf + } + + if ($subsFailed -gt 0) { + Write-Host " Cost-by-tag completed: $subsQueried subscription(s) returned data, $subsFailed skipped (timeout / throttle / access)." -ForegroundColor Yellow + } + else { + Write-Host " Cost-by-tag completed: $subsQueried subscription(s) returned data." -ForegroundColor Green + } + } + + # === Materialize aggregation into the result contract ================= + # For each tag, emit { TagValue; Cost; Currency } rows sorted by cost desc. + # The synthetic "(untagged resources)" and "(non-resource charges)" values + # reconcile the breakdown back to the subscription invoice total. + foreach ($t in $tagsToQuery) { + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($val in $tagAgg[$t].Keys) { + $c = [math]::Round([double]$tagAgg[$t][$val], 2) + if ($c -eq 0) { continue } + [void]$rows.Add([PSCustomObject]@{ TagValue = $val; Cost = $c; Currency = $currencySeen }) + } + $results[$t] = @($rows | Sort-Object Cost -Descending) + } + + return [PSCustomObject]@{ + TagsQueried = $tagsToQuery + CostByTag = $results + NoTagsFound = ($tagsToQuery.Count -eq 0) + UsedTimeframe = $usedTimeframe + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 new file mode 100644 index 000000000..147413072 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 @@ -0,0 +1,374 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-COSTDATA.PS1 +# AZURE FINOPS MULTITOOL - Current & Forecasted Cost Data +########################################################################### +# Purpose: Query Cost Management API at the management-group scope to +# retrieve actual month-to-date spend and forecasted spend for +# every subscription in a single efficient call. +# +# Approach: MG-scope queries avoid N per-subscription calls. We group +# results by SubscriptionId so costs roll up correctly. +# +# Reference: https://learn.microsoft.com/en-us/rest/api/cost-management/query/usage +########################################################################### + +function Get-CostData { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [switch]$RestrictToSelected + ) + + $costMap = @{} + + # Restrict results to the subscriptions the user selected. MG-scope + # queries return every subscription under the management group, so we + # filter to the selected set to avoid showing unselected siblings. + $selectedSubs = $null + if ($Subscriptions -and $Subscriptions.Count -gt 0) { + $selectedSubs = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($s in $Subscriptions) { if ($s.Id) { [void]$selectedSubs.Add([string]$s.Id) } } + } + + # Resolve the management-group scope we can actually query for cost. + # Falls back to per-subscription only if no accessible MG returns cost data. + # When the user picked a subset of subscriptions we KEEP the single fast + # MG-scope query but add a server-side SubscriptionId filter so only the + # selected subs are returned - this avoids the slow per-subscription + # fan-out (N calls per timeframe) that triggers 429 throttling. + $mgScopeId = Resolve-CostMgId -TenantId $TenantId + $subFilter = if ($RestrictToSelected) { Get-CostSubscriptionFilter -Subscriptions $Subscriptions } else { $null } + if (-not $mgScopeId) { + Write-Host " Querying actual costs (per-subscription)..." -ForegroundColor Cyan + return Get-CostDataPerSubscription -Subscriptions $Subscriptions + } + + # -- Actual Cost (Month-to-Date) ------------------------------------ + try { + Write-Host " Querying actual costs (MG scope)..." -ForegroundColor Cyan + $actualDataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'SubscriptionId' } + ) + } + if ($subFilter) { $actualDataset['filter'] = $subFilter } + $actualBody = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = $actualDataset + } | ConvertTo-Json -Depth 10 + + $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $actualBody + + if ($response.StatusCode -in @(401, 403)) { + Set-MgCostScopeFailed + throw "MG-scope cost query returned HTTP $($response.StatusCode). Falling back to per-subscription." + } + if ($response.StatusCode -ne 200) { + throw "MG-scope cost query returned HTTP $($response.StatusCode). Falling back to per-subscription." + } + + $result = ($response.Content | ConvertFrom-Json) + + # Resolve column indices by name. The MG-scope response is not contractually + # ordered, and a reorder would silently attribute cost to the wrong sub. + $aCols = $result.properties.columns + $aCostIdx = -1; $aSubIdx = -1; $aCurIdx = -1 + if ($aCols) { + for ($ci = 0; $ci -lt $aCols.Count; $ci++) { + $cn = ([string]$aCols[$ci].name).ToLower() + if ($cn -eq 'subscriptionid') { $aSubIdx = $ci } + elseif ($cn -eq 'currency') { $aCurIdx = $ci } + elseif ($cn -match 'cost|pretaxcost') { $aCostIdx = $ci } + } + } + if ($aCostIdx -eq -1) { $aCostIdx = 0 } + if ($aSubIdx -eq -1) { $aSubIdx = 1 } + if ($aCurIdx -eq -1) { $aCurIdx = 2 } + + if ($result.properties.rows) { + foreach ($row in $result.properties.rows) { + $subId = $row[$aSubIdx] + $amount = [math]::Round($row[$aCostIdx], 2) + $currency = $row[$aCurIdx] + + if ($selectedSubs -and -not $selectedSubs.Contains([string]$subId)) { continue } + + if (-not $costMap.ContainsKey($subId)) { + $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = $currency } + } + $costMap[$subId].Actual = $amount + $costMap[$subId].Currency = $currency + } + } + } + catch { + Write-Warning "Actual cost query failed: $($_.Exception.Message)" + Write-Warning "Falling back to per-subscription queries." + $costMap = Get-CostDataPerSubscription -Subscriptions $Subscriptions + return $costMap + } + + # -- Forecasted Cost (Current Billing Period) ----------------------- + # Try MG-scope first, fall back to per-subscription if it fails + $forecastSuccess = $false + try { + Write-Host " Querying forecast costs (MG scope)..." -ForegroundColor Cyan + $now = Get-Date + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + + $forecastBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.ToString('yyyy-MM-dd') + to = $monthEnd.ToString('yyyy-MM-dd') + } + dataset = $( + $fcDataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'SubscriptionId' } + ) + } + if ($subFilter) { $fcDataset['filter'] = $subFilter } + $fcDataset + ) + includeActualCost = $true + includeFreshPartialCost = $false + } | ConvertTo-Json -Depth 10 + + $forecastPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" + $fResponse = Invoke-AzRestMethodWithRetry -Path $forecastPath -Method POST -Payload $forecastBody + + if ($fResponse.StatusCode -ne 200) { + throw "Forecast query returned HTTP $($fResponse.StatusCode)" + } + + $fResult = ($fResponse.Content | ConvertFrom-Json) + + if ($fResult.properties.rows -and $fResult.properties.rows.Count -gt 0) { + # The Forecast API with includeActualCost returns rows that may have + # a CostStatus column (Actual/Forecast). Sum all rows per subscription + # to get the full-month projected cost. + # Resolve column indices by name. The forecast endpoint may order + # columns differently and adds a CostStatus column, so positional + # access can mistake "Forecast"/"Actual" text for a subscription ID. + $fCols = $fResult.properties.columns + $fCostIdx = -1; $fSubIdx = -1 + if ($fCols) { + for ($ci = 0; $ci -lt $fCols.Count; $ci++) { + $cn = ([string]$fCols[$ci].name).ToLower() + if ($cn -eq 'subscriptionid') { $fSubIdx = $ci } + elseif ($cn -match 'cost|pretaxcost') { $fCostIdx = $ci } + } + } + if ($fCostIdx -eq -1) { $fCostIdx = 0 } + if ($fSubIdx -eq -1) { $fSubIdx = 1 } + + $forecastSums = @{} + foreach ($row in $fResult.properties.rows) { + $subId = [string]$row[$fSubIdx] + if ($subId -notmatch '^[0-9a-fA-F]{8}-') { continue } + if ($selectedSubs -and -not $selectedSubs.Contains($subId)) { continue } + $amount = [double]$row[$fCostIdx] + if (-not $forecastSums.ContainsKey($subId)) { $forecastSums[$subId] = 0 } + $forecastSums[$subId] += $amount + } + foreach ($subId in $forecastSums.Keys) { + if (-not $costMap.ContainsKey($subId)) { + $costMap[$subId] = @{ Actual = 0; Forecast = 0; Currency = 'USD' } + } + $costMap[$subId].Forecast = [math]::Round($forecastSums[$subId], 2) + } + $forecastSuccess = $true + Write-Host " MG-scope forecast: got data for $($forecastSums.Count) subscriptions" -ForegroundColor Green + } + else { + throw "MG-scope forecast returned 0 rows" + } + } + catch { + Write-Warning "MG-scope forecast failed: $($_.Exception.Message)" + Write-Host " Falling back to per-subscription forecast queries..." -ForegroundColor Yellow + } + + # Per-subscription forecast fallback + if (-not $forecastSuccess -and $Subscriptions) { + $now = Get-Date + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + $subCount = $Subscriptions.Count + $i = 0 + $hitCount = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i % [math]::Max(1, [int]($subCount / 10)) -eq 0) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying forecasts ($i/$subCount subs)..." + } + } + try { + $fBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.ToString('yyyy-MM-dd') + to = $monthEnd.ToString('yyyy-MM-dd') + } + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + includeActualCost = $true + includeFreshPartialCost = $false + } | ConvertTo-Json -Depth 10 + + $fResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/forecast?api-version=2023-11-01" -Method POST -Payload $fBody + if ($fResp.StatusCode -eq 200) { + $fRes = ($fResp.Content | ConvertFrom-Json) + if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) { + $total = 0 + foreach ($row in $fRes.properties.rows) { $total += [double]$row[0] } + if (-not $costMap.ContainsKey($sub.Id)) { + $costMap[$sub.Id] = @{ Actual = 0; Forecast = 0; Currency = 'USD' } + } + $costMap[$sub.Id].Forecast = [math]::Round($total, 2) + $hitCount++ + } + } + } + catch { + # Forecast not available for this sub + } + } + Write-Host " Per-sub forecast: got data for $hitCount of $subCount subscriptions" -ForegroundColor $(if ($hitCount -gt 0) { 'Green' } else { 'Yellow' }) + } + + # Ensure any subs without forecast data default to actual + foreach ($subId in $costMap.Keys) { + if ($costMap[$subId].Forecast -eq 0 -and $costMap[$subId].Actual -gt 0) { + $costMap[$subId].Forecast = $costMap[$subId].Actual + } + } + + return $costMap +} + +# -- Fallback: Per-Subscription Cost Queries ---------------------------- +function Get-CostDataPerSubscription { + param([object[]]$Subscriptions) + + $costMap = @{} + $subCount = $Subscriptions.Count + $skipForecast = ($subCount -gt 100) # For very large tenants, skip per-sub forecast to halve API calls + if ($skipForecast) { + Write-Host " Large tenant ($subCount subs): skipping per-sub forecast to reduce API calls" -ForegroundColor Yellow + } + + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying costs ($i/$subCount subs)..." + } + } + try { + $body = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + } | ConvertTo-Json -Depth 10 + + $path = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement" + $resp = Invoke-AzRestMethodWithRetry -Path "$path/query?api-version=2023-11-01" -Method POST -Payload $body + + $actual = 0; $currency = 'USD' + if ($resp.StatusCode -eq 200) { + $res = ($resp.Content | ConvertFrom-Json) + if ($res.properties.rows -and $res.properties.rows.Count -gt 0) { + $actual = [math]::Round($res.properties.rows[0][0], 2) + $currency = $res.properties.rows[0][1] + } + } + elseif ($resp.StatusCode -in @(400, 403) -and $resp.Content) { + $errMsg = try { ($resp.Content | ConvertFrom-Json).error.message } catch { '' } + if ($errMsg -match 'AO View Charges') { + $script:costAccessIssue = 'EA' + Write-Warning " Cost data disabled for EA account owners. Enable 'AO View Charges' in the EA portal." + } + elseif ($resp.StatusCode -eq 403) { + $script:costAccessIssue = 'MCA' + Write-Warning " Cost data access denied. Verify Billing Profile Reader or Cost Management Reader role assignment." + } + } + + $costMap[$sub.Id] = @{ Actual = $actual; Forecast = $actual; Currency = $currency } + + # Per-sub forecast (skipped for large tenants) + if (-not $skipForecast) { + try { + $now = Get-Date + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + $fBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.ToString('yyyy-MM-dd') + to = $monthEnd.ToString('yyyy-MM-dd') + } + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + includeActualCost = $true + includeFreshPartialCost = $false + } | ConvertTo-Json -Depth 10 + + $fResp = Invoke-AzRestMethodWithRetry -Path "$path/forecast?api-version=2023-11-01" -Method POST -Payload $fBody + if ($fResp.StatusCode -eq 200) { + $fRes = ($fResp.Content | ConvertFrom-Json) + if ($fRes.properties.rows -and $fRes.properties.rows.Count -gt 0) { + $fAmount = [math]::Round($fRes.properties.rows[0][0], 2) + $costMap[$sub.Id].Forecast = $actual + $fAmount + } + } + } + catch { + # Forecast not available for all account types + } + } + } + catch { + Write-Warning " Cost query failed for $($sub.Name): $($_.Exception.Message)" + } + } + return $costMap +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 new file mode 100644 index 000000000..4caeb7158 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 @@ -0,0 +1,307 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-COSTTREND.PS1 +# AZURE FINOPS MULTITOOL - 6-Month Cost Trend Data +########################################################################### +# Purpose: Query Cost Management for the last 6 months of actual spend, +# returning monthly totals suitable for a bar chart display. +########################################################################### + +function Get-CostTrend { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [switch]$RestrictToSelected + ) + + Write-Host " Querying 6-month cost trend..." -ForegroundColor Cyan + + $endDate = Get-Date -Day 1 # First of current month + $startDate = $endDate.AddMonths(-6) + $fromStr = $startDate.ToString('yyyy-MM-dd') + $toStr = (Get-Date).ToString('yyyy-MM-dd') + + $body = @{ + type = 'ActualCost' + timeframe = 'Custom' + timePeriod = @{ + from = $fromStr + to = $toStr + } + dataset = @{ + granularity = 'Monthly' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + } | ConvertTo-Json -Depth 10 + + $months = [System.Collections.Generic.List[PSCustomObject]]::new() + $bySubscription = @{} # key = subId, value = sorted list of month entries + + # When the user picked a subset of subscriptions we KEEP the single fast + # MG-scope grouped call but add a server-side SubscriptionId filter so the + # trend only includes the selected subs - avoids per-subscription fan-out. + $subFilter = if ($RestrictToSelected) { Get-CostSubscriptionFilter -Subscriptions $Subscriptions } else { $null } + + # Grouped variant: one MG-scope call returns the per-subscription matrix + # (month x subscription) in a single response, avoiding an N-subscription loop. + $groupedDataset = @{ + granularity = 'Monthly' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'SubscriptionId' } + ) + } + if ($subFilter) { $groupedDataset['filter'] = $subFilter } + $groupedBody = @{ + type = 'ActualCost' + timeframe = 'Custom' + timePeriod = @{ + from = $fromStr + to = $toStr + } + dataset = $groupedDataset + } | ConvertTo-Json -Depth 10 + + # Helper: parse cost query rows into month entries + function Parse-CostRows { + param($Rows, $Columns) + $entries = [System.Collections.Generic.List[PSCustomObject]]::new() + if (-not $Rows) { return $entries } + + $costIdx = -1; $dateIdx = -1; $currIdx = -1 + if ($Columns) { + for ($ci = 0; $ci -lt $Columns.Count; $ci++) { + $n = $Columns[$ci].name.ToLower() + $t = $Columns[$ci].type.ToLower() + if ($n -match 'cost|precost|pretaxcost') { $costIdx = $ci } + elseif ($t -eq 'number' -and $costIdx -eq -1) { $costIdx = $ci } + elseif ($n -match 'billingmonth|usagedate' -or $t -eq 'datetime') { $dateIdx = $ci } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $ci } + } + } + if ($costIdx -eq -1) { $costIdx = 0 } + if ($dateIdx -eq -1) { $dateIdx = 1 } + if ($currIdx -eq -1) { $currIdx = 2 } + + foreach ($row in $Rows) { + $cost = [math]::Round([double]$row[$costIdx], 2) + $dateVal = $row[$dateIdx].ToString() + $dateClean = $dateVal -replace '[^0-9\-]', '' + if ($dateClean.Length -eq 8) { + $parsed = [datetime]::ParseExact($dateClean, 'yyyyMMdd', $null) + } else { + $parsed = [datetime]::Parse($dateVal) + } + $currency = if ($currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' } + [void]$entries.Add([PSCustomObject]@{ + Month = $parsed.ToString('MMM yyyy') + MonthDate = $parsed + Cost = $cost + Currency = $currency + }) + } + return $entries + } + + try { + # Parse a SubscriptionId-grouped Monthly response into per-sub entries. + function Parse-GroupedCostRows { + param($Rows, $Columns) + $out = [System.Collections.Generic.List[PSCustomObject]]::new() + if (-not $Rows) { return $out } + $costIdx = -1; $dateIdx = -1; $currIdx = -1; $subIdx = -1 + if ($Columns) { + for ($ci = 0; $ci -lt $Columns.Count; $ci++) { + $n = $Columns[$ci].name.ToLower() + $t = $Columns[$ci].type.ToLower() + if ($n -match 'subscriptionid') { $subIdx = $ci } + elseif ($n -match 'cost|precost|pretaxcost') { $costIdx = $ci } + elseif ($n -match 'billingmonth|usagedate' -or $t -eq 'datetime') { $dateIdx = $ci } + elseif ($n -match 'currency|billingcurrency') { $currIdx = $ci } + elseif ($t -eq 'number' -and $costIdx -eq -1) { $costIdx = $ci } + } + } + if ($costIdx -eq -1) { $costIdx = 0 } + if ($dateIdx -eq -1) { $dateIdx = 1 } + foreach ($row in $Rows) { + $cost = [math]::Round([double]$row[$costIdx], 2) + $dateVal = $row[$dateIdx].ToString() + $dateClean = $dateVal -replace '[^0-9\-]', '' + if ($dateClean.Length -eq 8) { + $parsed = [datetime]::ParseExact($dateClean, 'yyyyMMdd', $null) + } else { + $parsed = [datetime]::Parse($dateVal) + } + $currency = if ($currIdx -ge 0 -and $currIdx -lt $row.Count) { $row[$currIdx] } else { 'USD' } + $subId = if ($subIdx -ge 0 -and $subIdx -lt $row.Count) { [string]$row[$subIdx] } else { '' } + [void]$out.Add([PSCustomObject]@{ + SubId = $subId + Month = $parsed.ToString('MMM yyyy') + MonthDate = $parsed + Cost = $cost + Currency = $currency + }) + } + return $out + } + + # Build the aggregate month list + per-sub breakdown from grouped entries. + function Set-TrendFromGrouped { + param($Entries) + $agg = @{} + foreach ($e in $Entries) { + if ($e.SubId) { + if (-not $bySubscription.ContainsKey($e.SubId)) { + $bySubscription[$e.SubId] = [System.Collections.Generic.List[PSCustomObject]]::new() + } + [void]$bySubscription[$e.SubId].Add([PSCustomObject]@{ + Month = $e.Month; MonthDate = $e.MonthDate; Cost = $e.Cost; Currency = $e.Currency + }) + } + $key = $e.MonthDate.ToString('yyyy-MM') + if (-not $agg.ContainsKey($key)) { + $agg[$key] = @{ Cost = 0; Date = $e.MonthDate; Currency = $e.Currency } + } + $agg[$key].Cost += $e.Cost + } + foreach ($k in @($bySubscription.Keys)) { + $bySubscription[$k] = @($bySubscription[$k] | Sort-Object MonthDate) + } + foreach ($entry in $agg.GetEnumerator() | Sort-Object Key) { + [void]$months.Add([PSCustomObject]@{ + Month = $entry.Value.Date.ToString('MMM yyyy') + MonthDate = $entry.Value.Date + Cost = [math]::Round($entry.Value.Cost, 2) + Currency = $entry.Value.Currency + }) + } + } + + $subCount = if ($Subscriptions) { $Subscriptions.Count } else { 0 } + + # -- Fast path: single subscription in scope --------------------- + # No management-group resolution needed - the subscription IS the + # scope. One direct query populates both the aggregate and the + # single-sub breakdown, avoiding the throttle-prone MG probe loop. + if ($subCount -eq 1) { + $only = $Subscriptions[0] + $subPath = "/subscriptions/$($only.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body + if ($subResp.StatusCode -eq 200) { + $subResult = ($subResp.Content | ConvertFrom-Json) + if ($subResult.properties.rows) { + $months = Parse-CostRows -Rows $subResult.properties.rows -Columns $subResult.properties.columns + $bySubscription[$only.Id] = @($months | Sort-Object MonthDate) + } + } else { + Write-Warning " Single-sub cost trend returned HTTP $($subResp.StatusCode)" + } + } + else { + # -- Multi-sub path: one grouped MG-scope query --------------- + # group by SubscriptionId so a single call returns the month x + # subscription matrix (aggregate + per-sub) in one response. + # When the user picked a subset of subscriptions, skip MG scope + # (whole management group) and use the per-subscription loop so the + # trend only reflects the selected subscriptions. + $mgScopeId = Resolve-CostMgId -TenantId $TenantId + $useMgScope = [bool]$mgScopeId + $groupedOk = $false + + if ($useMgScope) { + $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $groupedBody + if ($response.StatusCode -eq 200) { + $result = ($response.Content | ConvertFrom-Json) + if ($result.properties.rows) { + $entries = Parse-GroupedCostRows -Rows $result.properties.rows -Columns $result.properties.columns + Set-TrendFromGrouped -Entries $entries + $groupedOk = ($months.Count -gt 0) + } + } else { + if ($response.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed } + Write-Warning " MG-scope grouped cost trend returned HTTP $($response.StatusCode) - falling back to per-sub" + $useMgScope = $false + } + } + + # -- Fallback: per-subscription loop (MG scope unavailable) --- + if (-not $groupedOk -and $Subscriptions) { + $sampleErrors = 0 + $sampleSize = [math]::Min(3, $subCount) + $aggTotals = @{} # used for aggregate if MG scope failed + + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying cost trend ($i/$subCount subs)..." + } + } + + $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body + + if ($subResp.StatusCode -eq 200) { + $subResult = ($subResp.Content | ConvertFrom-Json) + if ($subResult.properties.rows) { + $subMonths = Parse-CostRows -Rows $subResult.properties.rows -Columns $subResult.properties.columns + $bySubscription[$sub.Id] = @($subMonths | Sort-Object MonthDate) + + foreach ($sm in $subMonths) { + $key = $sm.MonthDate.ToString('yyyy-MM') + if (-not $aggTotals.ContainsKey($key)) { + $aggTotals[$key] = @{ Cost = 0; Date = $sm.MonthDate; Currency = $sm.Currency } + } + $aggTotals[$key].Cost += $sm.Cost + } + } + } else { + if ($i -le $sampleSize) { $sampleErrors++ } + } + + if ($i -eq $sampleSize -and $sampleErrors -eq $sampleSize -and $subCount -gt $sampleSize) { + Write-Host " All $sampleSize sample subs returned errors - skipping remaining $($subCount - $sampleSize) subs" -ForegroundColor Yellow + break + } + } + + if ($months.Count -eq 0 -and $aggTotals.Count -gt 0) { + foreach ($entry in $aggTotals.GetEnumerator() | Sort-Object Key) { + [void]$months.Add([PSCustomObject]@{ + Month = $entry.Value.Date.ToString('MMM yyyy') + MonthDate = $entry.Value.Date + Cost = [math]::Round($entry.Value.Cost, 2) + Currency = $entry.Value.Currency + }) + } + } + } + } + } catch { + Write-Warning "Cost trend query failed: $($_.Exception.Message)" + } + + # Sort by date + $sorted = @($months | Sort-Object MonthDate) + + return [PSCustomObject]@{ + Months = $sorted + BySubscription = $bySubscription + HasData = ($sorted.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 new file mode 100644 index 000000000..fec7c73fa --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 @@ -0,0 +1,168 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-IDLEVMS.PS1 +# AZURE FINOPS MULTITOOL - Idle & Underutilized VM Detection +########################################################################### +# Purpose: Query Azure Monitor metrics to find running VMs with very low +# CPU and network utilization that Advisor hasn't flagged yet. +# These are candidates for downsizing or shutting down. +########################################################################### + +function Get-IdleVMs { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + Write-Host " Scanning for idle and underutilized VMs..." -ForegroundColor Cyan + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $results = [System.Collections.Generic.List[PSCustomObject]]::new() + + # -- 1: Find all running VMs ------------------------------------------ + # Pull every VM with its power state so we can distinguish "no VMs at all" + # from "VMs exist but are all deallocated" (idle detection only applies to + # RUNNING VMs - a deallocated VM has no CPU to sample). + $totalVMs = 0 + $deallocatedCount = 0 + try { + $query = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| extend powerState = tostring(properties.extended.instanceView.powerState.code) +| project name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + osType = properties.storageProfile.osDisk.osType, + powerState +"@ + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 + $allVMs = if ($result) { @($result.Data) } else { @() } + $totalVMs = $allVMs.Count + $runningVMs = @($allVMs | Where-Object { $_.powerState -eq 'PowerState/running' }) + $deallocatedCount = $totalVMs - $runningVMs.Count + Write-Host " VMs found: $totalVMs ($($runningVMs.Count) running, $deallocatedCount stopped/deallocated)" -ForegroundColor Gray + } + catch { + Write-Warning " Running VM query failed: $($_.Exception.Message)" + $runningVMs = @() + } + + if ($runningVMs.Count -eq 0) { + $note = if ($totalVMs -gt 0) { + "$totalVMs VM(s) found but none are running ($deallocatedCount stopped/deallocated), so there is no CPU to sample for idle detection. Stopped/deallocated VMs still incur disk and IP cost - see scan_orphaned_resources." + } + else { + 'No virtual machines found in scope.' + } + return [PSCustomObject]@{ + IdleVMs = @() + Count = 0 + HasData = $false + ScannedVMs = 0 + TotalVMs = $totalVMs + DeallocatedVMs = $deallocatedCount + Note = $note + } + } + + # -- 2: Query 14-day avg CPU + Network for each VM ------------------- + $armBase = Get-FinOpsArmEndpoint + $token = (Get-AzAccessToken -ResourceUrl $armBase).Token + $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } + $now = (Get-Date).ToUniversalTime() + $fourteenDaysAgo = $now.AddDays(-14).ToString('yyyy-MM-ddTHH:mm:ssZ') + $nowStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ') + + $cpuThreshold = 5 # avg CPU < 5% = idle + $networkThreshold = 1048576 # < 1 MB/day total network = idle (14d * 1MB = 14MB) + $networkThreshold14d = $networkThreshold * 14 + + $vmCount = $runningVMs.Count + $vmIdx = 0 + foreach ($vm in $runningVMs) { + $vmIdx++ + if ($vmCount -gt 10 -and ($vmIdx -eq 1 -or $vmIdx % [math]::Max(1, [int]($vmCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Checking VM metrics ($vmIdx/$vmCount VMs)..." + } + } + $scope = "/subscriptions/$($vm.subscriptionId)/resourceGroups/$($vm.resourceGroup)/providers/Microsoft.Compute/virtualMachines/$($vm.name)" + try { + # Query CPU + Network In + Network Out in a single call + $metricUri = "$armBase$scope/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Percentage CPU,Network In Total,Network Out Total×pan=$fourteenDaysAgo/$nowStr&aggregation=Average,Total&interval=P14D" + $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop + $metricData = ($resp.Content | ConvertFrom-Json) + + $avgCpu = $null + $totalNetIn = 0 + $totalNetOut = 0 + + foreach ($metric in $metricData.value) { + $metricName = $metric.name.value + foreach ($ts in $metric.timeseries) { + foreach ($dp in $ts.data) { + switch ($metricName) { + 'Percentage CPU' { + if ($dp.average -ne $null) { $avgCpu = $dp.average } + } + 'Network In Total' { + if ($dp.total) { $totalNetIn += $dp.total } + } + 'Network Out Total' { + if ($dp.total) { $totalNetOut += $dp.total } + } + } + } + } + } + + $totalNetwork = $totalNetIn + $totalNetOut + + # Classify: idle if CPU < threshold AND network < threshold + $isIdle = $false + $classification = $null + + if ($avgCpu -ne $null -and $avgCpu -lt $cpuThreshold -and $totalNetwork -lt $networkThreshold14d) { + $isIdle = $true + $classification = 'Idle' + } + elseif ($avgCpu -ne $null -and $avgCpu -lt 10 -and $totalNetwork -lt ($networkThreshold14d * 10)) { + $isIdle = $true + $classification = 'Underutilized' + } + + if ($isIdle) { + $dailyNetMB = [math]::Round($totalNetwork / 14 / 1MB, 2) + [void]$results.Add([PSCustomObject]@{ + VMName = $vm.name + ResourceGroup = $vm.resourceGroup + SubscriptionId = $vm.subscriptionId + Location = $vm.location + VMSize = $vm.vmSize + OS = $vm.osType + AvgCPU14d = [math]::Round($avgCpu, 1) + NetworkPerDay = "$($dailyNetMB) MB" + Classification = $classification + Recommendation = if ($classification -eq 'Idle') { 'Deallocate or delete' } else { 'Downsize VM' } + }) + } + } + catch { + # Metrics not available — skip this VM + } + } + + Write-Host " Idle/underutilized VMs: $($results.Count)" -ForegroundColor Gray + + [PSCustomObject]@{ + IdleVMs = @($results) + Count = $results.Count + HasData = ($results.Count -gt 0) + ScannedVMs = $runningVMs.Count + TotalVMs = $totalVMs + DeallocatedVMs = $deallocatedCount + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 new file mode 100644 index 000000000..694e110fe --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 @@ -0,0 +1,185 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-LEGACYRESOURCES.PS1 +# AZURE FINOPS MULTITOOL - Legacy & Retiring Resource Detection +########################################################################### +# Purpose: Use Azure Resource Graph to surface resources running on legacy +# or retiring SKUs that should be modernized: first-generation VM +# families (A/D/F/G v1), HDD (Standard_LRS) managed disks, +# unmanaged (VHD) disks, and Basic-SKU public IPs / load balancers +# (retiring Sept 2025). Supports the FinOps modernization KPI. +########################################################################### +# Notes: +# - RBAC: Reader on each subscription (Azure Resource Graph). +# - "Legacy" VM families are v1 sizes with no version suffix (e.g. +# Standard_D2 vs Standard_D2_v3) plus Basic_A / Standard_A0-A7 and the +# G/GS series. These typically have a modern, cheaper successor. +########################################################################### + +function Get-LegacyResources { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + Write-Host " Scanning for legacy and retiring resources..." -ForegroundColor Cyan + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $allLegacy = [System.Collections.Generic.List[PSCustomObject]]::new() + + # -- 1: Legacy (v1) VM families --------------------------------------- + try { + $vmQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| extend vmSize = tostring(properties.hardwareProfile.vmSize) +| where vmSize matches regex @'(?i)^(Basic_A[0-9]+|Standard_A[0-7]|Standard_D[0-9]+|Standard_DS[0-9]+|Standard_F[0-9]+|Standard_G[0-9]+|Standard_GS[0-9]+)$' +| project name, resourceGroup, subscriptionId, location, vmSize +"@ + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allLegacy.Add([PSCustomObject]@{ + Category = 'Legacy VM Family' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.vmSize) (v1 - upgrade to current generation)" + Impact = 'High' + }) + } + Write-Host " Legacy VM families: $($rows.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " Legacy VM query failed: $($_.Exception.Message)" + } + + # -- 2: Unmanaged (VHD) disks ----------------------------------------- + try { + $vhdQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where isnotempty(properties.storageProfile.osDisk.vhd.uri) +| project name, resourceGroup, subscriptionId, location, + vhd = tostring(properties.storageProfile.osDisk.vhd.uri) +"@ + $result = Search-AzGraphSafe -Query $vhdQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allLegacy.Add([PSCustomObject]@{ + Category = 'Unmanaged Disk' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = 'VM uses unmanaged (VHD) OS disk - migrate to managed disks' + Impact = 'High' + }) + } + Write-Host " Unmanaged-disk VMs: $($rows.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " Unmanaged disk query failed: $($_.Exception.Message)" + } + + # -- 3: HDD (Standard_LRS) managed disks ------------------------------ + try { + $hddQuery = @" +resources +| where type =~ 'microsoft.compute/disks' +| where tostring(sku.name) =~ 'Standard_LRS' +| where toint(properties.diskSizeGB) >= 128 +| project name, resourceGroup, subscriptionId, location, + diskSizeGb = properties.diskSizeGB, sku = sku.name +"@ + $result = Search-AzGraphSafe -Query $hddQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allLegacy.Add([PSCustomObject]@{ + Category = 'HDD Managed Disk' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.diskSizeGb) GB HDD (Standard_LRS) - consider Standard/Premium SSD" + Impact = 'Low' + }) + } + Write-Host " HDD managed disks: $($rows.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " HDD disk query failed: $($_.Exception.Message)" + } + + # -- 4: Basic-SKU public IPs (retiring Sept 2025) --------------------- + try { + $pipQuery = @" +resources +| where type =~ 'microsoft.network/publicipaddresses' +| where tostring(sku.name) =~ 'Basic' +| project name, resourceGroup, subscriptionId, location, sku = sku.name +"@ + $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allLegacy.Add([PSCustomObject]@{ + Category = 'Basic Public IP' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = 'Basic-SKU public IP (retires Sep 2025) - migrate to Standard' + Impact = 'High' + }) + } + Write-Host " Basic public IPs: $($rows.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " Basic public IP query failed: $($_.Exception.Message)" + } + + # -- 5: Basic-SKU load balancers (retiring Sept 2025) ----------------- + try { + $lbQuery = @" +resources +| where type =~ 'microsoft.network/loadbalancers' +| where tostring(sku.name) =~ 'Basic' +| project name, resourceGroup, subscriptionId, location, sku = sku.name +"@ + $result = Search-AzGraphSafe -Query $lbQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allLegacy.Add([PSCustomObject]@{ + Category = 'Basic Load Balancer' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = 'Basic-SKU load balancer (retires Sep 2025) - migrate to Standard' + Impact = 'High' + }) + } + Write-Host " Basic load balancers: $($rows.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " Basic load balancer query failed: $($_.Exception.Message)" + } + + $byCategory = @( + $allLegacy | Group-Object Category | ForEach-Object { + [PSCustomObject]@{ Category = $_.Name; Count = $_.Count } + } | Sort-Object Count -Descending + ) + + return [PSCustomObject]@{ + HasData = ($allLegacy.Count -gt 0) + TotalCount = $allLegacy.Count + LegacyResources = @($allLegacy | Sort-Object @{ Expression = 'Impact'; Descending = $true }, Category) + ByCategory = $byCategory + ScannedSubs = $Subscriptions.Count + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 new file mode 100644 index 000000000..122333278 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 @@ -0,0 +1,156 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-MACCCOMMITMENT.PS1 +# AZURE FINOPS MULTITOOL - MACC Consumption Commitment Tracking +########################################################################### +# Purpose: Read the customer's Microsoft Azure Consumption Commitment (MACC) +# and return a minimal readout: commitment, consumed, remaining, +# % burned, and status for the current agreement period. +# +# Description: +# Queries the Consumption Lots API at billing-account scope and filters for +# lots whose source is 'ConsumptionCommitment' (the MACC): +# 1. Discover reachable billing accounts and their agreement type +# 2. For EA / MCA accounts, list consumption lots +# 3. Keep MACC lots and compute commitment / consumed / remaining / % / status +# +# ── Parameters ────────────────────────────────────────────────── +# Subscriptions Subscriptions in scope (used only to bound discovery) +# AgreementType Detected agreement type from Get-ContractInfo +# +# Notes: +# - The lots API is supported for Microsoft Customer Agreement (MCA) and +# Direct Enterprise Agreement (EA) only. PAYGO / CSP / MSDN return nothing. +# - Requires a billing role (Billing Account Reader / EA Reader) on the +# billing account. Standard subscription RBAC does not grant access. +# +# Reference: https://learn.microsoft.com/en-us/rest/api/consumption/lots/list-by-billing-account +########################################################################### + +function Get-MaccCommitment { + [CmdletBinding()] + param( + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [string]$AgreementType + ) + + Write-Host " Querying MACC consumption commitment..." -ForegroundColor Cyan + + # -- Result shape (single source of truth) -------------------------- + $result = [PSCustomObject]@{ + HasMacc = $false + Applicable = $true + Reason = '' + Commitments = @() + } + + # -- Step 0: Gate on agreement type --------------------------------- + # The lots API only returns data for MCA and Direct EA billing accounts. + if ($AgreementType -and $AgreementType -notin @('EnterpriseAgreement', 'MicrosoftCustomerAgreement')) { + $result.Applicable = $false + $result.Reason = "MACC tracking applies to Enterprise Agreement (EA) and Microsoft Customer Agreement (MCA) only. Detected agreement type: $AgreementType." + Write-Host " MACC not applicable for agreement type '$AgreementType'." -ForegroundColor DarkGray + return $result + } + + # -- Step 1: Discover reachable billing accounts -------------------- + $billingAccounts = @() + try { + $resp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" -Method GET + if ($resp.StatusCode -eq 200) { + $parsed = ($resp.Content | ConvertFrom-Json) + foreach ($a in $parsed.value) { + if ($a.properties.agreementType -in @('EnterpriseAgreement', 'MicrosoftCustomerAgreement')) { + $billingAccounts += [PSCustomObject]@{ + Name = $a.name + DisplayName = $a.properties.displayName + Agreement = $a.properties.agreementType + } + } + } + } + } catch { + $result.Reason = "Could not list billing accounts: $($_.Exception.Message)" + Write-Warning " MACC: $($result.Reason)" + return $result + } + + if ($billingAccounts.Count -eq 0) { + $result.Reason = 'No EA/MCA billing account is reachable. Assign a billing role (Billing Account Reader or EA Reader) to view MACC data.' + Write-Host " MACC: no reachable EA/MCA billing account." -ForegroundColor DarkGray + return $result + } + + # -- Step 2: List MACC lots per billing account --------------------- + $lots = [System.Collections.Generic.List[PSCustomObject]]::new() + $lotAccessDenied = $false # set if the lots call is forbidden (403) for any account + foreach ($ba in $billingAccounts) { + # Server-side filter to ConsumptionCommitment (the MACC) lots only. This + # mirrors the proven Cost Management data factory sample + # (MSBrett/ccm_datafactory), which reads the same lots endpoint. + $lotPath = "/providers/Microsoft.Billing/billingAccounts/$($ba.Name)/providers/Microsoft.Consumption/lots?api-version=2023-05-01&`$filter=source%20eq%20'ConsumptionCommitment'" + try { + $lotResp = Invoke-AzRestMethodWithRetry -Path $lotPath -Method GET + if ($lotResp.StatusCode -ne 200) { + # 403/401 = you can see the billing account but lack the billing + # role to read its consumption lots (the MACC). 404 = no lots + # exist for this account, which is genuinely "no MACC." + if ($lotResp.StatusCode -in @(401, 403)) { $lotAccessDenied = $true } + continue + } + $lotData = ($lotResp.Content | ConvertFrom-Json) + foreach ($lot in $lotData.value) { + $p = $lot.properties + if ("$($p.source)" -ne 'ConsumptionCommitment') { continue } + + $currency = if ($p.billingCurrency) { $p.billingCurrency } elseif ($p.originalAmount.currency) { $p.originalAmount.currency } else { 'USD' } + $original = if ($null -ne $p.originalAmount.value) { [double]$p.originalAmount.value } else { 0 } + + # The Lots API does not return a "used" amount. closedBalance is + # the amount REMAINING on the commitment, so consumed is simply + # originalAmount - closedBalance. This is the same calculation + # used by the Cost Management data factory sample + # (MSBrett/ccm_datafactory). + $remaining = if ($null -ne $p.closedBalance.value) { [double]$p.closedBalance.value } else { 0 } + $used = if ($original -gt 0) { [math]::Round($original - $remaining, 2) } else { 0 } + $pctUsed = if ($original -gt 0) { [math]::Round(($used / $original) * 100, 1) } else { 0 } + + $lots.Add([PSCustomObject]@{ + BillingAccount = $ba.DisplayName + Agreement = $ba.Agreement + Currency = $currency + Commitment = [math]::Round($original, 2) + Consumed = $used + Remaining = [math]::Round($remaining, 2) + PctUsed = $pctUsed + Status = if ($p.status) { $p.status } else { 'Unknown' } + StartDate = if ($p.startDate) { ([datetime]$p.startDate).ToString('yyyy-MM-dd') } else { '' } + ExpirationDate = if ($p.expirationDate) { ([datetime]$p.expirationDate).ToString('yyyy-MM-dd') } else { '' } + Estimated = [bool]$p.isEstimatedBalance + }) + } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $lotAccessDenied = $true } + Write-Warning " MACC lot query failed for account $($ba.DisplayName): $($_.Exception.Message)" + } + } + + if ($lots.Count -gt 0) { + $result.HasMacc = $true + $result.Commitments = @($lots) + Write-Host " Found $($lots.Count) MACC commitment lot(s)." -ForegroundColor Green + } elseif ($lotAccessDenied) { + $result.Reason = 'MACC data could not be read due to insufficient billing permissions. You can see the EA/MCA billing account, but reading its consumption commitment (lots) requires a billing role: for EA, Enterprise Administrator (read-only) or EA Reader at the enrollment scope; for MCA, Billing account reader or Billing profile reader. Standard subscription RBAC (Owner/Contributor/Reader) does not grant access. Ask a billing admin to assign one of these roles, then re-scan. Reference: https://learn.microsoft.com/azure/cost-management-billing/manage/understand-mca-roles' + Write-Host " MACC: billing access denied reading consumption lots." -ForegroundColor Yellow + } else { + $result.Reason = 'No MACC commitment found on the reachable EA/MCA billing account(s). This agreement may not include a consumption commitment.' + Write-Host " MACC: no consumption-commitment lots found." -ForegroundColor DarkGray + } + + return $result +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 new file mode 100644 index 000000000..b5347b5d7 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 @@ -0,0 +1,176 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-OPTIMIZATIONADVICE.PS1 +# AZURE FINOPS MULTITOOL - Azure Advisor Cost Optimization +########################################################################### +# Purpose: Pull all cost optimization recommendations from Azure Advisor +# across every subscription. Categorize by type: rightsize, +# shutdown, delete, modernize. +# +# Reference: https://learn.microsoft.com/en-us/azure/advisor/advisor-cost-recommendations +########################################################################### + +function Get-OptimizationAdvice { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $allRecs = [System.Collections.Generic.List[PSCustomObject]]::new() + + # Build subscription ID list and name lookup + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + $subNameMap = @{} + foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id] = $sub.Name } + + # Query all Advisor cost recommendations via Resource Graph (single call) + $query = @" +advisorresources +| where type == 'microsoft.advisor/recommendations' +| where properties.category == 'Cost' +| project subscriptionId, + shortDescriptionProblem = tostring(properties.shortDescription.problem), + shortDescriptionSolution = tostring(properties.shortDescription.solution), + impact = tostring(properties.impact), + impactedField = tostring(properties.impactedField), + impactedValue = tostring(properties.impactedValue), + annualSavings = tostring(properties.extendedProperties.annualSavingsAmount), + savingsAmount = tostring(properties.extendedProperties.savingsAmount), + savingsCurrency = tostring(properties.extendedProperties.savingsCurrency) +"@ + + try { + Write-Host " Querying Advisor cost recommendations via Resource Graph..." -ForegroundColor Cyan + $allRows = [System.Collections.Generic.List[object]]::new() + $skipToken = $null + + do { + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -SkipToken $skipToken + if ($result -and $result.Data) { foreach ($r in $result.Data) { [void]$allRows.Add($r) } } + $skipToken = if ($result) { $result.SkipToken } else { $null } + } while ($skipToken) + + Write-Host " Retrieved $($allRows.Count) Advisor cost recommendations." -ForegroundColor Cyan + + foreach ($row in $allRows) { + $problem = $row.shortDescriptionProblem + $solution = $row.shortDescriptionSolution + + # Skip reservation/savings plan recs (handled by Get-ReservationAdvice) + if ($problem -match 'reserv|savings plan') { continue } + + # Categorize the recommendation + $catText = "$problem $solution" + $category = switch -Regex ($catText) { + 'right.?siz|resize|downsize|scale down' { 'Rightsize' } + 'shut.?down|deallocate|idle|stopped' { 'Shutdown / Deallocate' } + 'delet|unused|orphan|unattached' { 'Delete Unused' } + 'modern|upgrade|migrate|move to' { 'Modernize' } + 'burstable|B-series' { 'Rightsize' } + default { 'Other' } + } + + $savings = $null + if ($row.annualSavings) { + $savings = [math]::Round([double]$row.annualSavings, 2) + } + elseif ($row.savingsAmount) { + $savings = [math]::Round([double]$row.savingsAmount, 2) + } + + $subId = $row.subscriptionId + [void]$allRecs.Add([PSCustomObject]@{ + Subscription = if ($subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { $subId } + SubscriptionId = $subId + Category = $category + Impact = $row.impact + Problem = $problem + Solution = $solution + ResourceType = $row.impactedField + ResourceName = $row.impactedValue + AnnualSavings = $savings + Currency = $row.savingsCurrency + }) + } + } catch { + Write-Warning " Advisor Resource Graph query failed: $($_.Exception.Message)" + Write-Warning " Falling back to per-subscription REST calls..." + + # Fallback: per-subscription REST API (slow but reliable) + foreach ($sub in $Subscriptions) { + try { + $advPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Advisor/recommendations?api-version=2023-01-01&`$filter=Category eq 'Cost'" + $advResp = Invoke-AzRestMethodWithRetry -Path $advPath -Method GET + if ($advResp.StatusCode -ne 200) { continue } + $advResult = ($advResp.Content | ConvertFrom-Json) + + foreach ($item in $advResult.value) { + $rec = $item.properties + if ($rec.shortDescription.problem -match 'reserv|savings plan') { continue } + + $catText = "$($rec.shortDescription.problem) $($rec.shortDescription.solution)" + $category = switch -Regex ($catText) { + 'right.?siz|resize|downsize|scale down' { 'Rightsize' } + 'shut.?down|deallocate|idle|stopped' { 'Shutdown / Deallocate' } + 'delet|unused|orphan|unattached' { 'Delete Unused' } + 'modern|upgrade|migrate|move to' { 'Modernize' } + 'burstable|B-series' { 'Rightsize' } + default { 'Other' } + } + + $savings = $null + if ($rec.extendedProperties.annualSavingsAmount) { + $savings = [math]::Round([double]$rec.extendedProperties.annualSavingsAmount, 2) + } elseif ($rec.extendedProperties.savingsAmount) { + $savings = [math]::Round([double]$rec.extendedProperties.savingsAmount, 2) + } + + [void]$allRecs.Add([PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + Category = $category + Impact = $rec.impact + Problem = $rec.shortDescription.problem + Solution = $rec.shortDescription.solution + ResourceType = $rec.impactedField + ResourceName = $rec.impactedValue + AnnualSavings = $savings + Currency = $rec.extendedProperties.savingsCurrency + }) + } + } catch { + Write-Warning " Advisor query failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Summarize by category ------------------------------------------ + $byCat = $allRecs | Group-Object Category | ForEach-Object { + [PSCustomObject]@{ + Category = $_.Name + Count = $_.Count + TotalSavings = [math]::Round(($_.Group | Where-Object { $_.AnnualSavings } | + Measure-Object -Property AnnualSavings -Sum).Sum, 2) + } + } + + $totalSavings = ($allRecs | Where-Object { $_.AnnualSavings } | + Measure-Object -Property AnnualSavings -Sum).Sum + + # -- Summarize by impact -------------------------------------------- + $byImpact = $allRecs | Group-Object Impact | ForEach-Object { + [PSCustomObject]@{ Impact = $_.Name; Count = $_.Count } + } + + return [PSCustomObject]@{ + Recommendations = $allRecs + ByCategory = $byCat + ByImpact = $byImpact + TotalCount = $allRecs.Count + EstimatedAnnualSavings = [math]::Round($totalSavings, 2) + Summary = "$($allRecs.Count) optimization recommendations (est. `$$([math]::Round($totalSavings, 2))/yr savings)" + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 new file mode 100644 index 000000000..c2402202e --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -0,0 +1,219 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-ORPHANEDRESOURCES.PS1 +# AZURE FINOPS MULTITOOL - Orphaned & Idle Resource Detection +########################################################################### +# Purpose: Use Azure Resource Graph to find resources that are costing +# money but serving no purpose: orphaned disks, unattached IPs, +# empty App Service Plans, unattached NICs, and stopped VMs +# that are still incurring compute charges. +########################################################################### + +function Get-OrphanedResources { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + Write-Host " Scanning for orphaned and idle resources..." -ForegroundColor Cyan + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $allOrphans = [System.Collections.Generic.List[PSCustomObject]]::new() + + # -- 1: Orphaned Managed Disks (no ownerVM) -------------------------- + try { + $diskQuery = @" +resources +| where type =~ 'microsoft.compute/disks' +| where managedBy == '' or isnull(managedBy) +| where properties.diskState == 'Unattached' +| project name, resourceGroup, subscriptionId, location, + diskSizeGb = properties.diskSizeGB, + sku = sku.name, diskState = properties.diskState, + type = 'Orphaned Disk' +"@ + $result = Search-AzGraphSafe -Query $diskQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Orphaned Disk' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.diskSizeGb) GB ($($r.sku))" + Impact = 'Medium' + }) + } + Write-Host " Orphaned disks: $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Orphaned disk query failed: $($_.Exception.Message)" + } + + # -- 2: Unattached Public IPs ----------------------------------------- + try { + $pipQuery = @" +resources +| where type =~ 'microsoft.network/publicipaddresses' +| where properties.ipConfiguration == '' or isnull(properties.ipConfiguration) +| where properties.natGateway == '' or isnull(properties.natGateway) +| project name, resourceGroup, subscriptionId, location, + sku = sku.name, ipAddress = properties.ipAddress, + allocationMethod = properties.publicIPAllocationMethod, + type = 'Unattached Public IP' +"@ + $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Unattached Public IP' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.sku) - $($r.allocationMethod)" + Impact = if ($r.sku -eq 'Standard') { 'Medium' } else { 'Low' } + }) + } + Write-Host " Unattached public IPs: $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Unattached public IP query failed: $($_.Exception.Message)" + } + + # -- 3: Unattached NICs ----------------------------------------------- + try { + $nicQuery = @" +resources +| where type =~ 'microsoft.network/networkinterfaces' +| where isnull(properties.virtualMachine) or properties.virtualMachine == '' +| where isnull(properties.privateEndpoint) or properties.privateEndpoint == '' +| project name, resourceGroup, subscriptionId, location, + enableAcceleratedNetworking = properties.enableAcceleratedNetworking, + type = 'Unattached NIC' +"@ + $result = Search-AzGraphSafe -Query $nicQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Unattached NIC' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "Accelerated: $($r.enableAcceleratedNetworking)" + Impact = 'Low' + }) + } + Write-Host " Unattached NICs: $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Unattached NIC query failed: $($_.Exception.Message)" + } + + # -- 4: Stopped (deallocated) VMs still on disk ----------------------- + try { + $vmQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where properties.extended.instanceView.powerState.displayStatus == 'VM deallocated' + or properties.extended.instanceView.powerState.code == 'PowerState/deallocated' +| project name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + powerState = properties.extended.instanceView.powerState.displayStatus, + type = 'Deallocated VM' +"@ + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Deallocated VM' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.vmSize) - still incurs disk/IP costs" + Impact = 'Medium' + }) + } + Write-Host " Deallocated VMs: $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Deallocated VM query failed: $($_.Exception.Message)" + } + + # -- 5: Empty App Service Plans (0 apps) ------------------------------ + try { + $aspQuery = @" +resources +| where type =~ 'microsoft.web/serverfarms' +| where properties.numberOfSites == 0 +| where sku.tier != 'Free' and sku.tier != 'Shared' +| project name, resourceGroup, subscriptionId, location, + sku = strcat(sku.tier, ' / ', sku.name), + workers = properties.numberOfWorkers, + type = 'Empty App Service Plan' +"@ + $result = Search-AzGraphSafe -Query $aspQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Empty App Service Plan' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.sku), $($r.workers) worker(s), 0 apps" + Impact = 'High' + }) + } + Write-Host " Empty App Service Plans: $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Empty ASP query failed: $($_.Exception.Message)" + } + + # -- 6: Orphaned Snapshots (older than 30 days) ----------------------- + try { + $snapshotCutoff = (Get-Date).AddDays(-30).ToString('yyyy-MM-dd') + $snapQuery = @" +resources +| where type =~ 'microsoft.compute/snapshots' +| where properties.timeCreated < datetime('$snapshotCutoff') +| project name, resourceGroup, subscriptionId, location, + diskSizeGb = properties.diskSizeGB, + timeCreated = properties.timeCreated, + type = 'Old Snapshot' +"@ + $result = Search-AzGraphSafe -Query $snapQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Old Snapshot (30d+)' + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.diskSizeGb) GB, created $($r.timeCreated)" + Impact = 'Low' + }) + } + Write-Host " Old snapshots (30d+): $($rows.Count)" -ForegroundColor Gray + } catch { + Write-Warning " Snapshot query failed: $($_.Exception.Message)" + } + + # -- Summary by category -- + $summary = $allOrphans | Group-Object Category | ForEach-Object { + [PSCustomObject]@{ + Category = $_.Name + Count = $_.Count + } + } + + return [PSCustomObject]@{ + Orphans = @($allOrphans) + Summary = @($summary) + TotalCount = $allOrphans.Count + HasData = ($allOrphans.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 new file mode 100644 index 000000000..fdea7c8f9 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -0,0 +1,298 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-POLICYINVENTORY.PS1 +# AZURE FINOPS MULTITOOL - Policy Inventory Across the Tenant +########################################################################### +# Purpose: Scan all policy assignments across the tenant's subscriptions +# and return a summary of assigned policies, their effects, +# scopes, and compliance state. +# +# Strategy: Resource Graph for assignments (1 paginated call) + +# MG-scope Policy Insights for compliance (1 call). +# Falls back to per-sub only for small tenants if above fail. +########################################################################### + +function Get-PolicyInventory { + [CmdletBinding()] + param( + [Parameter()] + [string]$TenantId, + + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $subCount = $Subscriptions.Count + Write-Host " Scanning policy assignments across $subCount subscriptions..." -ForegroundColor Cyan + + $allAssignments = [System.Collections.Generic.List[PSCustomObject]]::new() + $complianceMap = @{} + $gotAssignments = $false + $gotCompliance = $false + + # -- Strategy 1: ARM REST API for ALL effective assignments ---------- + # Resource Graph policyresources at subscription scope only returns + # assignments AT that scope. The ARM Policy API returns ALL effective + # assignments including those inherited from management groups and + # the tenant root group. + try { + Write-Host " Querying policy assignments via ARM REST API..." -ForegroundColor Cyan + $seenIds = @{} + foreach ($sub in $Subscriptions) { + $subName = $sub.Name + $nextLink = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01" + while ($nextLink) { + $resp = Invoke-AzRestMethodWithRetry -Path $nextLink -Method GET + if ($resp.StatusCode -ne 200) { break } + $body = $resp.Content | ConvertFrom-Json + foreach ($a in $body.value) { + # De-duplicate (same MG assignment appears under each sub) + if ($seenIds.ContainsKey($a.id)) { continue } + $seenIds[$a.id] = $true + + $props = $a.properties + $defId = $props.policyDefinitionId + $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' } + elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } + else { 'Custom' } + $scope = if ($a.id -match '^(.*)/providers/Microsoft\.Authorization/policyAssignments/') { + $Matches[1] + } + else { '' } + + [void]$allAssignments.Add([PSCustomObject]@{ + AssignmentName = if ($props.displayName) { $props.displayName } else { $a.name } + AssignmentId = $a.id + PolicyDefId = $defId + Scope = $scope + Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' } + EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' } + Origin = $origin + Subscription = $subName + Description = if ($props.description) { $props.description } else { '' } + }) + } + # Handle pagination via nextLink + $nextLink = if ($body.nextLink) { + $body.nextLink -replace '^https://management\.azure\.com', '' + } + else { $null } + } + } + + if ($allAssignments.Count -gt 0) { + $gotAssignments = $true + Write-Host " ARM REST API: $($allAssignments.Count) unique policy assignments (including inherited)" -ForegroundColor Green + } + } + catch { + Write-Warning " ARM REST policy query failed: $($_.Exception.Message)" + } + + # Fallback: Resource Graph if ARM REST didn't find any + if (-not $gotAssignments) { + try { + Write-Host " Falling back to Resource Graph for policy assignments..." -ForegroundColor Yellow + $argQuery = @" +policyresources +| where type =~ 'microsoft.authorization/policyassignments' +| project id, name, properties, subscriptionId, type +"@ + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $skipToken = $null + $pageNum = 0 + do { + $pageNum++ + $result = Search-AzGraphSafe -Query $argQuery -Subscription $subIds -First 1000 -SkipToken $skipToken + if ($result -and $result.Data) { + foreach ($r in $result.Data) { + $props = $r.properties + $defId = $props.policyDefinitionId + $origin = if ($defId -match '/policySetDefinitions/') { 'Initiative' } + elseif ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } + else { 'Custom' } + $subName = $r.subscriptionId + $matchSub = $Subscriptions | Where-Object { $_.Id -eq $r.subscriptionId } | Select-Object -First 1 + if ($matchSub) { $subName = $matchSub.Name } + [void]$allAssignments.Add([PSCustomObject]@{ + AssignmentName = if ($props.displayName) { $props.displayName } else { $r.name } + AssignmentId = $r.id + PolicyDefId = $defId + Scope = if ($props.scope) { $props.scope } else { ($r.id -replace '/providers/Microsoft\.Authorization/policyAssignments/.*', '') } + Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' } + EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' } + Origin = $origin + Subscription = $subName + Description = if ($props.description) { $props.description } else { '' } + }) + } + $skipToken = $result.SkipToken + } + else { $skipToken = $null } + } while ($skipToken) + if ($allAssignments.Count -gt 0) { + $gotAssignments = $true + Write-Host " Resource Graph fallback: $($allAssignments.Count) assignments" -ForegroundColor Green + } + } + catch { + Write-Warning " Resource Graph policy query failed: $($_.Exception.Message)" + } + } + + # -- Strategy 2: Resource Graph for compliance (tenant-wide, fast) --- + # The MG-scope PolicyInsights summarize REST API hangs indefinitely, + # and per-sub REST loops are slow on large tenants. + # Resource Graph policyresources table gives us compliance across ALL + # subscriptions in a single paginated call - fast and complete. + try { + Write-Host " Querying policy compliance via Resource Graph..." -ForegroundColor Cyan + $compQuery = @" +policyresources +| where type =~ 'microsoft.policyinsights/policystates' +| extend complianceState = tostring(properties.complianceState) +| summarize + Compliant = countif(complianceState =~ 'Compliant'), + NonCompliant = countif(complianceState =~ 'NonCompliant'), + Total = count() + by subscriptionId +"@ + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $compResult = Search-AzGraphSafe -Query $compQuery -Subscription $subIds -First 1000 + + if ($compResult -and $compResult.Data -and $compResult.Data.Count -gt 0) { + foreach ($row in $compResult.Data) { + $subName = $row.subscriptionId + $matchSub = $Subscriptions | Where-Object { $_.Id -eq $row.subscriptionId } | Select-Object -First 1 + if ($matchSub) { $subName = $matchSub.Name } + + $complianceMap[$row.subscriptionId] = [PSCustomObject]@{ + Subscription = $subName + SubscriptionId = $row.subscriptionId + TotalResources = $row.Total + NonCompliant = $row.NonCompliant + Compliant = $row.Compliant + # Not derivable from the compliance query; the REST fallback + # computes it. $null distinguishes "unknown" from a real zero. + PolicyCount = $null + } + } + $gotCompliance = $true + Write-Host " Resource Graph compliance: $($complianceMap.Count) subscriptions" -ForegroundColor Green + } + } + catch { + Write-Warning " Resource Graph compliance query failed: $($_.Exception.Message)" + } + + # -- Compliance fallback: per-sub REST (only if ARG compliance failed) -- + if (-not $gotCompliance) { + Write-Host " Falling back to per-sub compliance queries..." -ForegroundColor Yellow + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($subCount -gt 20 -and ($i % 10 -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Scanning policy compliance ($i/$subCount)..." + } + } + try { + $compPath = "/subscriptions/$($sub.Id)/providers/Microsoft.PolicyInsights/policyStates/latest/summarize?api-version=2019-10-01" + $compResp = Invoke-AzRestMethodWithRetry -Path $compPath -Method POST + if ($compResp.StatusCode -eq 200) { + $summary = ($compResp.Content | ConvertFrom-Json).value + if ($summary -and $summary.Count -gt 0) { + $s = $summary[0].results + $complianceMap[$sub.Id] = [PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + TotalResources = $s.resourceDetails | ForEach-Object { $_.count } | Measure-Object -Sum | Select-Object -ExpandProperty Sum + NonCompliant = ($s.resourceDetails | Where-Object { $_.complianceState -eq 'noncompliant' }).count + Compliant = ($s.resourceDetails | Where-Object { $_.complianceState -eq 'compliant' }).count + PolicyCount = $s.policyDetails | ForEach-Object { $_.count } | Measure-Object -Sum | Select-Object -ExpandProperty Sum + } + } + } + } + catch { + Write-Warning " Policy compliance failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Strategy 3: Per-sub fallback (only if Resource Graph failed) --- + if (-not $gotAssignments) { + Write-Host " Falling back to per-subscription policy scan..." -ForegroundColor Yellow + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Scanning policies ($i/$subCount subs)..." + } + } + try { + $assignPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01" + $resp = Invoke-AzRestMethodWithRetry -Path $assignPath -Method GET + if ($resp.StatusCode -eq 200) { + $assignments = ($resp.Content | ConvertFrom-Json).value + foreach ($a in $assignments) { + $props = $a.properties + $defId = $props.policyDefinitionId + $origin = if ($defId -match '/providers/Microsoft\.Authorization/policyDefinitions/') { 'BuiltIn' } else { 'Custom' } + if ($defId -match '/policySetDefinitions/') { $origin = 'Initiative' } + + [void]$allAssignments.Add([PSCustomObject]@{ + AssignmentName = $props.displayName + AssignmentId = $a.id + PolicyDefId = $defId + Scope = $props.scope + Effect = if ($props.parameters -and $props.parameters.effect) { $props.parameters.effect.value } else { '-' } + EnforcementMode = if ($props.enforcementMode) { $props.enforcementMode } else { 'Default' } + Origin = $origin + Subscription = $sub.Name + Description = if ($props.description) { $props.description } else { '' } + }) + } + } + } + catch { + Write-Warning " Policy assignments failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Deduplicate assignments by name + scope ----------------------- + $seen = @{} + $unique = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($a in $allAssignments) { + $key = "$($a.AssignmentName)|$($a.Scope)" + if (-not $seen.ContainsKey($key)) { + $seen[$key] = $true + [void]$unique.Add($a) + } + } + + # -- Compliance totals --------------------------------------------- + $totalCompliant = 0 + $totalNonCompliant = 0 + foreach ($c in $complianceMap.Values) { + $totalCompliant += $c.Compliant + $totalNonCompliant += $c.NonCompliant + } + $totalEvaluated = $totalCompliant + $totalNonCompliant + $compliancePct = if ($totalEvaluated -gt 0) { [math]::Round(($totalCompliant / $totalEvaluated) * 100, 1) } else { 0 } + + return [PSCustomObject]@{ + Assignments = $unique + AssignmentCount = $unique.Count + ComplianceBySubMap = $complianceMap + CompliancePct = $compliancePct + TotalCompliant = $totalCompliant + TotalNonCompliant = $totalNonCompliant + TotalEvaluated = $totalEvaluated + HasComplianceData = ($totalEvaluated -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 new file mode 100644 index 000000000..c622f0916 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 @@ -0,0 +1,254 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-POLICYRECOMMENDATIONS.PS1 +# AZURE FINOPS MULTITOOL - FinOps Policy Recommendations +########################################################################### +# Purpose: Compare the customer's existing policy assignments against a +# curated list of Microsoft-recommended FinOps/cost governance +# policies (Azure built-in policy definitions). +# +# Sources: +# - Azure built-in policies (Tags, General, Compute, Storage categories) +# - Microsoft Cloud Adoption Framework cost governance guidance +# - AzAdvertizer.net policy catalog reference +# +# Each recommendation includes the built-in policy definition ID so +# it can be deployed directly from the GUI. +########################################################################### + +function Get-PolicyRecommendations { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$ExistingAssignments # Policy assignment objects from Get-PolicyInventory + ) + + # -- Curated FinOps / Cost Governance Policies ---------------------- + # These are Azure built-in policy definition IDs verified from + # https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies + $recommendedPolicies = @( + # === TAGGING & NAMING (CAF: Enforce Tagging and Naming) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/726aca4c-86e9-4b04-b0c5-073027359532' + DisplayName = 'Require a tag on resources' + Category = 'Tags' + Pillar = 'Understand' + Priority = 'Required' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Enforce tagging on all resources for cost allocation and chargeback visibility' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags' + Parameters = @( + @{ Name = 'tagName'; Label = 'Tag name (e.g. CostCenter)'; Required = $true } + @{ Name = 'tagValue'; Label = 'Tag value (leave blank for any value)'; Required = $false } + ) + } + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/96670d01-0a4d-4649-9c89-2d3abc0a5025' + DisplayName = 'Require a tag on resource groups' + Category = 'Tags' + Pillar = 'Understand' + Priority = 'Required' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Enforce tagging on resource groups for cost allocation at the container level' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags' + Parameters = @( + @{ Name = 'tagName'; Label = 'Tag name (e.g. CostCenter)'; Required = $true } + ) + } + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/ea3f2387-9b95-492a-a190-fcdc54f7b070' + DisplayName = 'Inherit a tag from the resource group if missing' + Category = 'Tags' + Pillar = 'Understand' + Priority = 'Recommended' + DefaultEffect = 'Modify' + AllowedEffects = @('Modify','Disabled') + Purpose = 'Auto-inherit tags from resource group to child resources for consistent cost allocation' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags' + Parameters = @( + @{ Name = 'tagName'; Label = 'Tag name to inherit (e.g. CostCenter)'; Required = $true } + ) + } + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/40df99da-1232-49b1-a39a-6da8d878f469' + DisplayName = 'Inherit a tag from the subscription if missing' + Category = 'Tags' + Pillar = 'Understand' + Priority = 'Recommended' + DefaultEffect = 'Modify' + AllowedEffects = @('Modify','Disabled') + Purpose = 'Auto-inherit tags from subscription to resources for top-level cost allocation' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#tags' + Parameters = @( + @{ Name = 'tagName'; Label = 'Tag name to inherit (e.g. CostCenter)'; Required = $true } + ) + } + + # === ALLOWED RESOURCE LOCATIONS (CAF) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/e56962a6-4747-49cd-b67b-bf8b01975c4c' + DisplayName = 'Allowed locations' + Category = 'General' + Pillar = 'Optimize' + Priority = 'Required' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Restrict resource deployment to authorized Azure regions for compliance and cost control' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#general' + Parameters = @( + @{ Name = 'listOfAllowedLocations'; Label = 'Allowed locations (comma-separated, e.g. eastus,westus2,centralus)'; Required = $true; IsArray = $true } + ) + } + + # === RESTRICT VM SIZES (CAF) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/cccc23c7-8427-4f53-ad12-b6a63eb452b3' + DisplayName = 'Allowed virtual machine size SKUs' + Category = 'Compute' + Pillar = 'Optimize' + Priority = 'Required' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Restrict VM sizes to prevent over-provisioning and control compute costs' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#compute' + Parameters = @( + @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed VM SKUs (comma-separated, e.g. Standard_D2s_v3,Standard_B2ms)'; Required = $true; IsArray = $true } + ) + } + + # === ALLOWED STORAGE ACCOUNT SKUS (CAF) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1' + DisplayName = 'Allowed storage account SKUs' + Category = 'Storage' + Pillar = 'Optimize' + Priority = 'Recommended' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Restrict storage account types to control costs and enforce standard tiers' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#storage' + Parameters = @( + @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed storage SKUs (comma-separated, e.g. Standard_LRS,Standard_GRS,Standard_ZRS)'; Required = $true; IsArray = $true } + ) + } + + # === ALLOWED DISK SKUS (CAF) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/06a78e20-9358-41c9-923c-fb736d382a4d' + DisplayName = 'Allowed managed disk SKUs' + Category = 'Compute' + Pillar = 'Optimize' + Priority = 'Recommended' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Restrict managed disk types to prevent costly Premium or Ultra disks where not needed' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#compute' + Parameters = @( + @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed disk SKUs (comma-separated, e.g. Standard_LRS,StandardSSD_LRS,Premium_LRS)'; Required = $true; IsArray = $true } + ) + } + + # === DEPLOY DIAGNOSTIC SETTINGS (CAF) === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7f89b1eb-583c-429a-8828-af049802c1d9' + DisplayName = 'Audit diagnostic setting' + Category = 'Monitoring' + Pillar = 'Understand' + Priority = 'Required' + DefaultEffect = 'AuditIfNotExists' + AllowedEffects = @('AuditIfNotExists','Disabled') + Purpose = 'Automatically enable logging for diagnostics - ensures visibility into resource operations and costs' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#monitoring' + Parameters = @( + @{ Name = 'listOfResourceTypes'; Label = 'Resource types to audit (comma-separated, e.g. Microsoft.Compute/virtualMachines,Microsoft.Sql/servers,Microsoft.Storage/storageAccounts)'; Required = $true; IsArray = $true } + ) + } + + # === ADDITIONAL FINOPS-ALIGNED === + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/6c112d4e-5bc7-47ae-a041-ea2d9dccd749' + DisplayName = 'Not allowed resource types' + Category = 'General' + Pillar = 'Optimize' + Priority = 'Recommended' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Block expensive or unnecessary resource types to reduce cost sprawl' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#general' + Parameters = @( + @{ Name = 'listOfResourceTypesNotAllowed'; Label = 'Resource types to block (comma-separated, e.g. Microsoft.Sql/servers,Microsoft.HDInsight/clusters)'; Required = $true; IsArray = $true } + ) + } + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/7433c107-6db4-4ad1-b57a-a76dce0154a1' + DisplayName = 'Storage accounts should be limited by allowed SKUs' + Category = 'Storage' + Pillar = 'Optimize' + Priority = 'Recommended' + DefaultEffect = 'Deny' + AllowedEffects = @('Audit','Deny','Disabled') + Purpose = 'Prevent Premium storage where Standard suffices to reduce storage costs' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#storage' + Parameters = @( + @{ Name = 'listOfAllowedSKUs'; Label = 'Allowed storage SKUs (comma-separated, e.g. Standard_LRS,Standard_GRS)'; Required = $true; IsArray = $true } + ) + } + [PSCustomObject]@{ + PolicyDefId = '/providers/Microsoft.Authorization/policyDefinitions/013e242c-8828-4970-87b3-ab247555486d' + DisplayName = 'Azure Backup should be enabled for Virtual Machines' + Category = 'Backup' + Pillar = 'Quantify' + Priority = 'Recommended' + DefaultEffect = 'AuditIfNotExists' + AllowedEffects = @('AuditIfNotExists','Disabled') + Purpose = 'Ensure VMs are backed up to prevent costly data loss recovery scenarios' + Reference = 'https://learn.microsoft.com/en-us/azure/governance/policy/samples/built-in-policies#backup' + } + ) + + # -- Match existing assignments against recommendations ------------ + $existingDefIds = @{} + $existingNames = @{} + foreach ($a in $ExistingAssignments) { + if ($a.PolicyDefId) { + $existingDefIds[$a.PolicyDefId.ToLower()] = $true + } + if ($a.AssignmentName) { + $existingNames[$a.AssignmentName.ToLower()] = $true + } + } + + $analysis = foreach ($rec in $recommendedPolicies) { + $foundById = $existingDefIds.ContainsKey($rec.PolicyDefId.ToLower()) + $foundByName = $existingNames.ContainsKey($rec.DisplayName.ToLower()) + $status = if ($foundById -or $foundByName) { 'Assigned' } else { 'Missing' } + + [PSCustomObject]@{ + DisplayName = $rec.DisplayName + Status = $status + Category = $rec.Category + Pillar = $rec.Pillar + Priority = $rec.Priority + DefaultEffect = $rec.DefaultEffect + AllowedEffects = $rec.AllowedEffects + Purpose = $rec.Purpose + PolicyDefId = $rec.PolicyDefId + Reference = $rec.Reference + Parameters = if ($rec.Parameters) { $rec.Parameters } else { @() } + } + } + + $missing = @($analysis | Where-Object { $_.Status -eq 'Missing' }) + $assigned = @($analysis | Where-Object { $_.Status -eq 'Assigned' }) + + return [PSCustomObject]@{ + Analysis = $analysis + Missing = $missing + Assigned = $assigned + CompliancePct = [math]::Round(($assigned.Count / $analysis.Count) * 100, 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 new file mode 100644 index 000000000..bbfa6b38b --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 @@ -0,0 +1,233 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-RESERVATIONADVICE.PS1 +# AZURE FINOPS MULTITOOL - Reservation & Savings Plan Recommendations +########################################################################### +# Purpose: Pull RI (Reserved Instance) and Savings Plan recommendations +# from Azure Advisor and the Reservation Recommendation API. +# +# Rate optimization (RI/SP) is the #1 FinOps quick win - typical +# savings are 30-72% versus pay-as-you-go pricing. +# +# Reference: https://learn.microsoft.com/en-us/azure/advisor/advisor-cost-recommendations +########################################################################### + +function Get-ReservationAdvice { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $allRecommendations = [System.Collections.Generic.List[PSCustomObject]]::new() + + # Set to $true if an Advisor or reservation-recommendation query is + # forbidden (401/403) rather than simply returning no recommendations. + $accessDenied = $false + + # Build subscription ID list and name lookup + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + $subNameMap = @{} + foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id] = $sub.Name } + + # Query Advisor cost recommendations via Resource Graph (single call) + $query = @" +advisorresources +| where type == 'microsoft.advisor/recommendations' +| where properties.category == 'Cost' +| where properties.shortDescription.problem matches regex '(?i)reserv|savings plan|reserved instance' + or properties.shortDescription.solution matches regex '(?i)reserv|savings plan|reserved instance' +| project subscriptionId, + shortDescriptionProblem = tostring(properties.shortDescription.problem), + shortDescriptionSolution = tostring(properties.shortDescription.solution), + impact = tostring(properties.impact), + impactedField = tostring(properties.impactedField), + impactedValue = tostring(properties.impactedValue), + annualSavings = tostring(properties.extendedProperties.annualSavingsAmount), + savingsCurrency = tostring(properties.extendedProperties.savingsCurrency), + term = tostring(properties.extendedProperties.term), + displaySKU = tostring(properties.extendedProperties.displaySKU), + region = tostring(properties.extendedProperties.region), + displayQty = tostring(properties.extendedProperties.displayQty), + recName = name +"@ + + try { + Write-Host " Querying RI/SP recommendations via Resource Graph..." -ForegroundColor Cyan + $allRows = [System.Collections.Generic.List[object]]::new() + $skipToken = $null + + do { + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -SkipToken $skipToken + if ($result -and $result.Data) { foreach ($r in $result.Data) { [void]$allRows.Add($r) } } + $skipToken = if ($result) { $result.SkipToken } else { $null } + } while ($skipToken) + + Write-Host " Retrieved $($allRows.Count) RI/SP recommendations." -ForegroundColor Cyan + + foreach ($row in $allRows) { + $subId = $row.subscriptionId + $savings = if ($row.annualSavings) { [math]::Round([double]$row.annualSavings, 2) } else { $null } + $subName = if ($subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { $subId } + + # Savings Plans are subscription-scoped, flexible commitments: Advisor + # returns the subscription GUID as impactedValue and carries no + # SKU/region/qty. Resolve the GUID to the subscription name and label + # the flexible dimensions 'Any' so the row reads as a real scope-wide + # commitment rather than a bare ID with missing data. RIs keep their + # real SKU/region/qty. The savings come straight from Advisor. + $isSavingsPlan = ($row.shortDescriptionSolution -match '(?i)savings plan') -or ($row.shortDescriptionProblem -match '(?i)savings plan') + $isSubScope = $row.impactedField -match '(?i)subscriptions/subscriptions' + $resName = if ($isSubScope) { "$subName (subscription-wide)" } else { $row.impactedValue } + $sku = if ($row.displaySKU) { $row.displaySKU } elseif ($isSavingsPlan) { 'Any (flexible)' } else { '-' } + $region = if ($row.region) { $row.region } elseif ($isSavingsPlan) { 'Any' } else { '-' } + $qty = if ($row.displayQty) { $row.displayQty } elseif ($isSavingsPlan) { 'Commitment' } else { '-' } + + [void]$allRecommendations.Add([PSCustomObject]@{ + Subscription = $subName + SubscriptionId = $subId + Problem = $row.shortDescriptionProblem + Solution = $row.shortDescriptionSolution + Impact = $row.impact + Category = 'Reservation / Savings Plan' + ResourceType = $row.impactedField + ResourceName = $resName + SKU = $sku + Region = $region + Qty = $qty + AnnualSavings = $savings + Currency = $row.savingsCurrency + Term = $row.term + RecommendationId = $row.recName + }) + } + } catch { + Write-Warning " Advisor Resource Graph query failed: $($_.Exception.Message)" + Write-Warning " Falling back to per-subscription REST calls..." + + foreach ($sub in $Subscriptions) { + try { + $advPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Advisor/recommendations?api-version=2023-01-01&`$filter=Category eq 'Cost'" + $advResp = Invoke-AzRestMethodWithRetry -Path $advPath -Method GET + if ($advResp.StatusCode -ne 200) { + if ($advResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + continue + } + $advResult = ($advResp.Content | ConvertFrom-Json) + + $riRecs = $advResult.value | Where-Object { + $_.properties.shortDescription.problem -match 'reserv|savings plan|reserved instance' -or + $_.properties.shortDescription.solution -match 'reserv|savings plan|reserved instance' + } + + foreach ($item in $riRecs) { + $rec = $item.properties + $isSavingsPlan = ($rec.shortDescription.solution -match '(?i)savings plan') -or ($rec.shortDescription.problem -match '(?i)savings plan') + $isSubScope = $rec.impactedField -match '(?i)subscriptions/subscriptions' + $resName = if ($isSubScope) { "$($sub.Name) (subscription-wide)" } else { $rec.impactedValue } + $sku = if ($rec.extendedProperties.displaySKU) { $rec.extendedProperties.displaySKU } elseif ($isSavingsPlan) { 'Any (flexible)' } else { '-' } + $region = if ($rec.extendedProperties.region) { $rec.extendedProperties.region } elseif ($isSavingsPlan) { 'Any' } else { '-' } + $qty = if ($rec.extendedProperties.displayQty) { $rec.extendedProperties.displayQty } elseif ($isSavingsPlan) { 'Commitment' } else { '-' } + [void]$allRecommendations.Add([PSCustomObject]@{ + Subscription = $sub.Name + SubscriptionId = $sub.Id + Problem = $rec.shortDescription.problem + Solution = $rec.shortDescription.solution + Impact = $rec.impact + Category = 'Reservation / Savings Plan' + ResourceType = $rec.impactedField + ResourceName = $resName + SKU = $sku + Region = $region + Qty = $qty + AnnualSavings = if ($rec.extendedProperties.annualSavingsAmount) { + [math]::Round([double]$rec.extendedProperties.annualSavingsAmount, 2) + } else { $null } + Currency = $rec.extendedProperties.savingsCurrency + Term = $rec.extendedProperties.term + RecommendationId = $item.name + }) + } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Advisor query failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Also try the Reservation Recommendation API -------------------- + $reservationRecs = [System.Collections.Generic.List[PSCustomObject]]::new() + try { + $rrPath = "/providers/Microsoft.Consumption/reservationRecommendations?api-version=2023-05-01&`$filter=properties/scope eq 'Shared' and properties/lookBackPeriod eq 'Last30Days'" + $rrResp = Invoke-AzRestMethodWithRetry -Path $rrPath -Method GET + if ($rrResp -and $rrResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + if (-not $rrResp -or -not $rrResp.Content) { throw "Reservation recommendation API returned no content (HTTP $($rrResp.StatusCode))" } + $rrResult = ($rrResp.Content | ConvertFrom-Json) + + if ($rrResult.value) { + foreach ($item in $rrResult.value) { + $props = $item.properties + [void]$reservationRecs.Add([PSCustomObject]@{ + ResourceType = $props.resourceType + SKU = $props.skuProperties.name + RecommendedQty = $props.recommendedQuantity + Term = $props.term + CostWithoutRI = if ($props.costWithNoReservedInstances) { [math]::Round($props.costWithNoReservedInstances, 2) } else { $null } + CostWithRI = if ($props.totalCostWithReservedInstances) { [math]::Round($props.totalCostWithReservedInstances, 2) } else { $null } + NetSavings = if ($props.netSavings) { [math]::Round($props.netSavings, 2) } else { $null } + Currency = $props.currencyCode + Scope = $props.scope + LookBackPeriod = $props.lookBackPeriod + }) + } + } + } catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning "Reservation recommendation API query failed (non-critical): $($_.Exception.Message)" + } + + # -- De-duplicate Advisor records ----------------------------------- + # Azure Advisor frequently emits several identical recommendation + # records that differ only by recommendation GUID (overlapping + # generation cycles). Collapse them on the meaningful tuple + # (sub + resource type + term + SKU + region + qty + savings) so the + # grid shows one row per distinct buy. DuplicateCount records how + # many Advisor records were rolled into each row, and de-duping also + # prevents the estimated-savings total from being inflated 3x. + $deduped = [System.Collections.Generic.List[PSCustomObject]]::new() + $seenKeys = @{} + foreach ($rec in $allRecommendations) { + $key = '{0}|{1}|{2}|{3}|{4}|{5}|{6}' -f ` + $rec.SubscriptionId, $rec.ResourceType, $rec.Term, $rec.SKU, $rec.Region, $rec.Qty, $rec.AnnualSavings + if ($seenKeys.ContainsKey($key)) { + $seenKeys[$key].DuplicateCount++ + } + else { + $rec | Add-Member -NotePropertyName DuplicateCount -NotePropertyValue 1 -Force + $seenKeys[$key] = $rec + [void]$deduped.Add($rec) + } + } + if ($allRecommendations.Count -ne $deduped.Count) { + Write-Host " Collapsed $($allRecommendations.Count) Advisor records into $($deduped.Count) distinct recommendations." -ForegroundColor Cyan + } + $allRecommendations = $deduped + + # -- Aggregate savings ---------------------------------------------- + $totalAnnualSavings = ($allRecommendations | Where-Object { $_.AnnualSavings } | + Measure-Object -Property AnnualSavings -Sum).Sum + + $denied = ($accessDenied -and $allRecommendations.Count -eq 0 -and $reservationRecs.Count -eq 0) + + return [PSCustomObject]@{ + AdvisorRecommendations = $allRecommendations + ReservationRecommendations = $reservationRecs + TotalAdvisorCount = $allRecommendations.Count + TotalReservationCount = $reservationRecs.Count + EstimatedAnnualSavings = [math]::Round($totalAnnualSavings, 2) + AccessDenied = $denied + Summary = "$($allRecommendations.Count) Advisor + $($reservationRecs.Count) reservation recommendations" + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 new file mode 100644 index 000000000..d7c18a23f --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 @@ -0,0 +1,376 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-RESOURCECOSTS.PS1 +# AZURE FINOPS MULTITOOL - Per-Resource Cost Breakdown +########################################################################### +# Purpose: Query Cost Management per subscription to retrieve actual and +# forecasted spend grouped by individual resource. +########################################################################### + +function Get-ResourceCosts { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions, + + [Parameter()] + [string]$TenantId, + + [Parameter()] + $CostData, # Per-sub cost data for forecast ratio distribution + + [Parameter()] + [switch]$RestrictToSelected + ) + + # Guard: extract hashtable if pipeline pollution wrapped it in an array + if ($CostData -and $CostData -isnot [hashtable]) { + $CostData = @($CostData | Where-Object { $_ -is [hashtable] })[-1] + } + if (-not $CostData) { $CostData = @{} } + + $allRows = [System.Collections.Generic.List[PSCustomObject]]::new() + + # Linear month-to-date projection factor for per-resource forecasts. The + # per-resource Cost Management query only returns ActualCost (MTD), so a + # native forecast is not available. Project to month-end (Actual / dayOfMonth + # * daysInMonth) so Forecast is a real projection instead of equal to Actual. + $now = Get-Date + $daysInMonth = [DateTime]::DaysInMonth($now.Year, $now.Month) + $dayOfMonth = [math]::Max(1, $now.Day) + $forecastMult = $daysInMonth / $dayOfMonth + + # Friendly resource type map + $typeMap = @{ + 'microsoft.compute/virtualmachines' = 'Virtual Machine' + 'microsoft.compute/disks' = 'Managed Disk' + 'microsoft.network/loadbalancers' = 'Load Balancer' + 'microsoft.network/applicationgateways' = 'App Gateway' + 'microsoft.network/azurefirewalls' = 'Azure Firewall' + 'microsoft.network/publicipaddresses' = 'Public IP' + 'microsoft.network/virtualnetworkgateways' = 'VNet Gateway' + 'microsoft.network/virtualnetworks' = 'Virtual Network' + 'microsoft.network/privatednszones' = 'Private DNS Zone' + 'microsoft.network/networkinterfaces' = 'NIC' + 'microsoft.network/networksecuritygroups' = 'NSG' + 'microsoft.network/bastionhosts' = 'Bastion' + 'microsoft.containerservice/managedclusters' = 'AKS Cluster' + 'microsoft.sql/servers' = 'SQL Server' + 'microsoft.sql/servers/databases' = 'SQL Database' + 'microsoft.storage/storageaccounts' = 'Storage Account' + 'microsoft.web/sites' = 'App Service' + 'microsoft.web/serverfarms' = 'App Service Plan' + 'microsoft.keyvault/vaults' = 'Key Vault' + 'microsoft.operationalinsights/workspaces' = 'Log Analytics' + 'microsoft.insights/components' = 'App Insights' + 'microsoft.recoveryservices/vaults' = 'Recovery Vault' + 'microsoft.automation/automationaccounts' = 'Automation Account' + 'microsoft.dbformysql/flexibleservers' = 'MySQL Flexible' + 'microsoft.dbforpostgresql/flexibleservers' = 'PostgreSQL Flexible' + 'microsoft.cosmosdb/databaseaccounts' = 'Cosmos DB' + 'microsoft.cache/redis' = 'Redis Cache' + 'microsoft.cdn/profiles' = 'CDN / Front Door' + 'microsoft.containerregistry/registries' = 'Container Registry' + 'microsoft.apimanagement/service' = 'API Management' + 'microsoft.eventgrid/topics' = 'Event Grid Topic' + 'microsoft.servicebus/namespaces' = 'Service Bus' + 'microsoft.logic/workflows' = 'Logic App' + 'microsoft.security/pricings' = 'Defender Plan' + 'microsoft.hybridcompute/machines' = 'Arc Server' + } + + $gotMgData = $false + + # -- Strategy 1: MG-scope query (1-10 API calls instead of 300+) ---- + # When the user picked a subset of subscriptions we KEEP the fast MG-scope + # query but add a server-side SubscriptionId filter so only the selected + # subs' resources are returned - avoids the slow per-subscription fan-out + # that triggers 429 throttling. + $mgScopeId = if ($TenantId) { Resolve-CostMgId -TenantId $TenantId } else { $null } + $subFilter = if ($RestrictToSelected) { Get-CostSubscriptionFilter -Subscriptions $Subscriptions } else { $null } + if ($mgScopeId) { + try { + Write-Host " Querying resource costs (MG scope)..." -ForegroundColor Cyan + $rcDataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'ResourceId' } + @{ type = 'Dimension'; name = 'ResourceGroupName' } + ) + } + if ($subFilter) { $rcDataset['filter'] = $subFilter } + $body = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = $rcDataset + } | ConvertTo-Json -Depth 10 + + $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $body + + if ($resp.StatusCode -eq 200) { + $result = ($resp.Content | ConvertFrom-Json) + $cols = @{} + for ($colIdx = 0; $colIdx -lt $result.properties.columns.Count; $colIdx++) { + $cols[$result.properties.columns[$colIdx].name] = $colIdx + } + + $page = $result + $pageNum = 0 + do { + $pageNum++ + if ($page.properties.rows) { + if ($pageNum -eq 1 -or $pageNum % 3 -eq 0) { + Write-Host " Page $pageNum ($($page.properties.rows.Count) rows)..." -ForegroundColor Gray + } + foreach ($row in $page.properties.rows) { + $cost = [math]::Round($row[$cols['Cost']], 2) + $currency = $row[$cols['Currency']] + $resourceId = $row[$cols['ResourceId']] + $rg = $row[$cols['ResourceGroupName']] + + $resType = 'Unknown' + $resName = $resourceId + if ($resourceId -match '/providers/(.+)/([^/]+)$') { + $providerType = $Matches[1].ToLower() + $resName = $Matches[2] + $resType = if ($typeMap.ContainsKey($providerType)) { $typeMap[$providerType] } else { $providerType -replace 'microsoft\.', '' } + } + + [void]$allRows.Add([PSCustomObject]@{ + Subscription = '' + ResourceGroup = $rg + ResourceType = $resType + ResourcePath = $resourceId + Actual = $cost + Forecast = [math]::Round($cost * $forecastMult, 2) + Currency = $currency + }) + } + } + if ($page.properties.nextLink) { + $nextUri = [System.Uri]$page.properties.nextLink + $nResp = Invoke-AzRestMethodWithRetry -Path $nextUri.PathAndQuery -Method GET + if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) } + else { break } + } + else { break } + } while ($true) + + if ($allRows.Count -gt 0) { + $gotMgData = $true + Write-Host " MG scope: $($allRows.Count) resources across $pageNum page(s)" -ForegroundColor Green + + # Populate subscription names from ARM resource ID + $subNameMap = @{} + foreach ($sub in $Subscriptions) { $subNameMap[$sub.Id.ToLower()] = $sub.Name } + foreach ($r in $allRows) { + if ($r.ResourcePath -match '/subscriptions/([^/]+)/') { + $sid = $Matches[1].ToLower() + $r.Subscription = if ($subNameMap.ContainsKey($sid)) { $subNameMap[$sid] } else { $sid } + } + } + + # Apply forecast ratios from CostData (actual + forecast per sub) + if ($CostData) { + $ratios = @{} + foreach ($entry in $CostData.GetEnumerator()) { + $a = $entry.Value.Actual + $f = $entry.Value.Forecast + if ($a -gt 0 -and $f -gt $a) { $ratios[$entry.Key.ToLower()] = $f / $a } + } + foreach ($r in $allRows) { + if ($r.ResourcePath -match '/subscriptions/([^/]+)/') { + $sid = $Matches[1].ToLower() + if ($ratios.ContainsKey($sid)) { + $r.Forecast = [math]::Round($r.Actual * $ratios[$sid], 2) + } + } + } + } + } + } + else { + if ($resp.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed } + Write-Warning " MG-scope resource cost query returned HTTP $($resp.StatusCode)" + } + } + catch { + Write-Warning " MG-scope resource cost query failed: $($_.Exception.Message)" + } + } + + # -- Strategy 2: Per-subscription fallback (only if MG scope failed) - + if (-not $gotMgData) { + $subCount = $Subscriptions.Count + $skipForecast = ($subCount -gt 50) # For large tenants, skip per-sub forecast to halve API calls + if ($skipForecast) { + Write-Host " Large tenant ($subCount subs): skipping per-resource forecast to reduce API calls" -ForegroundColor Yellow + } + + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($i -eq 1 -or $i -eq $subCount -or ($subCount -gt 5 -and $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying resource costs ($i/$subCount subs)..." + } + } + $basePath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement" + + # -- Actual cost grouped by resource ---------------------------- + $actualMap = @{} + try { + Write-Host " Querying resource costs for $($sub.Name)..." -ForegroundColor Cyan + $body = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'ResourceId' } + @{ type = 'Dimension'; name = 'ResourceGroupName' } + ) + } + } | ConvertTo-Json -Depth 10 + + $resp = Invoke-AzRestMethodWithRetry -Path "$basePath/query?api-version=2023-11-01" -Method POST -Payload $body + + if ($resp.StatusCode -eq 200) { + $result = ($resp.Content | ConvertFrom-Json) + + # Build column index from response metadata (same for all pages) + # Distinct loop variable: $i is the outer per-subscription counter. + $cols = @{} + for ($colIdx = 0; $colIdx -lt $result.properties.columns.Count; $colIdx++) { + $cols[$result.properties.columns[$colIdx].name] = $colIdx + } + + # Process all pages (Cost Management API paginates at ~5000 rows) + $page = $result + do { + if ($page.properties.rows) { + foreach ($row in $page.properties.rows) { + $cost = [math]::Round($row[$cols['Cost']], 2) + $currency = $row[$cols['Currency']] + $resourceId = $row[$cols['ResourceId']] + $rg = $row[$cols['ResourceGroupName']] + + # Extract resource type from ARM ID + $resType = 'Unknown' + $resName = $resourceId + if ($resourceId -match '/providers/(.+)/([^/]+)$') { + $providerType = $Matches[1].ToLower() + $resName = $Matches[2] + $resType = if ($typeMap.ContainsKey($providerType)) { $typeMap[$providerType] } else { $providerType -replace 'microsoft\.', '' } + } + + $actualMap[$resourceId] = [PSCustomObject]@{ + Subscription = $sub.Name + ResourceGroup = $rg + ResourceType = $resType + ResourcePath = $resourceId + Actual = $cost + Forecast = [math]::Round($cost * $forecastMult, 2) + Currency = $currency + } + } + } + # Follow pagination link if present + if ($page.properties.nextLink) { + $uri = [System.Uri]$page.properties.nextLink + $nResp = Invoke-AzRestMethodWithRetry -Path $uri.PathAndQuery -Method GET + if ($nResp.StatusCode -eq 200) { $page = ($nResp.Content | ConvertFrom-Json) } + else { break } + } + else { break } + } while ($true) + } + } + catch { + Write-Warning " Resource cost query failed for $($sub.Name): $($_.Exception.Message)" + } + + # -- Forecast: use subscription-level forecast ratio ------------- + # The forecast API does not reliably support ResourceId grouping, + # so we get the sub-level forecast and distribute proportionally. + # For large tenants (50+ subs), skip per-sub forecast API calls + # and use CostData ratios if available. + $subTotalActual = 0 + foreach ($entry in $actualMap.Values) { $subTotalActual += $entry.Actual } + + $subForecast = $subTotalActual # default: same as actual + + # Use CostData ratio if available (avoids extra API call) + if ($CostData -and $CostData.ContainsKey($sub.Id)) { + $cd = $CostData[$sub.Id] + if ($cd.Forecast -gt $cd.Actual -and $cd.Actual -gt 0) { + $subForecast = $subTotalActual * ($cd.Forecast / $cd.Actual) + } + } + elseif (-not $skipForecast) { + # Only call forecast API for small tenants without CostData + try { + $now = Get-Date + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + + $fBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.ToString('yyyy-MM-dd') + to = $monthEnd.ToString('yyyy-MM-dd') + } + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + } + includeActualCost = $true + includeFreshPartialCost = $false + } | ConvertTo-Json -Depth 10 + + $fResp = Invoke-AzRestMethodWithRetry -Path "$basePath/forecast?api-version=2023-11-01" -Method POST -Payload $fBody + + if ($fResp.StatusCode -eq 200) { + $fResult = ($fResp.Content | ConvertFrom-Json) + if ($fResult.properties.rows -and $fResult.properties.rows.Count -gt 0) { + $forecastTotal = 0 + foreach ($row in $fResult.properties.rows) { + $forecastTotal += [double]$row[0] + } + $subForecast = [math]::Round($forecastTotal, 2) + } + } + } + catch { + # Forecast not available for all account types + } + } + + # Apply forecast ratio proportionally to each resource + if ($subTotalActual -gt 0 -and $subForecast -gt $subTotalActual) { + $ratio = $subForecast / $subTotalActual + foreach ($entry in $actualMap.Values) { + $entry.Forecast = [math]::Round($entry.Actual * $ratio, 2) + } + } + + # Collect rows from this sub + foreach ($entry in $actualMap.Values) { + [void]$allRows.Add($entry) + } + } + } # end per-sub fallback + + return $allRows +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 new file mode 100644 index 000000000..ee9482330 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -0,0 +1,375 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-SAVINGSREALIZED.PS1 +# AZURE FINOPS MULTITOOL - Savings Already Realized from Commitments +########################################################################### +# Purpose: Calculate how much existing RIs, Savings Plans, and AHB have +# already saved vs pay-as-you-go. This is the "value delivered" +# metric that FinOps teams report to leadership. +########################################################################### + +function Get-SavingsRealized { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions, + + [Parameter()] + [string]$TenantId, + + [Parameter()] + [object]$CommitmentData + ) + + Write-Host " Calculating savings already realized..." -ForegroundColor Cyan + + $riSavings = 0 + $spSavings = 0 + $ahbSavings = 0 + # Amortized cost split by pricing model, used for commitment COVERAGE + # (how much of eligible spend rides on a commitment) - distinct from the + # savings amounts above. Spot is excluded from the eligible base because it + # cannot be covered by a reservation or savings plan. + $committedAmort = 0.0 + $onDemandAmort = 0.0 + $spotAmort = 0.0 + $details = [System.Collections.Generic.List[PSCustomObject]]::new() + + # -- Short-circuit: skip RI/SP queries if no commitments exist ------- + $hasCommitments = $true + if ($CommitmentData -and $CommitmentData.PSObject.Properties['HasData']) { + if (-not $CommitmentData.HasData) { + $hasCommitments = $false + Write-Host " No reservations or savings plans detected — skipping commitment savings queries" -ForegroundColor DarkGray + } + } + + $gotMgData = $false + $subCount = if ($Subscriptions) { $Subscriptions.Count } else { 0 } + + # Map subscription Id -> friendly name so MG-grouped rows keep per-sub attribution + $subNameById = @{} + foreach ($s in $Subscriptions) { $subNameById[$s.Id] = $s.Name } + + # Build a Cost Management query body with the requested grouping dimensions + function New-SavingsQueryBody { + param([string]$Type, [string[]]$Dimensions) + @{ + type = $Type + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @($Dimensions | ForEach-Object { @{ type = 'Dimension'; name = $_ } }) + } + } | ConvertTo-Json -Depth 10 + } + + # Resolve named column indices from a Cost Management query result + function Get-SavingsColMap { + param($Columns) + $map = @{ Cost = 0; ChargeType = -1; PricingModel = -1; SubscriptionId = -1 } + for ($c = 0; $c -lt $Columns.Count; $c++) { + switch ($Columns[$c].name) { + 'Cost' { $map.Cost = $c } + 'ChargeType' { $map.ChargeType = $c } + 'PricingModel' { $map.PricingModel = $c } + 'SubscriptionId' { $map.SubscriptionId = $c } + } + } + $map + } + + # Parse an ActualCost result for UnusedReservation waste; returns detail rows + function Read-SavingsActual { + param($Result) + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + if (-not $Result -or -not $Result.properties.rows) { return $rows } + $m = Get-SavingsColMap -Columns $Result.properties.columns + foreach ($row in $Result.properties.rows) { + $charge = if ($m.ChargeType -ge 0) { [string]$row[$m.ChargeType] } else { '' } + if ($charge -match 'UnusedReservation') { + $sub = 'All (MG scope)' + if ($m.SubscriptionId -ge 0) { + $sid = [string]$row[$m.SubscriptionId] + $sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid } + } + $rows.Add([PSCustomObject]@{ + Subscription = $sub + Category = 'Unused Reservation' + Amount = [math]::Round([double]$row[$m.Cost], 2) + Type = 'Waste' + }) + } + } + return $rows + } + + # Parse an AmortizedCost result for RI/SP benefit; returns rows + savings totals + function Read-SavingsAmort { + param($Result) + $rows = [System.Collections.Generic.List[PSCustomObject]]::new() + $ri = 0.0; $sp = 0.0 + $committed = 0.0; $onDemand = 0.0; $spot = 0.0 + if ($Result -and $Result.properties.rows) { + $m = Get-SavingsColMap -Columns $Result.properties.columns + foreach ($row in $Result.properties.rows) { + $pm = if ($m.PricingModel -ge 0) { [string]$row[$m.PricingModel] } else { '' } + $cost = [math]::Round([double]$row[$m.Cost], 2) + $sub = 'All (MG scope)' + if ($m.SubscriptionId -ge 0) { + $sid = [string]$row[$m.SubscriptionId] + $sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid } + } + if ($pm -match 'Reservation') { + $ri += $cost * 0.4 + $committed += $cost + $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Reservation Benefit'; Amount = $cost; Type = 'Commitment' }) + } + elseif ($pm -match 'SavingsPlan') { + $sp += $cost * 0.25 + $committed += $cost + $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Savings Plan Benefit'; Amount = $cost; Type = 'Commitment' }) + } + elseif ($pm -match 'Spot') { $spot += $cost } + elseif ($pm) { $onDemand += $cost } + } + } + return [PSCustomObject]@{ Rows = $rows; RI = $ri; SP = $sp; Committed = $committed; OnDemand = $onDemand; Spot = $spot } + } + + if ($hasCommitments -and $subCount -eq 1) { + # -- Strategy 0: single-subscription fast path (skip MG resolution entirely) -- + $only = $Subscriptions[0] + try { + Write-Host " Calculating savings (single subscription, direct scope)..." -ForegroundColor Cyan + $subPath = "/subscriptions/$($only.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + + $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload (New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('ChargeType')) + if ($actualResp.StatusCode -eq 200) { + foreach ($d in (Read-SavingsActual -Result ($actualResp.Content | ConvertFrom-Json))) { + $d.Subscription = $only.Name; [void]$details.Add($d) + } + } + + $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload (New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('PricingModel')) + if ($amortResp.StatusCode -eq 200) { + $parsed = Read-SavingsAmort -Result ($amortResp.Content | ConvertFrom-Json) + foreach ($d in $parsed.Rows) { $d.Subscription = $only.Name; [void]$details.Add($d) } + $riSavings += $parsed.RI + $spSavings += $parsed.SP + $committedAmort += $parsed.Committed + $onDemandAmort += $parsed.OnDemand + $spotAmort += $parsed.Spot + } + + $gotMgData = $true + Write-Host " Single-subscription savings calculated (2 API calls)" -ForegroundColor Green + } catch { + Write-Warning " Single-subscription savings query failed: $($_.Exception.Message)" + } + } + elseif ($hasCommitments) { + # -- Strategy 1: MG-scope grouped by SubscriptionId (2 calls, per-sub attribution) -- + $mgScopeId = if ($TenantId) { Resolve-CostMgId -TenantId $TenantId } else { $null } + if ($mgScopeId) { + try { + Write-Host " Calculating savings (MG scope, grouped by subscription)..." -ForegroundColor Cyan + $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + + $actualResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload (New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('SubscriptionId', 'ChargeType')) + if ($actualResp.StatusCode -in @(401, 403)) { + Set-MgCostScopeFailed + throw "MG-scope savings query returned HTTP $($actualResp.StatusCode)" + } + if ($actualResp.StatusCode -eq 200) { + foreach ($d in (Read-SavingsActual -Result ($actualResp.Content | ConvertFrom-Json))) { [void]$details.Add($d) } + } + + $amortResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload (New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('SubscriptionId', 'PricingModel')) + if ($amortResp.StatusCode -eq 200) { + $parsed = Read-SavingsAmort -Result ($amortResp.Content | ConvertFrom-Json) + foreach ($d in $parsed.Rows) { [void]$details.Add($d) } + $riSavings += $parsed.RI + $spSavings += $parsed.SP + $committedAmort += $parsed.Committed + $onDemandAmort += $parsed.OnDemand + $spotAmort += $parsed.Spot + } + + $gotMgData = $true + Write-Host " MG scope savings calculated (2 API calls)" -ForegroundColor Green + } catch { + Write-Warning " MG-scope savings query failed: $($_.Exception.Message)" + } + } + } + + # -- Strategy 2: Per-subscription fallback (only if MG/direct scope unavailable) -- + if ($hasCommitments -and -not $gotMgData) { + # -- Step 1: Query amortized vs actual to find RI/SP benefit amounts -- + # The difference between ActualCost and AmortizedCost reveals commitment savings + $subCount = $Subscriptions.Count + $i = 0 + foreach ($sub in $Subscriptions) { + $i++ + if ($subCount -gt 5 -and ($i -eq 1 -or $i % [math]::Max(1, [int]($subCount / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Calculating savings ($i/$subCount subs)..." + } + } + try { + # Get ActualCost MonthToDate + $actualBody = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'ChargeType' } + ) + } + } | ConvertTo-Json -Depth 10 + + $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $actualBody + + if ($actualResp.StatusCode -eq 200) { + $actualResult = ($actualResp.Content | ConvertFrom-Json) + if ($actualResult.properties.rows) { + foreach ($row in $actualResult.properties.rows) { + $chargeType = $row[1] + $cost = [math]::Round([double]$row[0], 2) + + # RI/SP purchases show as separate charge types + if ($chargeType -match 'UnusedReservation') { + # This is wasted money — unused RI capacity + [void]$details.Add([PSCustomObject]@{ + Subscription = $sub.Name + Category = 'Unused Reservation' + Amount = $cost + Type = 'Waste' + }) + } + } + } + } + + # Get benefit usage via the reservation transactions or amortized view + $amortBody = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'PricingModel' } + ) + } + } | ConvertTo-Json -Depth 10 + + $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $amortBody + if ($amortResp.StatusCode -eq 200) { + $amortResult = ($amortResp.Content | ConvertFrom-Json) + if ($amortResult.properties.rows) { + foreach ($row in $amortResult.properties.rows) { + $pricingModel = $row[1] + $cost = [math]::Round([double]$row[0], 2) + + if ($pricingModel -match 'Reservation') { + # Amortized RI cost — the actual RI spend + $riSavings += $cost * 0.4 # Approximate: RIs typically save ~40% vs PAYG + $committedAmort += $cost + [void]$details.Add([PSCustomObject]@{ + Subscription = $sub.Name + Category = 'Reservation Benefit' + Amount = $cost + Type = 'Commitment' + }) + } + elseif ($pricingModel -match 'SavingsPlan') { + $spSavings += $cost * 0.25 # Approximate: SPs save ~25% on average + $committedAmort += $cost + [void]$details.Add([PSCustomObject]@{ + Subscription = $sub.Name + Category = 'Savings Plan Benefit' + Amount = $cost + Type = 'Commitment' + }) + } + elseif ($pricingModel -match 'Spot') { $spotAmort += $cost } + elseif ($pricingModel) { $onDemandAmort += $cost } + } + } + } + } catch { + Write-Warning " Savings query failed for $($sub.Name): $($_.Exception.Message)" + } + } + } # end per-sub fallback + + # -- Step 2: AHB realized savings (per-SKU Windows license premium) --- + try { + $ahbQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where properties.licenseType == 'Windows_Server' +| project vmSize = tostring(properties.hardwareProfile.vmSize), location +"@ + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $ahbResult = Search-AzGraphSafe -Query $ahbQuery -Subscription $subIds + $ahbVMs = if ($ahbResult.Data) { @($ahbResult.Data) } else { @() } + if ($ahbVMs.Count -gt 0) { + $ahbSavings = 0 + $haveRates = Get-Command Get-AhbVmRates -ErrorAction SilentlyContinue + foreach ($vm in $ahbVMs) { + $perVm = 50 # fallback monthly estimate per VM when live rates are unavailable + if ($haveRates) { + $rates = Get-AhbVmRates -VmSize $vm.vmSize -Region $vm.location + if ($rates) { $perVm = [math]::Round($rates.HourlyPremium * 730, 2) } + } + $ahbSavings += $perVm + } + [void]$details.Add([PSCustomObject]@{ + Subscription = 'All' + Category = 'Azure Hybrid Benefit (VMs)' + Amount = [math]::Round($ahbSavings, 2) + Type = 'AHB' + }) + } + } catch { + Write-Warning " AHB savings query failed: $($_.Exception.Message)" + } + + $totalMonthly = [math]::Round($riSavings + $spSavings + $ahbSavings, 2) + $totalAnnual = [math]::Round($totalMonthly * 12, 2) + + # Commitment coverage = committed eligible spend / total eligible spend. + # Eligible = everything except Spot (Spot cannot be covered by a commitment). + $eligibleBase = $committedAmort + $onDemandAmort + $commitmentCoverage = if ($eligibleBase -gt 0) { + [math]::Round(100 * $committedAmort / $eligibleBase, 1) + } + else { $null } + + return [PSCustomObject]@{ + RISavingsMonthly = [math]::Round($riSavings, 2) + SPSavingsMonthly = [math]::Round($spSavings, 2) + AHBSavingsMonthly = [math]::Round($ahbSavings, 2) + TotalMonthly = $totalMonthly + TotalAnnual = $totalAnnual + CommittedAmortized = [math]::Round($committedAmort, 2) + OnDemandAmortized = [math]::Round($onDemandAmort, 2) + SpotAmortized = [math]::Round($spotAmort, 2) + CommitmentCoveragePct = $commitmentCoverage + Details = @($details) + HasData = ($totalMonthly -gt 0 -or $details.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 new file mode 100644 index 000000000..610c9777a --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -0,0 +1,422 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-SHAREDCOSTALLOCATION.PS1 +# AZURE FINOPS MULTITOOL - Shared Hub Cost Allocation (Showback) +########################################################################### +# Purpose: Distribute the billed cost of SHARED hub resources (ExpressRoute +# gateway/circuit, VPN gateway, Azure Firewall, shared bandwidth) +# across the spoke subscriptions that consume them, and report each +# spoke's full "solution cost" = its own resources + its allocated +# share of the shared pool. +# +# Why: Shared connectivity bills entirely to the hub subscription, so +# Cost Management cannot tell which spoke generated which gigabyte. +# The split key (GB/TB transferred) lives in network telemetry, not +# billing data. This tool keeps the cost math here and takes the +# weighting vector as input - so an agent can fetch GB-per-spoke +# from Azure Monitor / Traffic Analytics (e.g. via the Azure MCP +# server) and hand it in, or fall back to a proxy key when no +# flow logs exist. +# +# Weighting providers: +# inline - exact GB/TB map supplied by the caller (best) +# trafficAnalytics - exact: queries Traffic Analytics / VNet flow logs in a +# Log Analytics workspace for measured GB per spoke +# equal - split evenly across spokes (no measurement) +# resourceCount - proxy: billable resource count per spoke (ARG, estimate) +# +# Split model (per shared pool): +# spoke_i = (Fixed / nSpokes) + (Variable * weight_i / totalWeight) +# where Fixed = pool * FixedRatio (split evenly - gateways cost money at +# zero traffic) and Variable = pool * (1 - FixedRatio) (by transfer). +# +# Sources: Live Cost Management API (default) OR the FinOps Hub / export +# when -HubData is injected by the caller (fast path). +# +# Notes: +# - RBAC: Cost Management Reader (hub + spoke subs) + Reader (ARG). +# - Per-spoke ExpressRoute attribution is impossible from billing alone; it +# requires the flow-log weighting key. Without it, results are estimates +# and are labelled as such. +########################################################################### + +# -- Resolve the shared cost pool resources -------------------------------- +# From explicit resource IDs and/or a resource group, confirm the shared +# resources exist via ARG and return their IDs + subscriptions. +function Resolve-SharedCostPool { + param( + [string[]]$SubscriptionIds, + [string[]]$ResourceIds, + [string]$ResourceGroup + ) + + $clauses = @() + if ($ResourceIds -and $ResourceIds.Count -gt 0) { + $idList = ($ResourceIds | ForEach-Object { "'$(ConvertTo-KqlLiteral $_)'" }) -join ',' + $clauses += "id in~ ($idList)" + } + if ($ResourceGroup) { + $clauses += "resourceGroup =~ '$(ConvertTo-KqlLiteral $ResourceGroup)'" + } + if ($clauses.Count -eq 0) { return @() } + + $where = $clauses -join ' or ' + $query = @" +resources +| where $where +| project id, name, type, resourceGroup, subscriptionId +"@ + + $res = Search-AzGraphSafe -Query $query -Subscription $SubscriptionIds -First 200 + $rows = if ($res) { @($res.Data) } else { @() } + + return @($rows | ForEach-Object { + [PSCustomObject]@{ + Id = [string]$_.id + Name = [string]$_.name + Type = [string]$_.type + ResourceGroup = [string]$_.resourceGroup + SubscriptionId = [string]$_.subscriptionId + } + }) +} + +# -- Build the spoke weighting vector -------------------------------------- +# Returns a hashtable: spokeId -> weight (double). Method decides the source. +function Get-SpokeWeighting { + param( + [string[]]$Spokes, + [string]$Method, + [object]$Values, + [string[]]$SubscriptionIds, + [string]$WorkspaceId, + [int]$LookbackDays = 30, + [string]$Query + ) + + $weights = @{} + foreach ($s in $Spokes) { $weights[$s] = 0.0 } + + switch ($Method) { + 'equal' { + foreach ($s in $Spokes) { $weights[$s] = 1.0 } + } + 'trafficAnalytics' { + if (-not $WorkspaceId) { + Write-Warning ' trafficAnalytics weighting needs workspaceId (the Log Analytics workspace GUID); weights left at 0.' + break + } + $lb = if ($LookbackDays -gt 0) { $LookbackDays } else { 30 } + # The query must return two columns: Spoke (subscription id) and GB (number). + $kql = if ($Query) { + $Query + } + else { + @" +AzureNetworkAnalytics_CL +| where TimeGenerated >= ago(${lb}d) +| where SubType_s == 'FlowLog' +| extend Spoke = column_ifexists('Subscription1_s', '') +| where isnotempty(Spoke) +| summarize GB = sum(InboundBytes_d + OutboundBytes_d) / 1e9 by Spoke +"@ + } + try { + $qr = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $kql -ErrorAction Stop + foreach ($row in @($qr.Results)) { + $sid = [string]$row.Spoke + if ($weights.ContainsKey($sid)) { $weights[$sid] = [double]$row.GB } + } + } + catch { + Write-Warning " Traffic Analytics query failed: $($_.Exception.Message)" + } + } + 'resourceCount' { + $spokeList = ($Spokes | ForEach-Object { "'$(ConvertTo-KqlLiteral $_)'" }) -join ',' + $query = @" +resources +| where subscriptionId in~ ($spokeList) +| summarize c = count() by subscriptionId +"@ + $res = Search-AzGraphSafe -Query $query -Subscription $Spokes -First 1000 + foreach ($r in @($res.Data)) { + $sid = [string]$r.subscriptionId + if ($weights.ContainsKey($sid)) { $weights[$sid] = [double]$r.c } + } + } + default { + # inline: read numeric values keyed by spoke id from $Values + if ($Values) { + $pairs = if ($Values -is [hashtable]) { + $Values.GetEnumerator() + } + else { + $Values.PSObject.Properties | ForEach-Object { [PSCustomObject]@{ Key = $_.Name; Value = $_.Value } } + } + foreach ($p in $pairs) { + $k = [string]$p.Key + if ($weights.ContainsKey($k)) { $weights[$k] = [double]$p.Value } + } + } + } + } + + return $weights +} + +# -- Read cost maps (by resource id + by subscription) --------------------- +# One pass over the data source produces both: byResourceId (for the shared +# pool) and bySubscriptionId (for each spoke's own cost). +function Get-AllocationCostMaps { + param( + [string[]]$SubscriptionIds, + [object[]]$HubData + ) + + $byResource = @{} + $bySub = @{} + $currency = 'USD' + $source = 'LiveApi' + + $fromHub = ($HubData -and @($HubData).Count -gt 0) + + if ($fromHub) { + $source = 'FinOpsHub' + $props = $HubData[0].PSObject.Properties.Name + foreach ($row in $HubData) { + $rid = [string](Get-HubRowValue -Row $row -Names @('ResourceId', 'x_ResourceId', 'InstanceId') -Props $props) + $cost = [double](Get-HubRowValue -Row $row -Names @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost', 'Cost') -Props $props) + $sub = [string](Get-HubRowValue -Row $row -Names @('SubAccountId', 'SubscriptionId', 'x_SubscriptionId', 'SubscriptionGuid') -Props $props) + $cur = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency') -Props $props) + if ($cur) { $currency = $cur } + + # SubAccountId is a full path (/subscriptions/{guid}); normalize. + if ($sub -and $sub -match '/subscriptions/([0-9a-fA-F-]+)') { $sub = $Matches[1] } + + if ($rid) { + if (-not $byResource.ContainsKey($rid)) { $byResource[$rid] = 0.0 } + $byResource[$rid] += $cost + } + if ($sub) { + if (-not $bySub.ContainsKey($sub)) { $bySub[$sub] = 0.0 } + $bySub[$sub] += $cost + } + } + } + else { + foreach ($sub in ($SubscriptionIds | Select-Object -Unique)) { + if (-not $sub) { continue } + $body = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'ResourceId' }) + } + } | ConvertTo-Json -Depth 12 + + $path = "/subscriptions/$sub/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + try { + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + $cols = @($data.properties.columns.name) + $iCost = [array]::IndexOf($cols, 'Cost') + $iRes = [array]::IndexOf($cols, 'ResourceId') + $iCur = [array]::IndexOf($cols, 'Currency') + if (-not $bySub.ContainsKey($sub)) { $bySub[$sub] = 0.0 } + foreach ($row in @($data.properties.rows)) { + $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 } + $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } + if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] } + $bySub[$sub] += $amount + if ($rid) { + if (-not $byResource.ContainsKey($rid)) { $byResource[$rid] = 0.0 } + $byResource[$rid] += $amount + } + } + } + } + catch { + Write-Warning " Cost query failed for $sub : $($_.Exception.Message)" + } + } + } + + return [PSCustomObject]@{ + ByResource = $byResource + BySub = $bySub + Currency = $currency + Source = $source + } +} + +# -- Main: allocate shared cost across spokes ------------------------------ +function Get-SharedCostAllocation { + [CmdletBinding()] + param( + [Parameter()] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [string[]]$SharedResourceIds, + + [Parameter()] + [string]$SharedResourceGroup, + + [Parameter()] + [string[]]$Spokes, + + [Parameter()] + [string]$WeightingMethod = 'inline', + + [Parameter()] + [object]$WeightingValues, + + [Parameter()] + [string]$WorkspaceId, + + [Parameter()] + [int]$LookbackDays = 30, + + [Parameter()] + [string]$WeightingQuery, + + [Parameter()] + [double]$FixedRatio = 0.5, + + [Parameter()] + [object[]]$HubData + ) + + if ((-not $SharedResourceIds -or $SharedResourceIds.Count -eq 0) -and -not $SharedResourceGroup) { + return [PSCustomObject]@{ + HasData = $false + Note = 'Provide sharedResourceIds (e.g. the ExpressRoute gateway / firewall) or a sharedResourceGroup that holds them.' + } + } + if (-not $Spokes -or $Spokes.Count -eq 0) { + return [PSCustomObject]@{ + HasData = $false + Note = 'Provide spokes - the subscription IDs that share the hub resources.' + } + } + if ($FixedRatio -lt 0) { $FixedRatio = 0 } + if ($FixedRatio -gt 1) { $FixedRatio = 1 } + + $scanSubs = @($Subscriptions | ForEach-Object { $_.Id }) + if (-not $scanSubs -or $scanSubs.Count -eq 0) { $scanSubs = $Spokes } + + Write-Host " Resolving shared resources..." -ForegroundColor Cyan + $pool = Resolve-SharedCostPool -SubscriptionIds $scanSubs -ResourceIds $SharedResourceIds -ResourceGroup $SharedResourceGroup + if (-not $pool -or $pool.Count -eq 0) { + return [PSCustomObject]@{ + HasData = $false + Note = 'No shared resources matched the supplied sharedResourceIds / sharedResourceGroup in the scanned subscriptions.' + } + } + + $sharedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($p in $pool) { [void]$sharedIds.Add($p.Id) } + $hubSubs = @($pool | ForEach-Object { $_.SubscriptionId } | Select-Object -Unique) + + Write-Host " Reading cost (pool + spokes)..." -ForegroundColor Cyan + $costSubs = @($hubSubs + $Spokes | Select-Object -Unique) + $maps = Get-AllocationCostMaps -SubscriptionIds $costSubs -HubData $HubData + + # -- Size the shared pool from billed cost ---------------------------- + $poolTotal = 0.0 + $poolResources = @() + foreach ($p in $pool) { + $c = if ($maps.ByResource.ContainsKey($p.Id)) { [double]$maps.ByResource[$p.Id] } else { 0.0 } + $poolTotal += $c + $poolResources += [PSCustomObject]@{ + Name = $p.Name + Type = $p.Type + Cost = [math]::Round($c, 2) + } + } + + # -- Build the weighting vector --------------------------------------- + Write-Host " Building weighting ($WeightingMethod)..." -ForegroundColor Cyan + $weights = Get-SpokeWeighting -Spokes $Spokes -Method $WeightingMethod -Values $WeightingValues -SubscriptionIds $scanSubs -WorkspaceId $WorkspaceId -LookbackDays $LookbackDays -Query $WeightingQuery + $totalWeight = 0.0 + foreach ($w in $weights.Values) { $totalWeight += [double]$w } + + # -- Allocate ---------------------------------------------------------- + $nSpokes = $Spokes.Count + $fixedTotal = $poolTotal * $FixedRatio + $varTotal = $poolTotal * (1 - $FixedRatio) + + $allocations = @() + foreach ($s in $Spokes) { + $w = [double]$weights[$s] + $allocFixed = if ($nSpokes -gt 0) { $fixedTotal / $nSpokes } else { 0 } + $allocVar = if ($totalWeight -gt 0) { $varTotal * ($w / $totalWeight) } elseif ($nSpokes -gt 0) { $varTotal / $nSpokes } else { 0 } + $allocShared = $allocFixed + $allocVar + $ownCost = if ($maps.BySub.ContainsKey($s)) { [double]$maps.BySub[$s] } else { 0.0 } + + $allocations += [PSCustomObject]@{ + Spoke = $s + WeightUnits = [math]::Round($w, 2) + WeightPct = if ($totalWeight -gt 0) { [math]::Round(($w / $totalWeight) * 100, 1) } else { [math]::Round(100.0 / [math]::Max($nSpokes, 1), 1) } + AllocatedFixed = [math]::Round($allocFixed, 2) + AllocatedVar = [math]::Round($allocVar, 2) + AllocatedShared = [math]::Round($allocShared, 2) + OwnCost = [math]::Round($ownCost, 2) + SolutionCost = [math]::Round($ownCost + $allocShared, 2) + } + } + $allocations = @($allocations | Sort-Object SolutionCost -Descending) + + $notes = @() + if ($WeightingMethod -in @('equal', 'resourceCount')) { + $notes += "Weighting '$WeightingMethod' is a proxy estimate, not metered transfer. Use weightingMethod=trafficAnalytics (with workspaceId) or supply inline GB/TB per spoke for an exact split." + } + elseif ($totalWeight -le 0) { + if ($WeightingMethod -eq 'trafficAnalytics') { + $notes += 'Traffic Analytics returned no GB for any spoke; variable cost was split evenly. Verify the workspace has flow logs/Traffic Analytics enabled and that the query returns Spoke + GB columns.' + } + else { + $notes += 'No inline weighting values resolved for any spoke; variable cost was split evenly. Provide weightingValues keyed by spoke subscription id.' + } + } + if ($maps.Source -eq 'LiveApi') { + $notes += 'Costs are live Cost Management actuals (month-to-date). Per-spoke ExpressRoute attribution cannot come from billing - it relies on the weighting key.' + } + + # Ready-to-paste targets for set_cost_allocation_rule: each spoke's share of + # the whole pool as a whole percentage, already normalized to sum 100.00. + $ruleTargets = @() + if ($poolTotal -gt 0 -and @($allocations).Count -gt 0) { + $rtInput = @($allocations | ForEach-Object { @{ subscriptionId = $_.Spoke; allocatedShared = $_.AllocatedShared } }) + $rt = ConvertTo-AllocationPercentages -Targets $rtInput -TargetDimension 'SubscriptionId' + if ($rt.Ok) { + $ruleTargets = @($rt.Values | ForEach-Object { [PSCustomObject]@{ subscriptionId = $_.name; percentage = $_.percentage } }) + $notes += 'RuleTargets is ready to paste straight into set_cost_allocation_rule (targets); percentages already sum to 100.' + } + } + + return [PSCustomObject]@{ + HasData = $true + Period = 'MonthToDate' + Source = $maps.Source + Currency = $maps.Currency + WeightingMethod = $WeightingMethod + FixedRatio = $FixedRatio + SharedPool = [PSCustomObject]@{ + TotalCost = [math]::Round($poolTotal, 2) + Resources = @($poolResources | Sort-Object Cost -Descending) + } + Allocations = $allocations + RuleTargets = $ruleTargets + Note = ($notes -join ' ') + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 new file mode 100644 index 000000000..095ff40b1 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 @@ -0,0 +1,131 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-STORAGETIERADVICE.PS1 +# AZURE FINOPS MULTITOOL - Storage Tier Optimization +########################################################################### +# Purpose: Identify storage accounts with hot-tier blob containers that +# have not been accessed recently and would benefit from moving +# to Cool or Archive tier to reduce costs. +########################################################################### + +function Get-StorageTierAdvice { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + Write-Host " Scanning storage tier optimization opportunities..." -ForegroundColor Cyan + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $results = [System.Collections.Generic.List[PSCustomObject]]::new() + + # -- 1: Find all storage accounts on Hot default tier ----------------- + try { + $query = @" +resources +| where type =~ 'microsoft.storage/storageaccounts' +| where properties.accessTier =~ 'Hot' or isnull(properties.accessTier) +| project name, resourceGroup, subscriptionId, location, + kind, sku = sku.name, + accessTier = tostring(properties.accessTier), + creationTime = properties.creationTime, + blobCount = properties.primaryEndpoints.blob +"@ + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 + $hotAccounts = if ($result) { @($result.Data) } else { @() } + Write-Host " Hot-tier storage accounts: $($hotAccounts.Count)" -ForegroundColor Gray + } + catch { + Write-Warning " Storage account query failed: $($_.Exception.Message)" + $hotAccounts = @() + } + + # -- 2: For each hot account, check last access metrics --------------- + $armBase = Get-FinOpsArmEndpoint + $token = (Get-AzAccessToken -ResourceUrl $armBase).Token + $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } + $now = (Get-Date).ToUniversalTime() + $thirtyDaysAgo = $now.AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ') + $nowStr = $now.ToString('yyyy-MM-ddTHH:mm:ssZ') + + foreach ($sa in $hotAccounts) { + $scope = "/subscriptions/$($sa.subscriptionId)/resourceGroups/$($sa.resourceGroup)/providers/Microsoft.Storage/storageAccounts/$($sa.name)" + try { + # Query transaction count (Blob service) over last 30 days + $metricUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Transactions×pan=$thirtyDaysAgo/$nowStr&aggregation=Total&interval=P30D" + $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction Stop + $metricData = ($resp.Content | ConvertFrom-Json) + + $totalTx = 0 + if ($metricData.value -and $metricData.value.Count -gt 0) { + foreach ($ts in $metricData.value[0].timeseries) { + foreach ($dp in $ts.data) { + if ($dp.total) { $totalTx += $dp.total } + } + } + } + + # Also query used capacity + $capacityUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=BlobCapacity×pan=$thirtyDaysAgo/$nowStr&aggregation=Average&interval=P30D" + $capResp = Invoke-WebRequest -Uri $capacityUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -ErrorAction SilentlyContinue + $capacityBytes = 0 + if ($capResp) { + $capData = ($capResp.Content | ConvertFrom-Json) + if ($capData.value -and $capData.value.Count -gt 0) { + foreach ($ts in $capData.value[0].timeseries) { + foreach ($dp in $ts.data) { + if ($dp.average -and $dp.average -gt $capacityBytes) { $capacityBytes = $dp.average } + } + } + } + } + + $capacityGB = [math]::Round($capacityBytes / 1GB, 2) + $recommendation = $null + $estSavingsPct = 0 + + if ($totalTx -eq 0 -and $capacityGB -gt 0) { + $recommendation = 'Archive' + $estSavingsPct = 90 + } + elseif ($totalTx -lt 100 -and $capacityGB -gt 0) { + $recommendation = 'Archive' + $estSavingsPct = 90 + } + elseif ($totalTx -lt 1000 -and $capacityGB -gt 1) { + $recommendation = 'Cool' + $estSavingsPct = 50 + } + + if ($recommendation) { + [void]$results.Add([PSCustomObject]@{ + StorageAccount = $sa.name + ResourceGroup = $sa.resourceGroup + SubscriptionId = $sa.subscriptionId + Location = $sa.location + CurrentTier = if ($sa.accessTier) { $sa.accessTier } else { 'Hot (default)' } + SKU = $sa.sku + CapacityGB = $capacityGB + Transactions30d = $totalTx + Recommendation = $recommendation + EstSavingsPct = $estSavingsPct + }) + } + } + catch { + # Metrics not available (classic account, no blob service, etc.) — skip + } + } + + Write-Host " Storage tier recommendations: $($results.Count)" -ForegroundColor Gray + + [PSCustomObject]@{ + Recommendations = @($results) + TotalHotAccounts = $hotAccounts.Count + Count = $results.Count + HasData = ($results.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 new file mode 100644 index 000000000..9de999c94 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 @@ -0,0 +1,243 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-TAGINVENTORY.PS1 +# AZURE FINOPS MULTITOOL - Tag Inventory Across the Tenant +########################################################################### +# Purpose: Use Azure Resource Graph to discover every tag name and value +# in use across all subscriptions, along with resource counts +# and resource types per tag. +# +# This is the "Understand" FinOps pillar - you can't allocate costs you +# can't see, and untagged resources are invisible to chargeback. +########################################################################### + +function Get-TagInventory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions + ) + + $subIds = $Subscriptions | ForEach-Object { $_.Id } + + # -- Query 1: Tag names, values, and counts ------------------------- + try { + Write-Host " Scanning tag inventory via Resource Graph..." -ForegroundColor Cyan + $tagQuery = @" +resources +| union resourcecontainers +| mvexpand tags +| extend tagName = tostring(bag_keys(tags)[0]) +| extend tagValue = tostring(tags[tagName]) +| where isnotempty(tagName) +| summarize ResourceCount = count(), ResourceTypes = make_set(type) by tagName, tagValue +| order by tagName asc, ResourceCount desc +"@ + + $allResults = @() + $skipToken = $null + + do { + $result = Search-AzGraphSafe -Query $tagQuery -Subscription $subIds -First 1000 -SkipToken $skipToken + if (-not $result) { break } + $allResults += $result.Data + $skipToken = $result.SkipToken + } while ($skipToken) + + } catch { + Write-Warning "Tag inventory query failed: $($_.Exception.Message)" + $allResults = @() + } + + # -- Query 2: Untagged resource count (via REST to avoid runspace issues with single-row aggregates) + $untaggedCount = 0 + try { + $countBody = @{ + subscriptions = @($subIds) + query = "resources | where isnull(tags) or tags == '{}' | summarize UntaggedCount = count()" + options = @{ resultFormat = 'objectArray' } + } | ConvertTo-Json -Depth 5 + $countResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $countBody + if ($countResp.StatusCode -eq 200) { + $countRows = @(($countResp.Content | ConvertFrom-Json).data) + if ($countRows.Count -gt 0) { + $untaggedCount = [int]$countRows[0].UntaggedCount + } + } + } catch { + Write-Warning "Untagged resource count failed: $($_.Exception.Message)" + } + + # -- Query 4: Untagged resource details (paginate all) ---------------- + $untaggedResources = @() + try { + $untaggedDetailQuery = @" +resources +| where isnull(tags) or tags == '{}' +| project name, type, resourceGroup, subscriptionId, location +| order by type asc, name asc +"@ + $allUntagged = @() + $udSkipToken = $null + do { + $udResult = Search-AzGraphSafe -Query $untaggedDetailQuery -Subscription $subIds -First 1000 -SkipToken $udSkipToken + if (-not $udResult -or -not $udResult.Data) { break } + $allUntagged += $udResult.Data + $udSkipToken = $udResult.SkipToken + if ($allUntagged.Count % 2000 -eq 0) { + Write-Host " Loaded $($allUntagged.Count) untagged resources so far..." -ForegroundColor Gray + } + } while ($udSkipToken) + + if ($allUntagged.Count -gt 0) { + # Map subscription IDs to names + $subNameMap = @{} + foreach ($s in $Subscriptions) { $subNameMap[$s.Id] = $s.Name } + $untaggedResources = @($allUntagged | ForEach-Object { + [PSCustomObject]@{ + ResourceName = $_.name + ResourceType = $_.type + ResourceGroup = $_.resourceGroup + Subscription = if ($subNameMap.ContainsKey($_.subscriptionId)) { $subNameMap[$_.subscriptionId] } else { $_.subscriptionId } + Location = $_.location + } + }) + Write-Host " Total untagged resources loaded: $($untaggedResources.Count)" -ForegroundColor Cyan + } + } catch { + Write-Warning "Untagged resource detail query failed: $($_.Exception.Message)" + } + + # -- Query 3: Total resource count (via REST to avoid runspace issues with single-row aggregates) + $totalCount = 0 + try { + $totalBody = @{ + subscriptions = @($subIds) + query = "resources | summarize TotalCount = count()" + options = @{ resultFormat = 'objectArray' } + } | ConvertTo-Json -Depth 5 + $totalResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $totalBody + if ($totalResp.StatusCode -eq 200) { + $totalRows = @(($totalResp.Content | ConvertFrom-Json).data) + if ($totalRows.Count -gt 0) { + $totalCount = [int]$totalRows[0].TotalCount + } + } + } catch { + Write-Warning "Total resource count failed: $($_.Exception.Message)" + } + + # Fallback: the REST count endpoint returns table-format results in some + # tenants (no objectArray rows), leaving the count at 0. Re-derive via the + # Resource Graph cmdlet path, which is reliable where the tag query works. + if ($totalCount -eq 0) { + try { + $tcResult = Search-AzGraphSafe -Query "resources | summarize TotalCount = count()" -Subscription $subIds -First 1 + $tcRows = if ($tcResult) { @($tcResult.Data) } else { @() } + if ($tcRows.Count -gt 0 -and $null -ne $tcRows[0].TotalCount) { + $totalCount = [int]$tcRows[0].TotalCount + } + } catch { } + } + + # Fallback: derive counts from detail data if REST queries failed + if ($untaggedCount -eq 0 -and $untaggedResources.Count -gt 0) { + $untaggedCount = $untaggedResources.Count + Write-Host " Using detail query count as fallback: $untaggedCount untagged" -ForegroundColor Yellow + } + + # -- Build summary -------------------------------------------------- + $tagNames = @{} + foreach ($row in $allResults) { + $name = $row.tagName + if (-not $tagNames.ContainsKey($name)) { + $tagNames[$name] = @{ Values = @(); TotalResources = 0 } + } + $tagNames[$name].Values += [PSCustomObject]@{ + Value = $row.tagValue + ResourceCount = $row.ResourceCount + ResourceTypes = $row.ResourceTypes + } + $tagNames[$name].TotalResources += $row.ResourceCount + } + + # -- Query 5: Tag locations (which subscriptions + RGs each tag is on) + $tagLocations = @{} + try { + $subNameMap = @{} + foreach ($s in $Subscriptions) { $subNameMap[$s.Id] = $s.Name } + + $locQuery = @" +resources +| union resourcecontainers +| mvexpand tags +| extend tagName = tostring(bag_keys(tags)[0]) +| where isnotempty(tagName) +| summarize ResourceCount = count() by tagName, subscriptionId, resourceGroup +| order by tagName asc, ResourceCount desc +"@ + $locResults = @() + $locSkip = $null + do { + $locResult = Search-AzGraphSafe -Query $locQuery -Subscription $subIds -First 1000 -SkipToken $locSkip + if (-not $locResult) { break } + $locResults += $locResult.Data + $locSkip = $locResult.SkipToken + } while ($locSkip) + + foreach ($row in $locResults) { + $name = $row.tagName + if (-not $tagLocations.ContainsKey($name)) { + $tagLocations[$name] = [System.Collections.Generic.List[string]]::new() + } + $subName = if ($subNameMap.ContainsKey($row.subscriptionId)) { $subNameMap[$row.subscriptionId] } else { $row.subscriptionId } + $loc = "$subName / $($row.resourceGroup)" + if ($loc -notin $tagLocations[$name]) { + [void]$tagLocations[$name].Add($loc) + } + } + } catch { + Write-Warning "Tag location query failed: $($_.Exception.Message)" + } + + # Last-resort fallback: when the dedicated total query is unavailable (the + # REST endpoint returned no rows and the cmdlet path also came up empty), + # derive the total from the tagged + untagged populations so coverage is + # still meaningful instead of showing 0 tagged / 0 untagged. + if ($totalCount -eq 0) { + $taggedFromArg = 0 + try { + $tagCountBody = @{ + subscriptions = @($subIds) + query = "resources | where isnotnull(tags) and tags != '{}' | summarize TaggedCount = count()" + options = @{ resultFormat = 'objectArray' } + } | ConvertTo-Json -Depth 5 + $tagCountResp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01" -Method POST -Payload $tagCountBody + if ($tagCountResp.StatusCode -eq 200) { + $tagCountRows = @(($tagCountResp.Content | ConvertFrom-Json).data) + if ($tagCountRows.Count -gt 0) { $taggedFromArg = [int]$tagCountRows[0].TaggedCount } + } + } catch { } + if ($untaggedCount -eq 0 -and $untaggedResources.Count -gt 0) { $untaggedCount = $untaggedResources.Count } + if (($taggedFromArg + $untaggedCount) -gt 0) { + $totalCount = $taggedFromArg + $untaggedCount + } + } + + $taggedCount = [math]::Max(0, $totalCount - $untaggedCount) + $tagCoverage = if ($totalCount -gt 0) { [math]::Round(($taggedCount / $totalCount) * 100, 1) } else { 0 } + + return [PSCustomObject]@{ + TagNames = $tagNames + TagCount = $tagNames.Count + TagLocations = $tagLocations + TotalResources = $totalCount + TaggedCount = $taggedCount + UntaggedCount = $untaggedCount + TagCoverage = $tagCoverage + UntaggedResources = $untaggedResources + RawResults = $allResults + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 new file mode 100644 index 000000000..c04833dfe --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 @@ -0,0 +1,173 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-TAGRECOMMENDATIONS.PS1 +# AZURE FINOPS MULTITOOL - Tag Recommendations (MS Best Practices) +########################################################################### +# Purpose: Compare the customer's actual tags against Microsoft's +# recommended tagging strategy from the Cloud Adoption Framework. +# +# Reference: +# https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging +# https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/govern/guides/standard/prescriptive-guidance#resource-tagging +########################################################################### + +function Get-TagRecommendations { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$ExistingTags, # Keys = tag names currently in use + + [hashtable]$TagLocations = @{} # Keys = tag names, Values = list of "Sub / RG" strings + ) + + # Microsoft Cloud Adoption Framework recommended tags for FinOps allocation + # These 7 tags map directly to CAF categories and FinOps allocation needs + $recommendedTags = @( + [PSCustomObject]@{ + TagName = 'CostCenter' + Category = 'Accounting' + Purpose = 'Financial allocation - maps resources to internal cost centers for chargeback/showback' + Pillar = 'Understand' + Priority = 'Required' + Weight = 3 + Example = 'CostCenter: CC-12345' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'BusinessUnit' + Category = 'Ownership' + Purpose = 'Org-level chargeback - enables showback/chargeback at department level' + Pillar = 'Understand' + Priority = 'Required' + Weight = 3 + Example = 'BusinessUnit: Finance | Engineering | Marketing' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'ApplicationName' + Category = 'Functional' + Purpose = 'Product/service cost mapping - groups resources by the application they support' + Pillar = 'Understand' + Priority = 'Required' + Weight = 2 + Example = 'ApplicationName: HRPortal | ERP | WebFrontend' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'WorkloadName' + Category = 'Functional' + Purpose = 'Workload attribution - identifies the workload a resource belongs to' + Pillar = 'Understand' + Priority = 'Required' + Weight = 1 + Example = 'WorkloadName: PaymentProcessing | DataPipeline' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'OpsTeam' + Category = 'Ownership' + Purpose = 'Accountability for spend - which team owns and operates the resource' + Pillar = 'Understand' + Priority = 'Required' + Weight = 1 + Example = 'OpsTeam: Platform-Infra | App-TeamA | SRE' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'Criticality' + Category = 'Classification' + Purpose = 'Prioritization of spend - business impact level drives optimization boundaries' + Pillar = 'Optimize' + Priority = 'Required' + Weight = 1 + Example = 'Criticality: Mission-Critical | Business-Critical | Low' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + [PSCustomObject]@{ + TagName = 'DataClassification' + Category = 'Classification' + Purpose = 'Compliance-driven allocation - data sensitivity determines governance requirements' + Pillar = 'Understand' + Priority = 'Required' + Weight = 1 + Example = 'DataClassification: Confidential | Public | Internal' + Reference = 'https://learn.microsoft.com/en-us/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging#minimum-suggested-tags' + } + ) + + # Check which recommended tags are present / missing + $existingNames = $ExistingTags.Keys | ForEach-Object { $_.ToLower() } + + $analysis = foreach ($rec in $recommendedTags) { + $found = $existingNames -contains $rec.TagName.ToLower() + + # Also check common variations + $variations = switch ($rec.TagName) { + 'CostCenter' { @('cost-center', 'costcenter', 'cost_center', 'cc') } + 'BusinessUnit' { @('bu', 'businessunit', 'business-unit', 'department', 'dept') } + 'ApplicationName' { @('applicationname', 'application', 'app', 'appname', 'app-name', 'workload') } + 'WorkloadName' { @('workloadname', 'workload', 'workload-name', 'workload_name') } + 'OpsTeam' { @('opsteam', 'ops-team', 'ops_team', 'operationsteam', 'team', 'owner', 'technicalowner') } + 'Criticality' { @('criticality', 'sla', 'tier', 'importance') } + 'DataClassification' { @('dataclassification', 'data-classification', 'data_classification', 'classification') } + default { @() } + } + $foundVariation = $existingNames | Where-Object { $_ -in $variations } | Select-Object -First 1 + + $status = if ($found) { 'Present' } + elseif ($foundVariation) { "Variation found: $foundVariation" } + else { 'Missing' } + + # Build location string from TagLocations + $matchedName = if ($found) { $rec.TagName } + elseif ($foundVariation) { $foundVariation } + else { $null } + + # Resolve original-case tag name from ExistingTags for accurate removal + $actualTagName = $null + if ($matchedName) { + $actualTagName = $ExistingTags.Keys | Where-Object { $_.ToLower() -eq $matchedName.ToLower() } | Select-Object -First 1 + if (-not $actualTagName) { $actualTagName = $matchedName } + } + + $locationStr = '' + if ($matchedName) { + # Case-insensitive lookup in TagLocations hashtable + $locKey = $TagLocations.Keys | Where-Object { $_.ToLower() -eq $matchedName.ToLower() } | Select-Object -First 1 + if ($locKey -and $TagLocations[$locKey]) { + $locs = @($TagLocations[$locKey]) + if ($locs.Count -le 3) { + $locationStr = $locs -join '; ' + } else { + $locationStr = ($locs[0..2] -join '; ') + " (+$($locs.Count - 3) more)" + } + } + } + + [PSCustomObject]@{ + TagName = $rec.TagName + ActualTagName = $actualTagName + Status = $status + Priority = $rec.Priority + Pillar = $rec.Pillar + Purpose = $rec.Purpose + Location = $locationStr + Example = $rec.Example + Reference = $rec.Reference + } + } + + $missingRequired = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Required' }) + $missingRecommended = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Recommended' }) + $present = @($analysis | Where-Object { $_.Status -ne 'Missing' }) + + return [PSCustomObject]@{ + Analysis = $analysis + MissingRequired = $missingRequired + MissingRecommended = $missingRecommended + Present = $present + CompliancePercent = [math]::Round(($present.Count / $analysis.Count) * 100, 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 new file mode 100644 index 000000000..fadba3449 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TenantHierarchy.ps1 @@ -0,0 +1,126 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-TENANTHIERARCHY.PS1 +# AZURE FINOPS MULTITOOL - Management Group & Subscription Hierarchy +########################################################################### +# Purpose: Retrieve the full management group tree with subscriptions +# nested under their parent groups. +########################################################################### + +function Get-TenantHierarchy { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [int]$TimeoutSeconds = 60 + ) + + try { + # Run in a background runspace with timeout to prevent UI freeze + $rs = [runspacefactory]::CreateRunspace() + $rs.Open() + $ps = [powershell]::Create() + $ps.Runspace = $rs + [void]$ps.AddScript({ + param($tid) + Get-AzManagementGroup -GroupId $tid -Expand -Recurse -ErrorAction Stop + }).AddArgument($TenantId) + + $asyncResult = $ps.BeginInvoke() + + # Wait for runspace — uses DispatcherFrame when WPF is loaded, else Start-Sleep + Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds + + if ($asyncResult.IsCompleted) { + $rootGroup = $ps.EndInvoke($asyncResult) + if ($ps.Streams.Error.Count -gt 0) { + throw $ps.Streams.Error[0].Exception + } + $ps.Dispose(); $rs.Close() + + if ($rootGroup) { + $actual = if ($rootGroup -is [array]) { $rootGroup[0] } else { $rootGroup } + $subMap = @{} + Build-SubMap -Group $actual -Map ([ref]$subMap) + return [PSCustomObject]@{ + RootGroup = $actual + SubscriptionMap = $subMap + } + } + } + else { + # Timed out — stop and fall through to fallback + $ps.Stop() + $ps.Dispose(); $rs.Close() + Write-Warning "Management group hierarchy timed out after $TimeoutSeconds seconds. Using flat subscription list." + } + + # Fallback + $subs = if ($Subscriptions) { @($Subscriptions) } else { + @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' }) + } + $fallbackRoot = [PSCustomObject]@{ + DisplayName = "Tenant Root" + Name = $TenantId + Children = @() + } + return [PSCustomObject]@{ + RootGroup = $fallbackRoot + SubscriptionMap = @{} + FlatSubs = $subs + } + } + catch { + # Lacking Microsoft.Management read/register access is expected for many + # scopes — fall back to a flat subscription list quietly instead of + # dumping a wall of authorization noise to the console. + $errMsg = $_.Exception.Message + if ($errMsg -match 'does not have authorization|Authorization_RequestDenied|register/action|RBACAccessDenied|Forbidden|\(403\)') { + Write-Verbose "Management group hierarchy not accessible (insufficient permissions); using flat subscription list." + } + else { + Write-Verbose "Management group hierarchy unavailable; using flat subscription list. ($errMsg)" + } + + $subs = if ($Subscriptions) { @($Subscriptions) } else { + @(Get-AzSubscription -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Enabled' }) + } + $fallbackRoot = [PSCustomObject]@{ + DisplayName = "Tenant Root" + Name = $TenantId + Children = @() + } + + return [PSCustomObject]@{ + RootGroup = $fallbackRoot + SubscriptionMap = @{} + FlatSubs = $subs + } + } +} + +function Build-SubMap { + param( + [object]$Group, + [ref]$Map + ) + + if ($Group.Children) { + foreach ($child in $Group.Children) { + if ($child.Type -eq '/subscriptions') { + $Map.Value[$child.Name] = $Group.DisplayName + } + elseif ($child.Children) { + Build-SubMap -Group $child -Map $Map + } + } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 new file mode 100644 index 000000000..806ca9f51 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -0,0 +1,381 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-UNITECONOMICS.PS1 +# AZURE FINOPS MULTITOOL - Unit Economics ($/vCPU, $/GB) +########################################################################### +# Purpose: Compute FinOps unit-economics KPIs by dividing amortized cost +# by provisioned capacity: cost per vCPU (compute) and cost per +# GB (storage). Combines Cost Management amortized spend with +# Azure Resource Graph capacity counts. +########################################################################### +# Notes: +# - RBAC: Cost Management Reader (billing/MG scope) + Reader (ARG). +# - vCPU and RAM are exact when the Compute SKU capability lookup succeeds +# (authoritative vCPUs/MemoryGB per size); they fall back to a VM-size-name +# heuristic only when the SKUs API is unavailable for a region. +# - Storage GB combines provisioned managed-disk size (Resource Graph) and +# Storage-account used capacity (Azure Monitor UsedCapacity metric, one +# call per account); failures fall back to disk-only with a note. +# - Costs are month-to-date amortized, scoped to the selected subscriptions, +# grouped by meter category, with a per-subscription fallback when the +# management-group scope is not accessible. +########################################################################### + +# -- Helper: exact vCPU/RAM from Compute SKU capabilities ----------------- +# The Resource Graph 'resources' table does not expose vCPU/memory, so we +# query the Compute SKUs API per region (cached by location, follows +# nextLink) and build a vmSize -> {vCPU, MemGb} map. Returns $null when the +# size is not found so the caller can fall back to a name heuristic. +function Get-VmSizeCapability { + param( + [string]$SubId, + [string]$Location, + [string]$VmSize, + [hashtable]$Cache + ) + if (-not $Location -or -not $SubId) { return $null } + if (-not $Cache.ContainsKey($Location)) { + $map = @{} + try { + $next = "/subscriptions/$SubId/providers/Microsoft.Compute/skus?api-version=2021-07-01&`$filter=location eq '$Location'" + $pages = 0 + while ($next -and $pages -lt 6) { + $resp = Invoke-AzRestMethodWithRetry -Path $next -Method GET + if (-not $resp -or $resp.StatusCode -ne 200 -or -not $resp.Content) { break } + $json = $resp.Content | ConvertFrom-Json + foreach ($s in @($json.value)) { + if ($s.resourceType -ne 'virtualMachines') { continue } + if (-not $s.name -or $map.ContainsKey($s.name)) { continue } + $vc = 0; $mem = 0.0 + foreach ($c in @($s.capabilities)) { + if ($c.name -eq 'vCPUs') { [void][int]::TryParse([string]$c.value, [ref]$vc) } + elseif ($c.name -eq 'MemoryGB') { [void][double]::TryParse([string]$c.value, [ref]$mem) } + } + $map[$s.name] = @{ VCpu = $vc; MemGb = $mem } + } + $nl = $json.nextLink + $next = if ($nl) { ($nl -replace '^https?://[^/]+', '') } else { $null } + $pages++ + } + } + catch { } + $Cache[$Location] = $map + } + $m = $Cache[$Location] + if ($m -and $m.ContainsKey($VmSize)) { + return [PSCustomObject]@{ VCpu = [int]$m[$VmSize].VCpu; MemGb = [double]$m[$VmSize].MemGb } + } + return $null +} + +# -- Helper: accumulate amortized cost by meter category ------------------ +# Parses a Cost Management query response (grouped by MeterCategory) and adds +# compute vs storage spend into the supplied references. Returns $true when +# the response contained any rows (i.e. the query succeeded with data). +function Add-MeterCosts { + param( + [string]$Content, + [ref]$ComputeRef, + [ref]$StorageRef, + [ref]$CurrencyRef + ) + $any = $false + try { + $data = $Content | ConvertFrom-Json + if ($data.properties.rows) { + foreach ($row in $data.properties.rows) { + $any = $true + $amount = [double]$row[0] + $category = [string]$row[1] + if ($row.Count -ge 3 -and $row[2]) { $CurrencyRef.Value = [string]$row[2] } + switch -Wildcard ($category) { + 'Virtual Machines*' { $ComputeRef.Value += $amount } + 'Storage*' { $StorageRef.Value += $amount } + default { } + } + } + } + } + catch { } + return $any +} + +# -- Helper: storage account used capacity (blob/file/queue/table) -------- +# Managed disks are captured separately via Resource Graph. This adds the +# data-plane used capacity of Storage accounts via the Azure Monitor metrics +# API (UsedCapacity, account-level, emitted once per day). One metrics call +# per account; a failed account is skipped so it never breaks the scan. +# Returns used GB, or $null when the account inventory could not be read. +function Get-StorageAccountUsedGb { + param([string[]]$SubIds) + + $accounts = @() + try { + $saQuery = @" +resources +| where type =~ 'microsoft.storage/storageaccounts' +| project id +"@ + $result = Search-AzGraphSafe -Query $saQuery -Subscription $SubIds -First 1000 + $accounts = if ($result) { @($result.Data) } else { @() } + } + catch { + Write-Warning " Storage account inventory query failed: $($_.Exception.Message)" + return $null + } + + if ($accounts.Count -eq 0) { return 0.0 } + + # UsedCapacity is a daily metric; a 2-day window guarantees a data point. + $end = (Get-Date).ToUniversalTime() + $start = $end.AddDays(-2) + $timespan = "$($start.ToString('yyyy-MM-ddTHH:mm:ssZ'))/$($end.ToString('yyyy-MM-ddTHH:mm:ssZ'))" + + $totalBytes = 0.0 + $queried = 0 + foreach ($a in $accounts) { + if ($queried -ge 500) { break } # safety bound on call fan-out + $queried++ + try { + $path = "$($a.id)/providers/Microsoft.Insights/metrics?api-version=2018-01-01&metricnames=UsedCapacity&aggregation=Average&interval=P1D×pan=$timespan" + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method GET + if (-not $resp -or $resp.StatusCode -ne 200 -or -not $resp.Content) { continue } + $json = $resp.Content | ConvertFrom-Json + foreach ($metric in @($json.value)) { + foreach ($ts in @($metric.timeseries)) { + $points = @($ts.data) | Where-Object { $null -ne $_.average } + if ($points.Count -gt 0) { $totalBytes += [double]$points[-1].average } + } + } + } + catch { } + } + return [math]::Round($totalBytes / 1GB, 1) +} + +function Get-UnitEconomics { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions + ) + + Write-Host " Computing unit economics (per vCPU, per GB RAM, per GB disk)..." -ForegroundColor Cyan + + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + + # -- 1: Provisioned compute capacity (exact vCPU + RAM via Compute SKUs) -- + $totalVCpu = 0 + $totalMemGb = 0.0 + $vmCount = 0 + $vcpuExact = $true + $skuCache = @{} + try { + $vmQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| extend vmSize = tostring(properties.hardwareProfile.vmSize), loc = tostring(location), subId = tostring(subscriptionId) +| summarize cnt = count() by vmSize, loc, subId +"@ + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + $count = [int]$r.cnt + $vmCount += $count + $caps = Get-VmSizeCapability -SubId $r.subId -Location $r.loc -VmSize $r.vmSize -Cache $skuCache + if ($caps -and $caps.VCpu -gt 0) { + $totalVCpu += ($caps.VCpu * $count) + $totalMemGb += ($caps.MemGb * $count) + } + else { + # Fall back to the leading core digit in the size name. + $vcpuExact = $false + $cores = 1 + if ([string]$r.vmSize -match '(?i)[A-Z]+(\d+)') { $cores = [int]$matches[1] } + if ($cores -lt 1) { $cores = 1 } + $totalVCpu += ($cores * $count) + } + } + Write-Host " VMs: $vmCount | vCPUs: $totalVCpu | RAM: $([math]::Round($totalMemGb, 0)) GB" -ForegroundColor Gray + } + catch { + Write-Warning " vCPU query failed: $($_.Exception.Message)" + } + + # -- 2: Provisioned storage (managed disk GB) ------------------------- + $diskGb = 0 + try { + $diskQuery = @" +resources +| where type =~ 'microsoft.compute/disks' +| summarize totalGb = sum(toint(properties.diskSizeGB)) +"@ + $result = Search-AzGraphSafe -Query $diskQuery -Subscription $subIds -First 1000 + $rows = if ($result) { @($result.Data) } else { @() } + if ($rows.Count -gt 0 -and $null -ne $rows[0].totalGb) { $diskGb = [double]$rows[0].totalGb } + Write-Host " Provisioned disk: $diskGb GB" -ForegroundColor Gray + } + catch { + Write-Warning " Storage capacity query failed: $($_.Exception.Message)" + } + + # -- 2b: Storage account used capacity (blob/file/queue/table) -------- + $blobFileGb = 0.0 + $blobFileOk = $false + try { + $used = Get-StorageAccountUsedGb -SubIds $subIds + if ($null -ne $used) { + $blobFileGb = [double]$used + $blobFileOk = $true + Write-Host " Storage accounts used: $blobFileGb GB" -ForegroundColor Gray + } + } + catch { + Write-Warning " Storage account capacity query failed: $($_.Exception.Message)" + } + + # Total storage denominator = managed disks + storage-account used capacity. + $totalGb = $diskGb + $blobFileGb + + # -- 3: Amortized cost by meter category (sub-scoped, with fallback) -- + $computeCost = 0.0 + $storageCost = 0.0 + $currency = 'USD' + $costOk = $false + $costScope = 'none' + $mgFailed = $false + try { + $mgScopeId = Resolve-CostMgId -TenantId $TenantId + if ($mgScopeId) { + $dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'MeterCategory' }) + } + # Keep the single fast MG-scope call but scope it to the selected + # subscriptions so the cost numerator matches the capacity denominator. + $subFilter = Get-CostSubscriptionFilter -Subscriptions $Subscriptions + if ($subFilter) { $dataset['filter'] = $subFilter } + $body = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = $dataset + } | ConvertTo-Json -Depth 10 + + $path = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + + if ($resp -and $resp.StatusCode -in @(401, 403)) { + $mgFailed = $true + Set-MgCostScopeFailed + } + elseif ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencyRef ([ref]$currency)) { + $costOk = $true + $costScope = "mg:$mgScopeId" + } + } + } + else { + $mgFailed = $true + } + } + catch { + Write-Warning " Amortized cost query failed: $($_.Exception.Message)" + $mgFailed = $true + } + + # Per-subscription fallback when the MG scope is not accessible. This is + # the same pattern Get-CostData uses so unit economics is never silently $0. + if (-not $costOk -and $mgFailed) { + foreach ($sid in $subIds) { + try { + $body = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'MeterCategory' }) + } + } | ConvertTo-Json -Depth 10 + + $path = "/subscriptions/$sid/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + if (Add-MeterCosts -Content $resp.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencyRef ([ref]$currency)) { + $costOk = $true + $costScope = 'per-sub' + } + } + } + catch { + Write-Warning " Per-sub cost query failed for $sid : $($_.Exception.Message)" + } + } + } + + # -- 4: Derived KPIs -------------------------------------------------- + $costPerVCpu = if ($totalVCpu -gt 0) { [math]::Round($computeCost / $totalVCpu, 2) } else { 0 } + $costPerVm = if ($vmCount -gt 0) { [math]::Round($computeCost / $vmCount, 2) } else { 0 } + $costPerGb = if ($totalGb -gt 0) { [math]::Round($storageCost / $totalGb, 4) } else { 0 } + $costPerGbRam = if ($totalMemGb -gt 0) { [math]::Round($computeCost / $totalMemGb, 2) } else { 0 } + + $totalKnown = $computeCost + $storageCost + $computeSharePct = if ($totalKnown -gt 0) { [math]::Round(100 * $computeCost / $totalKnown, 1) } else { 0 } + $storageSharePct = if ($totalKnown -gt 0) { [math]::Round(100 * $storageCost / $totalKnown, 1) } else { 0 } + + $hasData = $costOk -and (($totalVCpu -gt 0) -or ($totalGb -gt 0)) + + # -- 5: Honest diagnostics so $0 is explained, not silent ------------- + $notes = @() + if (-not $costOk) { + $notes += 'No cost data returned - check Cost Management Reader at the management-group or subscription scope.' + } + if ($costOk -and $computeCost -eq 0 -and $vmCount -gt 0) { + $notes += 'VMs found but $0 compute MTD (newly created, deallocated, or fully reservation/savings-plan covered).' + } + if ($totalGb -eq 0 -and $vmCount -gt 0) { + $notes += 'No storage GB found - VMs may use ephemeral OS disks and no Storage accounts hold data.' + } + if (-not $blobFileOk) { + $notes += 'Storage-account used capacity unavailable (metrics not readable) - cost per GB reflects managed disks only.' + } + if (-not $vcpuExact) { + $notes += 'Some vCPU/RAM values approximated from VM size names (SKU capability lookup unavailable for a region).' + } + if ($notes.Count -eq 0) { + $notes += 'vCPU/RAM are exact (Compute SKU capabilities); storage GB combines managed disks and Storage-account used capacity.' + } + + return [PSCustomObject]@{ + HasData = $hasData + Currency = $currency + ComputeCost = [math]::Round($computeCost, 2) + StorageCost = [math]::Round($storageCost, 2) + ComputeSharePct = $computeSharePct + StorageSharePct = $storageSharePct + VmCount = $vmCount + TotalVCpu = $totalVCpu + TotalMemoryGb = [math]::Round($totalMemGb, 0) + DiskGb = [math]::Round($diskGb, 1) + BlobFileGb = [math]::Round($blobFileGb, 1) + TotalStorageGb = [math]::Round($totalGb, 1) + CostPerVCpu = $costPerVCpu + CostPerGbRam = $costPerGbRam + CostPerVm = $costPerVm + CostPerGb = $costPerGb + VCpuExact = $vcpuExact + BlobFileOk = $blobFileOk + CostScope = $costScope + Period = 'MonthToDate' + ScannedSubs = $Subscriptions.Count + Note = ($notes -join ' ') + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 new file mode 100644 index 000000000..53e9e2652 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 @@ -0,0 +1,309 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-USAGEPROPORTIONALALLOCATION.PS1 +# TELEMETRY-KEYED SHOWBACK FOR SHARED PLATFORMS (AKS / APIM / AOAI) +########################################################################### +# Purpose: Split the billed cost of a shared platform across SUB-RESOURCE +# consumers (k8s namespace, APIM product, OpenAI deployment) by a +# usage signal pulled from telemetry - for showback/chargeback. +# Date: Created for FinOps Multitool shared-cost allocation +# +# Description: +# Native Azure cost allocation rules can only key on SubscriptionId, +# ResourceGroupName or Tag. When the consumer you want to charge is a +# Kubernetes namespace, an APIM product/subscription, or an Azure OpenAI +# model deployment, there is NO billing dimension for it - so the only fair +# split is telemetry-driven. This tool reads a usage key from a Log Analytics +# workspace (Container Insights for AKS, workspace-based Application Insights +# for APIM token metrics, Azure Monitor metrics for OpenAI tokens) and +# apportions the shared pool cost accordingly. +# +# SHOWBACK GUARD: these consumers are NOT Azure billing dimensions, so the +# result is Mode=Showback, RuleTargets is always empty, and it can NEVER be +# written via set_cost_allocation_rule. It is for reporting / internal +# chargeback only. +# +# ── Parameters ────────────────────────────────────────────────── +# Preset aksNamespace | apimTokens | openAiTokens | custom +# WorkspaceId Log Analytics workspace GUID holding the telemetry +# SharedResourceIds Resource IDs whose billed cost forms the pool +# SharedResourceGroup Resource group holding the shared platform +# PoolAmount Explicit pool cost (use instead of resolving resources) +# LookbackDays Telemetry window (default 30) +# DimensionName Override the consumer dimension (e.g. APIM 'API ID') +# WeightingQuery Full KQL override - must return columns Consumer, Weight +# +# Prerequisites: +# - Az.OperationalInsights + read on the workspace +# - The platform must emit the telemetry (Container Insights / App Insights +# token metrics / AOAI diagnostic metrics) +# +# Usage: Get-UsageProportionalAllocation -Preset aksNamespace ` +# -SharedResourceGroup rg-aks -WorkspaceId +########################################################################### + +# -- Preset registry: each preset maps a shared platform to a usage key ---- +# Query templates use {lb} (lookback days) and {dim} (dimension name). Each +# query MUST return two columns: Consumer (string) and Weight (number). +function Get-TelemetryPreset { + param([string]$Name) + + $presets = @{ + aksNamespace = @{ + ConsumerDimension = 'KubernetesNamespace' + Source = 'Container Insights (Log Analytics)' + DefaultDimension = 'container.azm.ms/namespace' + Query = @' +InsightsMetrics +| where TimeGenerated >= ago({lb}d) +| where Name in ('cpuUsageNanoCores', 'memoryWorkingSetBytes') +| extend ns = tostring(parse_json(Tags)['container.azm.ms/namespace']) +| where isnotempty(ns) +| summarize cpuCores = avgif(Val, Name == 'cpuUsageNanoCores') / 1000000000.0, memGB = avgif(Val, Name == 'memoryWorkingSetBytes') / 1073741824.0 by ns +| extend Weight = cpuCores + (memGB / 4.0) +| project Consumer = ns, Weight +'@ + } + apimTokens = @{ + ConsumerDimension = 'ApimConsumer' + Source = 'Application Insights (workspace-based)' + DefaultDimension = 'Subscription Id' + Query = @' +AppMetrics +| where TimeGenerated >= ago({lb}d) +| where Name in ('Total Tokens', 'Tokens', 'TotalTokens', 'total_tokens') +| extend consumer = tostring(Properties['{dim}']) +| where isnotempty(consumer) +| summarize Weight = sum(Sum) by Consumer = consumer +'@ + } + openAiTokens = @{ + ConsumerDimension = 'OpenAIResource' + Source = 'Azure Monitor metrics (Cognitive Services)' + DefaultDimension = 'Resource' + Query = @' +AzureMetrics +| where TimeGenerated >= ago({lb}d) +| where ResourceProvider == 'MICROSOFT.COGNITIVESERVICES' +| where MetricName in ('ProcessedPromptTokens', 'GeneratedTokens', 'TokenTransaction', 'ProcessedInferenceTokens') +| summarize Weight = sum(Total) by Consumer = Resource +'@ + } + } + + if ($presets.ContainsKey($Name)) { return $presets[$Name] } + return $null +} + +# -- Generalized telemetry weighting provider ------------------------------ +function Get-TelemetryWeighting { + param( + [string]$WorkspaceId, + [string]$Preset = 'aksNamespace', + [int]$LookbackDays = 30, + [string]$Query, + [string]$DimensionName + ) + + $weights = @{} + $lb = if ($LookbackDays -gt 0) { $LookbackDays } else { 30 } + + $def = Get-TelemetryPreset -Name $Preset + if (-not $def -and -not $Query) { + return [PSCustomObject]@{ + Ok = $false + ConsumerDimension = 'Unknown' + Source = 'Unknown' + BillingWritable = $false + Weights = $weights + QueryUsed = $null + Note = "Unknown preset '$Preset'. Use aksNamespace, apimTokens, openAiTokens, or supply a weightingQuery returning Consumer, Weight." + } + } + + $dimName = if ($DimensionName) { $DimensionName } elseif ($def) { $def.DefaultDimension } else { '' } + $kql = if ($Query) { $Query } else { $def.Query } + $kql = $kql.Replace('{lb}', "$lb").Replace('{dim}', $dimName) + + $consumerDim = if ($def) { $def.ConsumerDimension } else { 'Custom' } + $source = if ($def) { $def.Source } else { 'Custom query (Log Analytics)' } + + if (-not $WorkspaceId) { + return [PSCustomObject]@{ + Ok = $false + ConsumerDimension = $consumerDim + Source = $source + BillingWritable = $false + Weights = $weights + QueryUsed = $kql + Note = 'workspaceId (the Log Analytics workspace GUID) is required to read telemetry.' + } + } + + $note = '' + try { + $qr = Invoke-AzOperationalInsightsQuery -WorkspaceId $WorkspaceId -Query $kql -ErrorAction Stop + foreach ($row in @($qr.Results)) { + $c = [string]$row.Consumer + if (-not $c) { continue } + $w = 0.0; [double]::TryParse([string]$row.Weight, [ref]$w) | Out-Null + if ($w -gt 0) { $weights[$c] = $w } + } + if ($weights.Count -eq 0) { + $note = 'Telemetry query returned no rows. Verify the workspace has the expected data and that the query returns Consumer + Weight columns.' + } + } + catch { + $note = "Telemetry query failed: $($_.Exception.Message)" + } + + return [PSCustomObject]@{ + Ok = ($weights.Count -gt 0) + ConsumerDimension = $consumerDim + Source = $source + BillingWritable = $false + Weights = $weights + QueryUsed = $kql + Note = $note + } +} + +# -- Main: telemetry-keyed showback split of a shared pool ----------------- +function Get-UsageProportionalAllocation { + [CmdletBinding()] + param( + [Parameter()] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [string[]]$SharedResourceIds, + + [Parameter()] + [string]$SharedResourceGroup, + + [Parameter()] + [double]$PoolAmount = 0, + + [Parameter()] + [string]$Preset = 'aksNamespace', + + [Parameter()] + [string]$WorkspaceId, + + [Parameter()] + [int]$LookbackDays = 30, + + [Parameter()] + [string]$DimensionName, + + [Parameter()] + [string]$WeightingQuery, + + [Parameter()] + [object[]]$HubData + ) + + if (-not $WorkspaceId) { + return [PSCustomObject]@{ + HasData = $false + Note = 'Provide workspaceId - the Log Analytics workspace GUID that holds the platform telemetry (Container Insights / App Insights).' + } + } + $haveResources = ($SharedResourceIds -and $SharedResourceIds.Count -gt 0) -or $SharedResourceGroup + if (-not $haveResources -and $PoolAmount -le 0) { + return [PSCustomObject]@{ + HasData = $false + Note = 'Provide the shared pool: sharedResourceIds / sharedResourceGroup (cost is resolved), or poolAmount (an explicit cost to split).' + } + } + + $scanSubs = @($Subscriptions | ForEach-Object { $_.Id }) + + # -- Size the shared pool --------------------------------------------- + $poolTotal = 0.0 + $poolResources = @() + $currency = 'USD' + $source = 'Explicit' + + if ($haveResources) { + Write-Host ' Resolving shared platform resources...' -ForegroundColor Cyan + $pool = Resolve-SharedCostPool -SubscriptionIds $scanSubs -ResourceIds $SharedResourceIds -ResourceGroup $SharedResourceGroup + if (@($pool).Count -gt 0) { + $hubSubs = @($pool | ForEach-Object { $_.SubscriptionId } | Select-Object -Unique) + $maps = Get-AllocationCostMaps -SubscriptionIds $hubSubs -HubData $HubData + $currency = $maps.Currency + $source = $maps.Source + foreach ($p in $pool) { + $c = if ($maps.ByResource.ContainsKey($p.Id)) { [double]$maps.ByResource[$p.Id] } else { 0.0 } + $poolTotal += $c + $poolResources += [PSCustomObject]@{ Name = $p.Name; Type = $p.Type; Cost = [math]::Round($c, 2) } + } + } + } + + if ($poolTotal -le 0 -and $PoolAmount -gt 0) { + $poolTotal = $PoolAmount + $source = 'Explicit' + } + + if ($poolTotal -le 0) { + return [PSCustomObject]@{ + HasData = $false + Note = 'The shared pool cost resolved to 0. Check the resource IDs / resource group, or pass poolAmount explicitly.' + } + } + + # -- Telemetry usage key ---------------------------------------------- + Write-Host " Reading telemetry usage ($Preset)..." -ForegroundColor Cyan + $tw = Get-TelemetryWeighting -WorkspaceId $WorkspaceId -Preset $Preset -LookbackDays $LookbackDays -Query $WeightingQuery -DimensionName $DimensionName + + $weights = $tw.Weights + $totalWeight = 0.0 + foreach ($v in $weights.Values) { $totalWeight += [double]$v } + + $allocations = @() + $notes = @() + if ($totalWeight -gt 0) { + foreach ($key in $weights.Keys) { + $w = [double]$weights[$key] + $pct = $w / $totalWeight + $allocations += [PSCustomObject]@{ + Consumer = $key + WeightUnits = [math]::Round($w, 4) + WeightPct = [math]::Round($pct * 100, 2) + AllocatedCost = [math]::Round($poolTotal * $pct, 2) + } + } + $allocations = @($allocations | Sort-Object AllocatedCost -Descending) + } + else { + $notes += 'No usage telemetry resolved, so the pool was not split. ' + $tw.Note + } + + $notes += "Showback only: $($tw.ConsumerDimension) is not an Azure billing dimension, so this allocation CANNOT be written as a native cost allocation rule (set_cost_allocation_rule). Use it for internal chargeback / reporting." + if ($tw.Note -and $totalWeight -gt 0) { $notes += $tw.Note } + + return [PSCustomObject]@{ + HasData = $true + Mode = 'Showback' + BillingWritable = $false + Preset = $Preset + ConsumerDimension = $tw.ConsumerDimension + Source = $tw.Source + CostSource = $source + Period = 'MonthToDate' + Currency = $currency + SharedPool = [PSCustomObject]@{ + TotalCost = [math]::Round($poolTotal, 2) + Resources = @($poolResources | Sort-Object Cost -Descending) + } + Allocations = $allocations + RuleTargets = @() + Note = ($notes -join ' ') + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 new file mode 100644 index 000000000..012b84192 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 @@ -0,0 +1,358 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# GET-VMCOSTBREAKDOWN.PS1 +# AZURE FINOPS MULTITOOL - Full VM Cost Decomposition +########################################################################### +# Purpose: Decompose a single virtual machine's total solution cost into +# its meter components - compute, OS/data disks, network egress, +# network infrastructure (public IP / NIC), backup/recovery, +# security (Defender), monitoring/extension agents, and licensing +# - instead of a single rolled-up number. +# +# Approach: A VM's billed cost is spread across several resource IDs (the +# VM, its managed disks, its public IPs, and bandwidth meters). +# We resolve the VM and its associated resources from Azure +# Resource Graph, then read cost grouped by ResourceId + meter +# and bucket each line item into a human-readable category. +# +# Sources: Live Cost Management API (default) OR the FinOps Hub / export +# when -HubData is injected by the caller (fast path, +# also carries consumed quantity so egress GB is exact). +# +# Notes: +# - RBAC: Cost Management Reader (sub scope) + Reader (ARG). +# - Bandwidth that Azure bills at subscription scope (no resource ID) +# cannot be attributed to one VM; it is reported as a caveat, not folded +# into the VM total. +########################################################################### + +# -- Meter -> category classifier ----------------------------------------- +# Buckets a billed line item into a VM-solution cost category using the +# meter category/subcategory (and resource type as a fallback for Hub rows +# where the meter category may be sparse). +function Get-VmMeterBucket { + param( + [string]$MeterCategory, + [string]$MeterSubCategory, + [string]$MeterName, + [string]$ResourceType + ) + + $cat = "$MeterCategory".Trim() + $sub = "$MeterSubCategory".Trim() + $mtr = "$MeterName".Trim() + $rt = "$ResourceType".Trim().ToLowerInvariant() + $blob = "$cat $sub $mtr" + + switch -Regex ($cat) { + '^(Bandwidth|Inter-Region|Content Delivery Network|Routing Preference)' { return 'Network egress' } + '^(Virtual Network|Load Balancer|VPN Gateway|ExpressRoute|IP Addresses|NAT Gateway|Application Gateway|Azure Firewall|Azure DNS)' { return 'Network infra' } + '^Storage' { return 'Disk / storage' } + '^(Backup|Azure Site Recovery|Recovery Services)' { return 'Backup / recovery' } + '(Defender|Security Center)' { return 'Security (Defender)' } + '^(Log Analytics|Azure Monitor|Application Insights)' { return 'Monitoring / agents' } + '^Virtual Machines Licenses' { return 'Licensing' } + '^Virtual Machines' { + if ($blob -match '(?i)\b(Windows|SQL|RHEL|SUSE|License)\b') { return 'Licensing' } + return 'Compute' + } + } + + # Fallback on resource type (Hub rows sometimes lack a meter category) + switch -Regex ($rt) { + 'microsoft\.compute/virtualmachines$' { return 'Compute' } + 'microsoft\.compute/disks$' { return 'Disk / storage' } + 'microsoft\.network/publicipaddresses$' { return 'Network infra' } + 'microsoft\.network/networkinterfaces$' { return 'Network infra' } + 'microsoft\.recoveryservices/' { return 'Backup / recovery' } + } + + if ($blob -match '(?i)data transfer|egress|bandwidth') { return 'Network egress' } + return 'Other' +} + +# -- Resolve a VM and its associated billable resources from ARG ---------- +# Returns $null when the VM cannot be located. Otherwise an object with the +# VM identity plus a lowercased HashSet of every resource ID whose cost +# rolls up into this VM's solution (VM, disks, NICs, public IPs). +function Resolve-VmAssociation { + param( + [string[]]$SubscriptionIds, + [string]$VmName, + [string]$ResourceId, + [string]$ResourceGroup + ) + + $where = if ($ResourceId) { + "id =~ '$(ConvertTo-KqlLiteral $ResourceId)'" + } + elseif ($ResourceGroup) { + "name =~ '$(ConvertTo-KqlLiteral $VmName)' and resourceGroup =~ '$(ConvertTo-KqlLiteral $ResourceGroup)'" + } + else { + "name =~ '$(ConvertTo-KqlLiteral $VmName)'" + } + + $vmQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where $where +| extend osDiskId = tostring(properties.storageProfile.osDisk.managedDisk.id) +| project id, name, resourceGroup, location, subscriptionId, + vmSize = tostring(properties.hardwareProfile.vmSize), + osDiskId, + dataDisks = properties.storageProfile.dataDisks, + nics = properties.networkProfile.networkInterfaces +"@ + + $res = Search-AzGraphSafe -Query $vmQuery -Subscription $SubscriptionIds -First 50 + $rows = if ($res) { @($res.Data) } else { @() } + if ($rows.Count -eq 0) { return $null } + + $ambiguous = $rows.Count -gt 1 + $vm = $rows[0] + + $assoc = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + [void]$assoc.Add([string]$vm.id) + if ($vm.osDiskId) { [void]$assoc.Add([string]$vm.osDiskId) } + + $nicIds = @() + foreach ($d in @($vm.dataDisks)) { + $did = [string]$d.managedDisk.id + if ($did) { [void]$assoc.Add($did) } + } + foreach ($n in @($vm.nics)) { + $nid = [string]$n.id + if ($nid) { $nicIds += $nid; [void]$assoc.Add($nid) } + } + + # Resolve public IPs attached to the VM's NICs + if ($nicIds.Count -gt 0) { + $nicList = ($nicIds | ForEach-Object { "'$(ConvertTo-KqlLiteral $_)'" }) -join ',' + $pipQuery = @" +resources +| where type =~ 'microsoft.network/networkinterfaces' +| where id in~ ($nicList) +| mv-expand ipc = properties.ipConfigurations +| extend pip = tostring(ipc.properties.publicIPAddress.id) +| where isnotempty(pip) +| project pip +"@ + try { + $pipRes = Search-AzGraphSafe -Query $pipQuery -Subscription $SubscriptionIds -First 100 + foreach ($p in @($pipRes.Data)) { if ($p.pip) { [void]$assoc.Add([string]$p.pip) } } + } + catch { Write-Warning " Public IP resolution failed: $($_.Exception.Message)" } + } + + return [PSCustomObject]@{ + Id = [string]$vm.id + Name = [string]$vm.name + ResourceGroup = [string]$vm.resourceGroup + Location = [string]$vm.location + SubscriptionId = [string]$vm.subscriptionId + VmSize = [string]$vm.vmSize + Associated = $assoc + Ambiguous = $ambiguous + MatchCount = $rows.Count + } +} + +function Get-VmCostBreakdown { + [CmdletBinding()] + param( + [Parameter()] + [string]$TenantId, + + [Parameter()] + [object[]]$Subscriptions, + + [Parameter()] + [string]$VmName, + + [Parameter()] + [string]$ResourceId, + + [Parameter()] + [string]$ResourceGroup, + + [Parameter()] + [object[]]$HubData + ) + + if (-not $VmName -and -not $ResourceId) { + return [PSCustomObject]@{ + HasData = $false + Note = 'Provide a vmName (optionally with resourceGroup) or a full resourceId to decompose.' + } + } + + $subIds = @($Subscriptions | ForEach-Object { $_.Id }) + + Write-Host " Resolving VM and associated resources..." -ForegroundColor Cyan + $vm = Resolve-VmAssociation -SubscriptionIds $subIds -VmName $VmName -ResourceId $ResourceId -ResourceGroup $ResourceGroup + if (-not $vm) { + $target = if ($ResourceId) { $ResourceId } else { $VmName } + return [PSCustomObject]@{ + HasData = $false + Target = $target + Note = "No virtual machine found matching '$target' in the scanned subscriptions." + } + } + + # Bucket accumulators: category -> @{ Cost; Qty; Meters(set) } + $buckets = @{} + $addLine = { + param($Category, $Amount, $Qty, $MeterLabel) + if (-not $buckets.ContainsKey($Category)) { + $buckets[$Category] = @{ Cost = 0.0; Qty = 0.0; Meters = [System.Collections.Generic.HashSet[string]]::new() } + } + $buckets[$Category].Cost += [double]$Amount + $buckets[$Category].Qty += [double]$Qty + if ($MeterLabel) { [void]$buckets[$Category].Meters.Add([string]$MeterLabel) } + } + + $currency = 'USD' + $source = 'LiveApi' + $subLevelEgress = 0.0 # bandwidth billed with no resource ID (cannot attribute) + $fromHub = ($HubData -and @($HubData).Count -gt 0) + + if ($fromHub) { + # ---- FinOps Hub / export fast path (richest: carries quantity) --- + $source = 'FinOpsHub' + Write-Host " Decomposing from FinOps Hub export..." -ForegroundColor Cyan + $props = $HubData[0].PSObject.Properties.Name + + foreach ($row in $HubData) { + $rid = [string](Get-HubRowValue -Row $row -Names @('ResourceId', 'x_ResourceId', 'InstanceId') -Props $props) + if (-not $rid -or -not $vm.Associated.Contains($rid)) { continue } + + $cost = [double](Get-HubRowValue -Row $row -Names @('CostInBillingCurrency', 'BilledCost', 'EffectiveCost', 'Cost') -Props $props) + $qty = [double](Get-HubRowValue -Row $row -Names @('ConsumedQuantity', 'Quantity', 'UsageQuantity') -Props $props) + $cur = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency') -Props $props) + if ($cur) { $currency = $cur } + + $mc = [string](Get-HubRowValue -Row $row -Names @('MeterCategory', 'x_SkuMeterCategory', 'ServiceName') -Props $props) + $ms = [string](Get-HubRowValue -Row $row -Names @('MeterSubCategory', 'x_SkuMeterSubcategory') -Props $props) + $mn = [string](Get-HubRowValue -Row $row -Names @('MeterName', 'x_SkuMeterName', 'Meter') -Props $props) + $rt = [string](Get-HubRowValue -Row $row -Names @('ResourceType', 'x_ResourceType', 'ConsumedService') -Props $props) + + $bucket = Get-VmMeterBucket -MeterCategory $mc -MeterSubCategory $ms -MeterName $mn -ResourceType $rt + $label = if ($ms) { "$mc / $ms" } elseif ($mc) { $mc } else { $rt } + & $addLine $bucket $cost $qty $label + } + } + else { + # ---- Live Cost Management API path ------------------------------- + Write-Host " Querying cost (Cost Management, MonthToDate)..." -ForegroundColor Cyan + $rgFilter = @{ dimensions = @{ name = 'ResourceGroupName'; operator = 'In'; values = @($vm.ResourceGroup) } } + $body = @{ + type = 'AmortizedCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ + totalCost = @{ name = 'Cost'; function = 'Sum' } + totalQty = @{ name = 'UsageQuantity'; function = 'Sum' } + } + grouping = @( + @{ type = 'Dimension'; name = 'ResourceId' } + @{ type = 'Dimension'; name = 'MeterCategory' } + ) + filter = $rgFilter + } + } | ConvertTo-Json -Depth 12 + + $path = "/subscriptions/$($vm.SubscriptionId)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + try { + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $body + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + $cols = @($data.properties.columns.name) + $iCost = [array]::IndexOf($cols, 'Cost') + $iQty = [array]::IndexOf($cols, 'UsageQuantity') + $iRes = [array]::IndexOf($cols, 'ResourceId') + $iCat = [array]::IndexOf($cols, 'MeterCategory') + $iCur = [array]::IndexOf($cols, 'Currency') + + foreach ($row in @($data.properties.rows)) { + $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 } + $qty = if ($iQty -ge 0) { [double]$row[$iQty] } else { 0 } + $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } + $mc = if ($iCat -ge 0) { [string]$row[$iCat] } else { '' } + if ($iCur -ge 0 -and $row[$iCur]) { $currency = [string]$row[$iCur] } + + # Bandwidth often bills with an empty resource ID at sub + # scope - record it as an unattributable caveat. + if (-not $rid) { + if ((Get-VmMeterBucket -MeterCategory $mc) -eq 'Network egress') { $subLevelEgress += $amount } + continue + } + if (-not $vm.Associated.Contains($rid)) { continue } + + $bucket = Get-VmMeterBucket -MeterCategory $mc + & $addLine $bucket $amount $qty $mc + } + } + else { + $code = if ($resp) { $resp.StatusCode } else { 'no response' } + return [PSCustomObject]@{ + HasData = $false + VmName = $vm.Name + Note = "Cost Management query failed (HTTP $code). Need Cost Management Reader on the subscription." + } + } + } + catch { + return [PSCustomObject]@{ + HasData = $false + VmName = $vm.Name + Note = "Cost Management query error: $($_.Exception.Message)" + } + } + } + + # -- Assemble breakdown ------------------------------------------------ + $total = 0.0 + foreach ($b in $buckets.Values) { $total += $b.Cost } + + $breakdown = @( + $buckets.GetEnumerator() | ForEach-Object { + [PSCustomObject]@{ + Category = $_.Key + Cost = [math]::Round($_.Value.Cost, 2) + PctOfTotal = if ($total -gt 0) { [math]::Round(($_.Value.Cost / $total) * 100, 1) } else { 0 } + Quantity = [math]::Round($_.Value.Qty, 2) + Meters = @($_.Value.Meters) + } + } | Sort-Object Cost -Descending + ) + + $egressBucket = $buckets['Network egress'] + $egressQty = if ($egressBucket) { [math]::Round($egressBucket.Qty, 2) } else { 0 } + + $notes = @() + if ($vm.Ambiguous) { $notes += "$($vm.MatchCount) VMs matched '$VmName'; showing the first. Pass resourceId or resourceGroup to disambiguate." } + if ($subLevelEgress -gt 0) { $notes += "Excludes $([math]::Round($subLevelEgress,2)) $currency of bandwidth billed at subscription scope (no resource ID, not attributable to one VM)." } + if (-not $fromHub) { $notes += 'Egress quantity (GB) is exact only on the FinOps Hub path; call with dataSource=hub when an export exists.' } + + return [PSCustomObject]@{ + HasData = ($breakdown.Count -gt 0) + VmName = $vm.Name + ResourceId = $vm.Id + ResourceGroup = $vm.ResourceGroup + SubscriptionId = $vm.SubscriptionId + Location = $vm.Location + VmSize = $vm.VmSize + Currency = $currency + Period = 'MonthToDate' + Source = $source + TotalCost = [math]::Round($total, 2) + EgressGb = $egressQty + Breakdown = $breakdown + ResourcesIncluded = @($vm.Associated) + Note = if ($notes.Count -gt 0) { $notes -join ' ' } else { 'Full VM solution cost: compute + disks + network + extensions, month-to-date.' } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 new file mode 100644 index 000000000..d5c579505 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Initialize-Scanner.ps1 @@ -0,0 +1,259 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# INITIALIZE-SCANNER.PS1 +# AZURE FINOPS MULTITOOL - Authentication & Prerequisites +########################################################################### +# Purpose: Validate required Az modules, authenticate to Azure, and return +# tenant context for the scanner to operate against. +########################################################################### + +function Show-TenantPicker { + param([object[]]$Tenants) + + Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue + + $pickerXaml = @" + + + + + + + + + + +