diff --git a/.build/BuildHelper/Build-PsModule.ps1 b/.build/BuildHelper/Build-PsModule.ps1 index ef16f0182..92b37c46f 100644 --- a/.build/BuildHelper/Build-PsModule.ps1 +++ b/.build/BuildHelper/Build-PsModule.ps1 @@ -20,8 +20,8 @@ function Build-PsModule $moduleName = 'FinOpsToolkit' $moduleFullName = "$moduleName.psm1" $modulePath = Join-Path -Path $rootPath -ChildPath "src/powershell/$moduleFullName" - $privatePath = Join-Path -Path $rootPath -ChildPath "src/powershell/private" - $publicPath = Join-Path -Path $rootPath -ChildPath "src/powershell/public" + $privatePath = Join-Path -Path $rootPath -ChildPath "src/powershell/Private" + $publicPath = Join-Path -Path $rootPath -ChildPath "src/powershell/Public" $stringsPath = Join-Path -Path $rootPath -ChildPath 'src/powershell/en-US' $releasePath = Join-Path -Path $rootPath -ChildPath "release/$moduleName/$baseVersion" $manifestPath = Join-Path -Path $releasePath -ChildPath "$moduleName.psd1" diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 1702ee02d..3a9552b85 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -3,6 +3,11 @@ name: 'PowerShell Tests' on: pull_request: paths: + - '.github/workflows/dev.yml' + - '.build/BuildHelper/**' + - '.build/BuildHelper.psm1' + - 'package.json' + - 'src/scripts/Get-Version.ps1' - 'src/powershell/**' # KQL sources are covered by unit tests (HubsKqlOperators.Tests.ps1) - 'src/templates/finops-hub/**/*.kql' @@ -11,6 +16,10 @@ on: - 'src/templates/finops-hub/**/timeZones.bicep' - 'docs/deploy/finops-hub-latest.json' - 'docs/deploy/finops-hub-preview.json' + +permissions: + contents: read + jobs: run_pester_tests: name: Pester @@ -25,3 +34,124 @@ jobs: - name: Run Tests shell: pwsh run: .build/start.ps1 -Task Test.PowerShell.All + + multitool_native: + name: Multitool (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [windows-latest, ubuntu-latest, macos-latest] + env: + DOTNET_CLI_TELEMETRY_OPTOUT: '1' + DOTNET_NOLOGO: '1' + steps: + - name: Install and cache test dependencies + uses: potatoqualitee/psmodulecache@9e4b63833c22c1768d648e115a9c849afdda22a5 # v6.3 + with: + modules-to-cache: Pester:6.0.0, Az.Accounts, Az.Resources, Az.ResourceGraph, Az.Storage + shell: pwsh + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install SDK for native Parquet restore + if: runner.os == 'Linux' + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + dotnet-version: '8.0.x' + - name: Run native multitool tests + shell: pwsh + env: + TMPDIR: ${{ runner.temp }} + run: | + $ErrorActionPreference = 'Stop' + Import-Module Pester -RequiredVersion 6.0.0 -Force + $names = @( + 'AzGraphPagination', 'BudgetCoverage', 'CommitmentUtilizationDedupe' + 'CostQueryPagination', 'CurrencyLabel', 'FOHubProvider', 'HubSizeProbe' + 'MultitoolResultIntegrity', 'MultitoolSafety', 'ParquetPackageClient', 'PolicyEffect' + 'RetryJitter', 'Start-FinOpsMultitool' + ) + $paths = @($names | ForEach-Object { + Join-Path 'src/powershell/Tests/Unit' "$_.Tests.ps1" + }) + foreach ($path in $paths) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing test: $path" } + } + $configuration = New-PesterConfiguration + $configuration.Run.Path = $paths + $configuration.Run.PassThru = $true + $configuration.Run.Exit = $false + $configuration.Output.Verbosity = 'Detailed' + $configuration.TestResult.Enabled = $true + $configuration.TestResult.OutputFormat = 'NUnitXml' + $configuration.TestResult.OutputPath = Join-Path $env:RUNNER_TEMP 'multitool-unit.xml' + $result = Invoke-Pester -Configuration $configuration + $result | Select-Object Result, TotalCount, PassedCount, FailedCount, SkippedCount, FailedContainersCount + @( + '## Multitool validation' + "Tested merge commit: $env:GITHUB_SHA" + "Host: $([Runtime.InteropServices.RuntimeInformation]::OSDescription); PowerShell: $($PSVersionTable.PSVersion)" + "Unit tests: $($result.Result); passed $($result.PassedCount), failed $($result.FailedCount), skipped $($result.SkippedCount)." + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + if ($null -eq $result -or $result.Result -ne 'Passed' -or + $result.TotalCount -le 0 -or $result.PassedCount -le 0 -or + $result.FailedCount -ne 0 -or $result.FailedContainersCount -ne 0 -or + $result.FailedBlocksCount -ne 0 -or $result.Containers.Count -ne $paths.Count) { + throw 'Native multitool tests failed or discovery was incomplete.' + } + exit 0 + - name: Run package and real Parquet integration tests + if: ${{ !cancelled() }} + shell: pwsh + env: + TMPDIR: ${{ runner.temp }} + run: | + $ErrorActionPreference = 'Stop' + Import-Module Pester -RequiredVersion 6.0.0 -Force + $paths = @('src/powershell/Tests/Integration/MultitoolPackage.Tests.ps1') + $minimumPassed = 3 + $parquetCoverage = 'Not run on macOS: NuGet signed-package verification is not supported. https://learn.microsoft.com/dotnet/core/tools/nuget-signed-package-verification#macos' + if (-not $IsMacOS) { + $paths += 'src/powershell/Tests/Integration/MultitoolParquet.Tests.ps1' + $minimumPassed += 2 + $parquetCoverage = 'Real signed Parquet restore and cold/cached reads in separate processes.' + } + foreach ($path in $paths) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing test: $path" } + } + $configuration = New-PesterConfiguration + $configuration.Run.Path = $paths + $configuration.Run.PassThru = $true + $configuration.Run.Exit = $false + $configuration.Output.Verbosity = 'Detailed' + $configuration.TestResult.Enabled = $true + $configuration.TestResult.OutputFormat = 'NUnitXml' + $configuration.TestResult.OutputPath = Join-Path $env:RUNNER_TEMP 'multitool-integration.xml' + $result = Invoke-Pester -Configuration $configuration + $result | Select-Object Result, TotalCount, PassedCount, FailedCount, SkippedCount, FailedContainersCount + @( + "Integration tests: $($result.Result); passed $($result.PassedCount), failed $($result.FailedCount), skipped $($result.SkippedCount)." + 'Checks: isolated module build and public launch; CSV, HTML, and text reports.' + "Parquet coverage: $parquetCoverage" + 'Azure access is replaced with synthetic responses. Package downloads and signature verification require network access.' + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + if ($null -eq $result -or $result.Result -ne 'Passed' -or + $result.PassedCount -lt $minimumPassed -or $result.FailedCount -ne 0 -or + $result.SkippedCount -ne 0 -or $result.NotRunCount -ne 0 -or + $result.FailedContainersCount -ne 0 -or $result.FailedBlocksCount -ne 0 -or + $result.Containers.Count -ne $paths.Count) { + throw 'Multitool integration tests failed, were skipped, or discovery was incomplete.' + } + exit 0 + - name: Upload multitool test evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: multitool-tests-${{ matrix.os }} + path: | + ${{ runner.temp }}/multitool-unit.xml + ${{ runner.temp }}/multitool-integration.xml + if-no-files-found: error + retention-days: 14 diff --git a/docs-mslearn/TOC.yml b/docs-mslearn/TOC.yml index 052ef93eb..44207a304 100644 --- a/docs-mslearn/TOC.yml +++ b/docs-mslearn/TOC.yml @@ -196,6 +196,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 @@ -242,6 +248,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 6ca4dd7c3..73f7f7211 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -3,7 +3,7 @@ title: FinOps toolkit changelog description: Review the latest features and enhancements in the FinOps toolkit, including updates to FinOps hubs, Power BI reports, and more. author: MSBrett ms.author: brettwil -ms.date: 09/11/2026 +ms.date: 10/07/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit @@ -54,6 +54,62 @@ The following section lists features and enhancements that are currently in deve - **Fixed** - Made the idle application gateway and idle public IP query join kinds explicit so they no longer rely on the `innerunique` default ([#2225](https://github.com/microsoft/finops-toolkit/pull/2225)). +### [FinOps multitool](multitool/finops-multitool-overview.md) + +- **Added** + - Added the FinOps multitool, which scans an Azure environment for cost optimization, governance, and FinOps insights through a PowerShell 7 terminal UI ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). + - Included 30 read-only scan modules, with 26 available in the menu, covering orphaned resources, idle VMs, storage tier advice, Azure Hybrid Benefit, tag and policy inventory and recommendations, cost data, cost trend, cost by tag, resource costs, reservation advice, commitment utilization, estimated savings, budget status and history, anomaly alerts, Advisor recommendations, billing structure, and contract info. + - Added a companion set of agent skills that carry the investigation routing, the queries, and the interpretation rules so AI agents can run the same analysis through Azure CLI or an Azure MCP server. + - Added engine-side aggregation through the FinOps hub's Azure Data Explorer or Microsoft Fabric Kusto database for large environments, with a storage reader as a small-dataset fallback. + - Added a non-interactive mode for an already-authenticated pipeline or scheduled job, and automatic private CSV, HTML, and text reports. Consoles that can't render the arrow-key menus, such as PowerShell remoting sessions, fall back to numbered prompts. + - Added `-Accessible` to select numbered prompts without clearing the screen or repainting menus in cursor-capable terminals. + - Added a searchable KPI reference and in-report calculation details for cost shares, unit rates, VM and storage screening, and budget coverage and forecast availability. + - Added aggregate and per-subscription cost trend views with captured UTC periods, partial-month labels, and separate empty and unverified coverage states. + - Added sticky table headers, row numbers, sorting, resizable columns, and expanded views to the local HTML report without changing CSV data. + - Added an explicit ordinary Cost Management CSV export source without requiring a FinOps hub, with selected-scope coverage, reads limited to the chosen export folder, manifest-verified partitions, and no silent live-cost fallback. Discovery reports progress per scope and per storage account. If you choose exports from the menu and none can be verified, you're asked whether to use the Cost Management API instead. Interactive runs ask before scanning more than 100 storage accounts for exports. +- **Fixed** + - Fixed literal `\u000D` text appearing at the ends of terminal table rows on Windows ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). + - Fixed policy recommendations failing when a complete inventory contains no policy assignments ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). + - Fixed incomplete billing-scope discovery appearing as complete commitment utilization. + - Kept unreadable Hub tags unverified instead of counting them as missing tags. + - Rejected explicit empty scan lists instead of running the default scans. + - Fixed malformed budget records counting toward confirmed budget coverage. + - Preserved budget inventory failures and partial coverage in budget history reports. + - Rejected unsafe export blob paths and invalid fallback container names, and escaped control characters in storage-discovery diagnostics. + - Made failed and unreadable Kusto discovery visible without changing the selected tenant or subscriptions. + - Rejected malformed Kusto responses and partial query failures instead of returning incomplete or empty success results. + - Rejected empty authentication tokens before sending Kusto queries and retained detailed Kusto HTTP errors on PowerShell 7. + - Reported the macOS Parquet signature-verification limitation before invoking a package client or downloading packages. + - Fixed AI token totals using inconsistent account and deployment measurements, and kept same-named deployments in different accounts separate. + - Fixed commitment SKU and kind metadata, incomplete fallback pagination, and unavailable utilization appearing as measured zero. + - Clarified budget sampling and CPU units, distinguished storage lookup failures from missing permissions, and added private launcher help and read-only regression checks. + - Hardened CSV exports against formula prefixes after whitespace or invisible characters and unsafe column names, while preserving numeric credits. + - Fixed storage-backed Parquet imports returning empty values or misaligning costs when an export contains nested metadata. + - Fixed measured zero unit-cost KPIs appearing unavailable. + - Fixed automatic Hub discovery failures aborting scans, including when every probe fails, while preserving explicit source choices and tenant boundaries ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). + - Fixed provider-discovery exceptions aborting detected-Hub scans and preserved the selected storage fallback through scan execution ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). + - Required a Y or N answer before switching to the Cost Management API when a detected FinOps hub's storage can't be reached. + - Limited estimated savings and unit costs to the selected subscriptions, querying subscriptions individually when a management group doesn't contain every selected subscription. + - Kept orphaned-resource costs and resource forecasts in their billing currencies instead of combining or relabeling them. Resource costs from the Cost Management API now include `ForecastSource`, which shows whether each forecast comes from a Cost Management forecast or a month-to-date projection. Forecasts stay empty when they're unavailable or can't be split across resources, and a failed forecast request keeps actual resource costs and reports the gap as limited data. + - Rejected partitioned export runs without a valid manifest, and read only the newest snapshot of a legacy unpartitioned export instead of adding snapshots together. + - Kept unattributed FinOps hub charges with their own subscription, and kept tag values that differ only by case separate. + - Counted the recommended `ApplicationName` and `OpsTeam` tags toward cost allocation. + - Reported incomplete Advisor recommendations and unreadable storage capacity instead of presenting partial results as complete. + - Stopped Advisor cost and reservation recommendations from being counted twice when the Resource Graph read fails partway through and the per-subscription fallback runs. + - Kept every resource cost row from the per-subscription Cost Management fallback, including unattributed charges and resource IDs that differ only by case, instead of keeping only the last row for each ID. + - Reported the count and amount in each currency of export rows that have no subscription, such as purchases or refunds billed outside a subscription. These rows aren't included in subscription totals. + - Fixed the cost trend leaving out selected subscriptions that the management-group query omitted, such as subscriptions outside that group. The scan now queries those subscriptions individually, and any it can't read or combine in one currency stay unverified instead of counting as zero cost. + - Grouped each scan's summaries, notes, and guidance in the HTML report into one scrollable panel so long notes don't lengthen the page. + - Confirmed billing account ownership before reporting contract details, and counted only enabled anomaly alert rules as detection coverage. + - Fixed FinOps hub CSV fallback reads to match hub ingestion: each export run counts toward the month in its manifest, and only the latest FOCUS cost run with rows is kept for each export scope and month, instead of adding runs together or dropping other scopes. Exports for unselected subscriptions are skipped, a manifest that can't be verified stops the read only when it could affect the months being read, and CSV files that no readable manifest lists are reported. The fallback checks at most 2,000 export manifests, doesn't download manifests over 1 MB, and stops instead of counting a file that two runs list. Fixed agreement detection from subscription metadata when billing account details can't be read. + - Corrected agent skill guidance on billed and effective cost and on when tags appear in cost data, and replaced references to unavailable tools with the terminal UI data sources. + - Stopped unresolved explicit subscription lookups from searching other tenants or widening the scan scope ([#2155](https://github.com/microsoft/finops-toolkit/pull/2155)). + - Fixed policy-definition read failures appearing as complete scan results; warnings and reports now identify unread definitions while retaining assignments and valid compliance data ([#2335](https://github.com/microsoft/finops-toolkit/issues/2335)). + - Limited management-group cost discovery to 25 candidates, including the tenant root, while preserving pagination, selected-subscription fallback, and tenant-specific caching ([#2335](https://github.com/microsoft/finops-toolkit/issues/2335)). + - Fixed crowded HTML reports for large subscription selections with bounded columns, compact scope lists, expandable tag and policy details, and local table filtering and pagination without reducing CSV detail. + - Fixed missing resource-cost periods and unclear resource identities by retaining the requested UTC window, displaying resource and reservation-charge labels, and preserving full IDs in HTML and CSV. + - Added verified subscription and management-group display names to policy locations while retaining scope IDs when name lookup fails. + ### [Power BI reports](power-bi/reports.md) - **Changed** diff --git a/docs-mslearn/toolkit/finops-toolkit-overview.md b/docs-mslearn/toolkit/finops-toolkit-overview.md index 22f68c7fb..9e4df0a92 100644 --- a/docs-mslearn/toolkit/finops-toolkit-overview.md +++ b/docs-mslearn/toolkit/finops-toolkit-overview.md @@ -3,7 +3,7 @@ title: FinOps toolkit overview description: Learn how the FinOps toolkit helps you automate and extend the Microsoft Cloud with starter kits, scripts, and advanced solutions to improve FinOps practices. author: flanakin ms.author: micflan -ms.date: 08/05/2026 +ms.date: 09/16/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..80b57576c --- /dev/null +++ b/docs-mslearn/toolkit/multitool/finops-multitool-overview.md @@ -0,0 +1,90 @@ +--- +title: FinOps multitool overview +description: FinOps multitool scans an Azure environment for cost optimization, governance, and FinOps insights from a terminal UI, with agent skills so AI assistants can run the same analysis. +author: z-larsen +ms.author: zlarsen +ms.date: 09/20/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, grounded in your live resource state. It reports on cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance. Run it from an interactive terminal, or call it as tools from an AI agent. + +## How it works + +FinOps multitool provides 30 scan modules, with 26 available in the terminal menu, and renders findings for the subscriptions you select: + +- **Interactive scanning**
Choose the subscriptions and scan modules you want, then review results in the terminal. Every completed run automatically saves CSV files, an HTML report, and a text summary to a private local folder. See [Report storage](../powershell/multitool/start-finopsmultitool.md#report-storage) for locations and privacy limits. Consoles that can't render the arrow-key menus fall back to numbered prompts. Non-interactive runs require an existing Azure sign-in context. + +- **AI agent support**
Agent skills describe the same investigations, the queries behind them, and how to read the results, so AI assistants can answer cost questions from your environment's data. + +- **Cost data sources**
When a [FinOps hub](../hubs/finops-hubs-overview.md) is available, cost scans query the hub's Azure Data Explorer or Microsoft Fabric database and push aggregation into the engine, returning only summarized results. A storage reader covers smaller datasets, and the Cost Management API is used when no hub is present. + +- **Read-only**
The multitool never creates, changes, or deletes a resource. + +## Benefits + +FinOps multitool provides the following benefits: + +- Choose from 26 menu scans across optimization, governance, cost analysis, commitments, monitoring, and sustainability, with four more modules for direct investigations. +- Scope each scan to the subscriptions you select. +- Get a CSV file per selected scan, an HTML report, and a text summary saved automatically to a private local folder. +- Read costs from a FinOps hub or the Cost Management API, with resource inventory from Azure Resource Graph. +- Run the same scans from a pipeline or a scheduled job with `-NonInteractive`. +- Run the same investigations from an AI assistant through agent skills. + +## 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 reports the same kinds of findings in the terminal and through AI agent skills, so you can scan an environment during a working session without leaving the command line. + +## Required permissions + +Most scans need [Reader](/azure/role-based-access-control/built-in-roles#reader) or [Cost Management Reader](/azure/role-based-access-control/built-in-roles#cost-management-reader) on the target scope. Account scans (billing structure, contract info, and Microsoft Azure Consumption Commitment balance) also need agreement-specific billing access: [Billing account reader or Billing profile reader for a Microsoft Customer Agreement](/azure/cost-management-billing/manage/understand-mca-roles), or [Enterprise Administrator (read only) for an Enterprise Agreement](/azure/cost-management-billing/manage/understand-ea-roles), at the scope the scan reads. + +Commitment utilization reads reservation and savings plan usage at billing account or billing profile scope, so it needs the same access as account scans. Reader on a subscription isn't enough. Without it, the scan tells you it couldn't reach a billing scope instead of showing zero commitments. + +The carbon scan needs Reader or [Carbon Optimization Reader](/azure/carbon-optimization/permissions) assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. + +## Give feedback + +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..c12f4717d --- /dev/null +++ b/docs-mslearn/toolkit/powershell/multitool/finops-multitool-commands.md @@ -0,0 +1,155 @@ +--- +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: 10/07/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 supports two ways to investigate FinOps data: + +- **Terminal UI (TUI)** – An interactive terminal experience launched with [Start-FinOpsMultitool](start-finopsmultitool.md). It surfaces 26 of the 30 scans. +- **Agent skills** – A set of skills that describe which investigation answers a question, the queries behind it, and how to read the results. + +The terminal UI prompts for each choice by default. Consoles that can't render the arrow-key menus, such as PowerShell remoting sessions, fall back to numbered prompts. Use `-Accessible` to select numbered prompts without clearing the screen or repainting menu rows in any console. This mode stays in the signed-in tenant. To run the tool from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters; it takes precedence over `-Accessible`. + +Automation requires an existing Azure context established with the intended identity. Without one, `-NonInteractive` fails before scanning. PowerShell 7 or later is required. For the macOS Parquet limitation and alternative data sources, see [FinOps hub data paths](#finops-hub-data-paths). + +An explicit `-SubscriptionId` must resolve in the current tenant. Unresolved or mismatched subscriptions stop the run without searching other tenants or widening scope. Sign in to the intended tenant before trying again. A valid subscription selection changes context only in the current PowerShell process. The tool verifies tenant ownership before source discovery and stops subscription enumeration if the tenant is missing or changed. + +CSV, HTML, and text reports are saved automatically on the machine running the multitool, in a new private folder under the current user's local application data. Use `-OutputPath` to select a different local parent folder outside Git repositories. For location details and privacy limits, see [Report storage](start-finopsmultitool.md#report-storage). + +The HTML report's **KPI reference** tab lists the available KPI definitions, including entries not measured in the current run. Each entry includes its status, calculation, required inputs, interpretation, limitations, and a link to its source scan when that scan was included. **Computed** means a value was derived, not that the environment is healthy; some values are estimates or proxies. **Unavailable**, **Not run**, and **Informational** distinguish missing measurements, unselected scans, and definitions that need additional data or calculations. + +Existing result tables provide sticky headers, row numbers, local search, sorting, resizable columns, and 25-row pages. **Expand** opens the same table in a larger view; **Close** or Escape restores its position and state. Search includes collapsed details. Wide tables scroll within the report, and printing retains all matching rows. Each scan's summaries, notes, and guidance share one panel below its heading, which scrolls when the content is long. Printing removes that height limit, though collapsed details print only their summary line. The controls work locally without external scripts, uploads, or changes to CSV data. + +**Calculation and thresholds** disclosures explain Unit Economics, Idle VMs, Storage Tier Advice, and Budget Status beside their results. Unit Economics percentages are shares of the **VM compute plus storage subtotal**, not total Azure spend or an efficiency score. The report includes the captured UTC cost period and amortized basis. Unit rates use current capacity, including stopped VMs, rather than time-weighted running-resource capacity. Compare with a workload-specific baseline instead of assuming a universal healthy percentage. + +Idle and storage screening show their thresholds and evaluated counts. Missing metrics leave resources unevaluated. Budget coverage counts subscriptions with a budget, while usable forecasts are counted separately; zero at-risk budgets isn't an all-clear when forecasts are missing. + +
+ +## 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 estimated savings. +- **Monitoring** – Budget status, budget history, and anomaly alerts. +- **Advisor** – Azure Advisor cost recommendations. +- **Account** – Billing structure, contract info, and Microsoft Azure Consumption Commitment (MACC) balance. +- **AI and ML** – Azure AI workload spend. +- **Sustainability** – Carbon emissions. + +VM cost breakdown, shared cost allocation, usage-proportional allocation, and billing account are direct module functions, not menu entries or valid `Start-FinOpsMultitool -Scans` choices. The remaining 26 scans are available through the terminal UI. + +Analysis scans are read-only. Most need Reader or Cost Management Reader access. Account scans also need agreement-specific billing access, such as Billing account reader or Billing profile reader for a Microsoft Customer Agreement, or Enterprise Administrator (read only) for an Enterprise Agreement. Commitment utilization reads reservation and savings plan usage at billing account or billing profile scope, so it needs that billing access. The carbon scan needs Reader or Carbon Optimization Reader assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. + +Complete Resource Graph reads fail when a page is unreadable, a continuation token repeats, or the page limit is reached. A full page without a continuation token is also unverified, even if the true result happens to equal the page size. Affected inventory scans don't report partial rows as a successful complete inventory. Cost trend also rejects missing currency fields or monthly totals that would mix currencies. + +**Cost Trend** provides selected-scope aggregate and per-subscription HTML views with names, IDs, and row currencies. API queries record one UTC window covering six full months and the current partial month; the report labels that partial month from the captured query end. When the management-group response omits selected subscriptions, such as subscriptions outside that group, the scan queries those subscriptions individually. Coverage distinguishes returned rows, successful empty subscription queries, and omitted subscriptions whose individual results couldn't be added. Missing subscriptions and months aren't treated as zero cost, and an unverified aggregate isn't a whole-tenant total. Requests and grouped results stay filtered to the selected IDs. Both trend datasets and the captured metadata remain in CSV. Older results without metadata show unverified coverage and an unrecorded query window. + +Resource-cost API results include the requested UTC month-to-date window in HTML and CSV. The period comes from the [Cost Management query request](/rest/api/cost-management/query/usage), not a billing-completeness timestamp. Friendly resource names, nested resource types, and reservation-charge labels retain their original resource IDs. Charges without subscription attribution remain separate instead of being assigned to an arbitrary subscription. + +Management-group cost-scope discovery probes at most 25 distinct candidates: up to 24 non-root groups, followed by the tenant root. List pagination and the existing per-candidate retry budget are preserved; this isn't a limit on total HTTP requests. When the candidate list is capped, a warning explains that other groups remain unprobed. If no candidate succeeds, cost scans query the selected subscriptions individually. Cached scopes and discovery failures aren't reused for a different tenant. + +On the API path, **Cost by Tag** retains successful subscription queries when another subscription fails. Reports identify incomplete coverage and failed subscriptions, and whole-scope allocation KPIs remain unavailable. Failed continuation pages don't contribute partial costs. The scan rejects mixed-currency totals and incomplete tag maps; if no subscription can be read, it reports an error. + +**Unit Economics** and **AI Workload Metrics** retain capacity or usage measurements when currency evidence is missing or mixed, but leave monetary totals and rates unavailable with an explanation. AI rates use matching account costs and usage rather than total AI spend, and incomplete metric reads suppress aggregate rates. A measured zero with known currency stays zero. + +Live AI metrics and amortized account cost share a captured UTC window. Token totals use the same measured basis per account and deployment, with prompt-plus-generated fallback only when both measurements exist. The report separates account costs from token usage, retains resource and subscription identity, and doesn't combine same-named deployments across accounts. Missing measurements stay unavailable. These effective account rates aren't per-model prices or a billing reconciliation. Cost covers Cognitive Services accounts, not the separate ML, Search, or GPU inventory. + +Commitment utilization retains every returned reservation and savings plan with its latest returned period and identity. Missing reservation SKU or kind can be enriched from matching reservation details; denied metadata doesn't discard known utilization. Absent commitment types and unknown percentages have unavailable averages instead of 0%. A measured zero stays zero. Averages are unweighted and suppressed when coverage or scope is unverified; utilization isn't a measurement of realized savings. + +Advisor and reservation savings retain each recommendation's currency and don't combine incompatible amounts. Billing account, profile, invoice-section, department, rule, and MACC reads follow all returned pages; failures remain visible as incomplete coverage. Tag inventory and anomaly scans also report incomplete reads rather than presenting them as measured zero or healthy coverage. + +For large subscription selections, budget status can sample subscriptions first. If the sample contains no budgets, it skips the remainder and reports coverage as unverified. Unreadable subscriptions aren't counted as having no budget. Policy inventory tracks assignment coverage separately from compliance coverage; incomplete compliance reads suppress the overall percentage. Storage tier advice leaves accounts with missing measurements unevaluated rather than treating absent samples as zero activity. + +Policy inventory also tracks definition-read coverage separately. Failed or malformed definition reads produce visible warnings and a **Limited data** result. `DefinitionCoverageIncomplete` and `DefinitionErrors` preserve the failure details in the scan result and CSV export without discarding assignments or valid compliance percentages. An unresolved effect in a successfully read definition isn't treated as a read failure. + +Policy locations show available subscription and management-group names, with the original scope IDs retained in HTML details and CSV. Only management groups referenced by assignments are looked up, and a name is accepted only when the returned group ID and tenant match. Missing access or an invalid response leaves the ID visible and records a `ScopeNameErrors` entry without changing assignment or compliance results. + +Budget history supports monthly cost budgets with no filter, tag or dimension `In` filters, or `and` combinations. Empty filter objects mean no filter. Filtered budgets query matching costs rather than reusing whole-subscription totals, including when the primary source is a hub. Months outside the budget's full-month validity, unsupported filters, and incompatible currencies remain unavailable. Comparisons use the current budget amount and filter, not historical budget revisions. Current forecasts separately remain unavailable when Azure doesn't return a forecast amount and compatible currency. + +The scan keeps the **Savings Realized** menu name for compatibility. It estimates savings using assumed discounts. It doesn't measure realized savings or calculate a savings percentage. Results include `IsEstimate` and `EstimateBasis`. Compare the estimates with matching pay-as-you-go rates and benefit usage before reporting realized savings. + +Commitment estimates cover usage charges in the reported UTC month-to-date period and retain the billing currency. Purchases, refunds, and unused commitment charges are excluded. Unknown, nonmonetary, or mixed currencies and negative usage adjustments stop the estimate. Azure Hybrid Benefit uses a separate USD estimate for 730 hours on the current VM inventory. The scan doesn't combine or annualize these amounts. For scripts, use `RISavingsMonthToDate`, `SPSavingsMonthToDate`, and `CommitmentSavingsMonthToDate` with `Currency` and `Period`. The old monthly commitment fields and combined monthly and annual totals remain empty. + +
+ +## FinOps hub data paths + +When a [FinOps hub](../../hubs/finops-hubs-overview.md) is present, cost scans read from the hub and choose the path automatically: + +- **Kusto database (used when available)** – When the hub has an Azure Data Explorer or Microsoft Fabric cluster, the multitool discovers it through Azure Resource Graph and pushes aggregation into the engine, returning only summarized results. This scales to large datasets without loading raw cost rows into PowerShell. To query a local hub on your own hardware, set the `FINOPS_HUB_KUSTO_URI` environment variable to a local Kusto endpoint (optionally set `FINOPS_HUB_KUSTO_DB`, which defaults to `Hub`). +- **Storage reader (small-dataset fallback)**: when no Kusto endpoint is configured or discovered, the multitool reads the hub's storage export and aggregates in PowerShell. Use this for smaller datasets. Reading Parquet exports prepares a pinned reader using NuGet on Windows or .NET SDK 8 or later on Linux. NuGet signed-package verification [isn't supported on macOS](/dotnet/core/tools/nuget-signed-package-verification#macos); use Kusto or available CSV exports there. The reader doesn't bypass signature verification. If it can't be prepared, the tool warns with the reason and attempts `msexports` CSV instead. The CSV fallback checks at most 2,000 export manifests; above that, use Kusto or the Cost Management API. It doesn't download a manifest larger than 1 MB, and it stops instead of counting a CSV file that two export runs list. A failed export read remains an error, not zero cost. + +Storage reads require Storage Blob Data Reader or equivalent data access. Kusto queries require database query access. Both paths need network access to the endpoint. Local Kusto queries are anonymous, but the public launcher still uses Azure context and resource metadata. + +An explicit `-DataSource API` or `-DataSource GraphOnly` takes precedence over `FINOPS_HUB_KUSTO_URI` and doesn't preload hub data. A configured Kusto URI can select a hub without a discovered storage account. An explicit `-DataSource Hub` reports an error if no configured endpoint or hub storage is available. + +Ordinary Cost Management exports are a separate source: select **Cost Management exports (CSV storage)** or use `-DataSource Export`. No hub is required. Discovery first reads export definitions for the selected subscriptions, their management-group ancestors, and linked billing accounts, reporting progress per scope. It then scans storage accounts in those subscriptions and merges anything Cost Management can't see, reporting progress per storage account. With more than 100 storage accounts, interactive runs ask before scanning them, because Azure allows 100 container listings per 5 minutes in each subscription and region; a skipped scan is reported. Container names are discovered automatically, so you don't enter them; that scan looks at containers whose names contain `export`, `msexports`, `ingestion`, `finops`, `cost`, or `focus`, plus any container a visible definition names. Unavailable locations produce summarized warnings, with details under `-Verbose`. The reader requires ActualCost or FOCUS BilledCost and storage data and network access; it doesn't create exports, read local files, or parse Parquet. Unattended runs require exactly one readable candidate. If you choose exports from the interactive menu and no export can be verified, you're asked whether to use the Cost Management API instead; `-DataSource Export` stops without switching sources. When the chosen run has a manifest, every declared partition must be readable or the run is reported as incomplete. Supported views are cost totals, resource costs, cost by tag, and the months present in that export run. Separate financial API scans are excluded, while inventory scans can still query Azure. Reads remain inside the chosen folder, filter row subscriptions, and retain partial coverage as unverified instead of filling gaps with live costs. Rows with no subscription, such as purchases or refunds billed outside a subscription, aren't included in subscription totals; the export coverage note reports their count and amount in each currency. Use Kusto for very large datasets because this CSV reader loads parts into memory. + +Automatic hub discovery queries only the selected subscriptions in the verified Azure context. When a hub can't be verified, discovery failures remain visible, interactive runs still offer API or GraphOnly, and `-NonInteractive` defaults to API without changing scope, even if every probe fails. An explicit `-DataSource Hub` request that can't be satisfied still stops instead of switching sources. + +Provider-discovery exceptions for a detected hub warn and fall back to that hub's storage-reader checks. Existing size and reachability warnings still apply. The scan runner keeps the selected storage path without repeating provider discovery. Explicit Kusto endpoint failures and failed Kusto cost queries don't silently switch sources. + +The internal Kusto provider lookup also warns for failed or unreadable Resource Graph responses. Kusto query results must contain valid table and row shapes with nonblank, noncolliding column names. A partial failure reported by `QueryStatus` rejects the response rather than returning partial cost rows. Valid empty results, zero amounts, and credits are preserved. + +GraphOnly excludes cost-dependent scans and orphan cost enrichment. Remaining scans can still use Azure Monitor, Advisor, policy, and carbon APIs. Dependencies can't re-enable an excluded cost scan. + +When no hub is available, the tool offers the Cost Management API. Once you select **FinOps Hub**, the tool reports any read or query failure as an error. Select **Cost Management API** to run a separate live scan. Kusto-only hubs don't currently support the AI workload scan. When forecasts are available for current-month storage data, the tool shows them as separate full-month API totals. It doesn't add forecasts to hub actuals. + +
+ +## 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, and so is the terminal UI. Both report what they find and recommend a change; applying it stays with you. + +
+ +## 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%3Areactions-%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..2a71e02bf --- /dev/null +++ b/docs-mslearn/toolkit/powershell/multitool/start-finopsmultitool.md @@ -0,0 +1,160 @@ +--- +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: 10/07/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. Scans cover cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance. + +Results appear in the terminal and are saved automatically on the machine running the command. Each run creates a private folder with one CSV file per selected scan, a `FinOpsReport.html` summary, and a `ScanSummary.txt` file. Failed or empty scans have a CSV status record. The scans don't change Azure resources. + +In the HTML report, large subscription selections appear as a count with an expandable scope list. Result tables with more than 25 rows support local filtering and pagination. Long tag-value and policy-assignment lists stay in expandable details, and wide tables scroll within their section. CSV exports retain the full returned data regardless of the current HTML filter or page. + +The command requires PowerShell 7 or later and the `Az.Accounts`, `Az.ResourceGraph`, and `Az.Storage` modules. For console modes and the macOS Parquet limitation, see [Terminal support](#terminal-support) and [FinOps hub data paths](#finops-hub-data-paths). + +Most scans need Reader or Cost Management Reader access on the target scope. Account scans (billing structure, contract info, and MACC commitment) also need agreement-specific billing access: [Billing account reader or Billing profile reader for a Microsoft Customer Agreement](/azure/cost-management-billing/manage/understand-mca-roles), or [Enterprise Administrator (read only) for an Enterprise Agreement](/azure/cost-management-billing/manage/understand-ea-roles). Grant access at the scope the scan reads. Commitment utilization reads at billing account or billing profile scope, so it needs that same billing access. The carbon scan needs Reader or Carbon Optimization Reader assigned at the subscription. Carbon emissions permissions don't apply at resource group or resource scope. + +The tool prompts for each choice by default. To run it from a pipeline or a scheduled job, use `-NonInteractive` and supply the choices as parameters. + +`-NonInteractive` requires an existing Azure context. Authenticate with the intended identity using `Connect-AzAccount` before launching the scan. Without a context, the command fails before scanning instead of starting interactive sign-in. + +An explicit subscription must belong to the signed-in tenant. Failed lookups don't search other tenants or open a broader subscription picker. Sign in to the intended tenant before targeting its subscription. A valid selection changes context only in the current PowerShell process. + +Automatic hub discovery keeps the selected tenant and subscriptions. When a hub can't be verified, failed probes produce warnings and the tool continues to the source menu, or defaults to API with `-NonInteractive`. This also applies when every probe fails. Explicit `-DataSource Hub` selections never switch to API automatically. Provider-discovery exceptions can fall back to an already detected hub's storage reader, but configured Kusto endpoints and failed Kusto cost queries don't silently switch sources. + +
+ +## Syntax + +```powershell +Start-FinOpsMultitool ` + [-SubscriptionId ] ` + [-OutputPath ] ` + [-Scans ] ` + [-DataSource ] ` + [-NonInteractive] ` + [-Accessible] ` + [] +``` + +
+ +## Parameters + +| Name | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `‑SubscriptionId` | Optional. Scopes the scan to a single subscription in the current tenant. A failed or mismatched lookup stops the run without searching other tenants or widening scope. When omitted, accessible subscriptions in the selected tenant are discovered. | +| `‑OutputPath` | Optional. Local parent folder for reports. Defaults to `FinOpsToolkit/Multitool/Reports` under the current user's local application data. Each run creates a new timestamped subfolder. Git repositories, UNC paths, mapped Windows network drives, symbolic links, and junctions aren't accepted. Unix network mounts aren't detected. | +| `‑Scans` | Optional. Runs the specified scans instead of the default selection. Accepts a scan command name, such as `Get-OrphanedResources`, or its menu label, such as `Orphaned Resources`. Use `All` on its own to select every menu scan, including Billing Structure. An unrecognized name returns an error. | +| `‑DataSource` | Optional. Sets the data source and skips the data source prompt. Valid values are `Hub`, `Export`, `API`, and `GraphOnly`. `Export` reads one existing CSV or CSV.gz Cost Management export instead of querying live costs, and doesn't require a hub; it needs ActualCost or FOCUS BilledCost, plus Storage Blob Data Reader and network access to the export destination. Parquet and local files aren't supported on that path, and an unattended `Export` run requires exactly one readable candidate. `Export`, `API`, and `GraphOnly` take precedence over `FINOPS_HUB_KUSTO_URI` and don't preload hub data. An explicit `Hub` selection fails if no configured Kusto endpoint or hub storage is available. Select `API` separately for a live scan. | +| `‑NonInteractive` | Optional. Runs without prompting and requires an existing authenticated Azure context. Every choice comes from the parameters or their defaults. Reports are saved automatically, even when `-OutputPath` is omitted. | +| `-Accessible` | Optional. Uses numbered prompts without clearing the screen or repainting menu rows. Stays in the signed-in tenant. `-NonInteractive` takes precedence and disables all prompts when both switches are set. | + +
+ +## 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. + +### Use numbered prompts + +```powershell +Start-FinOpsMultitool -Accessible +``` + +Uses numbered subscription and scan prompts in any console. The screen isn't cleared, and menu rows aren't repainted. This mode stays in the signed-in tenant; sign in separately before using a different tenant. + +### Scope to a single subscription + +```powershell +Start-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' +``` + +Launches the terminal UI scoped to a single subscription. + +### Choose a local report folder + +```powershell +Start-FinOpsMultitool -OutputPath (Join-Path $HOME 'FinOpsReports') +``` + +Launches the terminal UI and saves reports in a new run subfolder under the specified local folder. Choose a location outside Git repositories and synced folders. + +### Run specific scans without prompting + +Authenticate with the intended identity first. Then run: + +```powershell +Start-FinOpsMultitool ` + -NonInteractive ` + -SubscriptionId '00000000-0000-0000-0000-000000000000' ` + -Scans Get-OrphanedResources, Get-IdleVMs ` + -DataSource API +``` + +Runs two scans against one subscription without prompting and saves all report formats in the default local folder. Use this form from a pipeline or a scheduled job. + +
+ +## Report storage + +The default parent folder is `FinOpsToolkit/Multitool/Reports` under `[Environment]::GetFolderPath('LocalApplicationData')`. On Windows, that's usually `%LOCALAPPDATA%\FinOpsToolkit\Multitool\Reports`. The command prints the full path for each run. Reports never overwrite an earlier run. + +The tool creates the run folder with permissions restricted to the current user. It rejects Git repositories, UNC paths, mapped Windows network drives, symbolic links, and junctions, and includes an ignore-all `.gitignore` to reduce accidental staging. Unix network mounts aren't detected, so choose a path on a local filesystem. If saving fails, the command reports the error and retains results in `$FinOpsResults`. It doesn't silently use the current directory instead. + +Reports are plaintext, not encrypted, and can contain sensitive cost and resource details. The tool doesn't upload them. Keep custom folders outside cloud-sync locations, protect access to your account, and follow your organization's retention policy. Administrators and processes running as your account can still access the files. Moving or force-adding reports to Git bypasses these safeguards. + +
+ +## Terminal support + +The tool uses arrow-key menus when the console supports them. Consoles that can't drive those menus, such as PowerShell remoting sessions and some editor terminals, automatically fall back to numbered prompts that read one line at a time. Use `-Accessible` to select this mode in a console that supports cursor addressing. Both paths run the same scans and produce the same results. + +Use `-NonInteractive` when nothing can answer a prompt, such as a build agent. It disables prompts even when `-Accessible` is also set. + +In accessible mode, three invalid or blank source-menu answers cancel the run instead of selecting a source. Hub-to-API confirmation prompts require an explicit yes or no; invalid or blank input cancels without starting a scan. + +## Resource Graph only + +`-DataSource GraphOnly` removes scans that require cost data, including budget history, AI workload metrics, unit economics, and MACC. Dependencies can't re-enable those scans, and orphan cost enrichment is skipped. The remaining scans can still call Azure Monitor metrics, Advisor, policy, and carbon APIs; the option doesn't restrict every request to Azure Resource Graph. + +
+ +## FinOps hub data paths + +When you select [FinOps Hub](../../hubs/finops-hubs-overview.md), the tool prefers the configured or discovered Kusto database. Kusto aggregates the data and returns summaries without loading raw cost records into PowerShell. To query a local hub, set `FINOPS_HUB_KUSTO_URI` to its endpoint. A configured endpoint doesn't require a discovered storage account. When no Kusto endpoint is configured or discovered, the tool reads hub storage exports, which is intended for smaller datasets. A failed query remains an error; it doesn't silently switch sources. For more information, see [FinOps multitool commands](finops-multitool-commands.md). + +Reading Parquet exports prepares a pinned reader using NuGet on Windows or .NET SDK 8 or later on Linux. NuGet signed-package verification [isn't supported on macOS](/dotnet/core/tools/nuget-signed-package-verification#macos); use a configured Kusto source or available hub CSV exports there. Package signatures and hashes are checked before loading cached assemblies. An unavailable verifier leaves the cache unloaded but intact. If the reader can't be prepared, the tool warns you with the reason and attempts the hub's `msexports` CSV instead of normalized Parquet data. A failed export read remains an error, not zero cost. + +On macOS, Parquet setup returns the unsupported-verification reason before invoking a package client or downloading packages. It doesn't disable signature checks or automatically select a Kusto endpoint. + +
+ +## 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 a1e8531eb..853841263 100644 --- a/docs-mslearn/toolkit/powershell/powershell-commands.md +++ b/docs-mslearn/toolkit/powershell/powershell-commands.md @@ -3,7 +3,7 @@ title: FinOps toolkit PowerShell module description: Automate and scale your FinOps efforts using the FinOps toolkit PowerShell module, which includes commands to manage FinOps solutions. author: flanakin ms.author: micflan -ms.date: 08/13/2026 +ms.date: 09/16/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 03ac79baf..71f81fe22 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..9eeb36baf --- /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, grounded in your live resource state. It reports on cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance. Run it from an interactive terminal UI, or call it as tools from an AI agent. + +
+

New in the FinOps toolkitv15

+

+ The FinOps multitool is a new addition to the FinOps toolkit. It provides 30 read-only scan modules, with 26 in the terminal menu, plus agent skills for AI assistants and a FinOps hub Kusto data path for large environments. +

+

See all changes

+
+ + + +## 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..b7a95e806 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/FinOpsMultitool.psm1 @@ -0,0 +1,89 @@ +# 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). +if ($IsWindows -or $PSEdition -eq 'Desktop') { + $documents = [Environment]::GetFolderPath('MyDocuments') + if (-not [string]::IsNullOrWhiteSpace($documents)) { + $moduleFolder = if ($PSEdition -eq 'Desktop') { 'WindowsPowerShell/Modules' } else { 'PowerShell/Modules' } + $userModDir = Join-Path $documents $moduleFolder + $separator = [IO.Path]::PathSeparator + if ((Test-Path -LiteralPath $userModDir) -and $userModDir -notin ($env:PSModulePath -split [regex]::Escape([string]$separator))) { + $env:PSModulePath = "$userModDir$separator$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 'Get-JitteredDelay.ps1') +. (Join-Path $helpersPath 'Invoke-AzRestMethodWithRetry.ps1') +. (Join-Path $helpersPath 'Get-CostQueryResponsePage.ps1') +. (Join-Path $helpersPath 'Resolve-CurrencyLabel.ps1') +. (Join-Path $helpersPath 'Search-AzGraphSafe.ps1') +. (Join-Path $helpersPath 'Resolve-BillingScope.ps1') +. (Join-Path $helpersPath 'MgCostScope.ps1') +. (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 '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 '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 'Get-BillingAccount.ps1') +. (Join-Path $modulePath 'Get-UsageProportionalAllocation.ps1') +. (Join-Path $modulePath 'Get-AIWorkloadMetrics.ps1') +. (Join-Path $modulePath 'Get-CarbonMetrics.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..28f5c2ac2 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1 @@ -0,0 +1,4791 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; callers pass these uniformly across the scan family.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Justification = 'Scan results are published to the caller session by design.')] +param() + +########################################################################### +# INVOKE-FINOPSMULTITOOL.PS1 +# INTERACTIVE TERMINAL LAUNCHER FOR FINOPS MULTITOOL +########################################################################### +# 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 7+ +# - Az PowerShell modules: Az.Accounts, Az.ResourceGraph, Az.Storage +# - Azure RBAC: Reader + Cost Management Reader on target scope +########################################################################### + +function Invoke-FinOpsMultitool { + # .SYNOPSIS + # Runs the private FinOps Multitool terminal interface and creates local reports. + # .DESCRIPTION + # Uses the existing Azure identity to run read-only analysis within the selected tenant + # and subscriptions. Use Start-FinOpsMultitool as the supported public entry point. + # .PARAMETER SubscriptionId + # Selects a subscription in the current tenant. An unresolved ID stops the scan. + # .PARAMETER OutputPath + # Selects the local parent directory for a new private report folder, outside Git repositories. + # .PARAMETER Scans + # Selects scan function names, such as Get-CostData. Omit for interactive selection. + # .PARAMETER DataSource + # Selects Hub, Export, API, or GraphOnly. An unavailable explicit source does not fall back silently. + # .PARAMETER NonInteractive + # Disables prompts. Requires an existing Azure context; this switch does not sign in. + # .PARAMETER Accessible + # Uses numbered prompts without clearing the screen or repainting menus. NonInteractive disables all prompts. + # .EXAMPLE + # Invoke-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-CostData -DataSource API -NonInteractive + # Runs the cost-data scan for one selected subscription and saves reports locally. + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'NonInteractive', Justification = 'Read by the nested picker functions, which PSScriptAnalyzer does not trace into.')] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Accessible', Justification = 'Read by nested console helpers to disable cursor-driven interaction.')] + param( + [string]$SubscriptionId, + [string]$OutputPath, + [ValidateNotNullOrEmpty()] + [string[]]$Scans, + [ValidateSet('Hub', 'Export', 'API', 'GraphOnly')] + [string]$DataSource, + [switch]$NonInteractive, + [switch]$Accessible + ) + + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "FinOps Multitool requires PowerShell 7 or later. This session is PowerShell $($PSVersionTable.PSVersion). Open PowerShell 7 with 'pwsh', import the module there, and run the scan again. No scan was started." + } + + function Write-FinOpsConsole { + [CmdletBinding()] + param( + [Parameter(Position = 0)][AllowNull()][AllowEmptyString()][object]$Object, + [ConsoleColor]$ForegroundColor, + [ConsoleColor]$BackgroundColor, + [switch]$NoNewline + ) + $parameters = @{} + $PSBoundParameters + $parameters.Object = [regex]::Replace([string]$Object, '[\p{Cc}\p{Cf}]', { + param($character) + '\u{0:X4}' -f [int][char]$character.Value + }) + Write-Host @parameters + } + + # -- Load modules (always force-reimport to pick up latest changes) ---- + $multitoolRoot = $PSScriptRoot + # Re-probe the console every run; the host can differ between invocations. + $script:FinOpsRichConsole = $null + $psm1Path = Join-Path $multitoolRoot 'FinOpsMultitool.psm1' + if (Test-Path $psm1Path) { + Import-Module $psm1Path -Force + } + 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-FinOpsConsole "" + Write-FinOpsConsole " MISSING REQUIRED MODULES" -ForegroundColor Red + Write-FinOpsConsole " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray + foreach ($m in $missing) { + Write-FinOpsConsole " $($m.Name)" -ForegroundColor Red -NoNewline + Write-FinOpsConsole " — $($m.Reason)" -ForegroundColor DarkGray + } + Write-FinOpsConsole "" + Write-FinOpsConsole " Install with:" -ForegroundColor White + $names = ($missing.Name | ForEach-Object { "'$_'" }) -join ', ' + Write-FinOpsConsole " Install-Module $names -Scope CurrentUser" -ForegroundColor Yellow + Write-FinOpsConsole "" + 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' } + ) + + # An explicit -Scans list replaces the default selection. Names match either the + # scan function or its display name, so both the docs and the menu labels work. + if ($Scans -and $Scans.Count -gt 0) { + if ($Scans.Count -eq 1 -and $Scans[0] -match '^(?i)all$') { + foreach ($m in $scanModules) { $m.Selected = $true } + } + else { + foreach ($m in $scanModules) { $m.Selected = $false } + $unknownScans = @() + foreach ($name in $Scans) { + $matched = @($scanModules | Where-Object { $_.Fn -eq $name -or $_.Name -eq $name }) + if ($matched.Count -gt 0) { foreach ($m in $matched) { $m.Selected = $true } } + else { $unknownScans += $name } + } + if ($unknownScans.Count -gt 0) { + Write-Error "Unknown scan name(s): $($unknownScans -join ', '). Valid names: $(($scanModules | ForEach-Object { $_.Fn }) -join ', ')" + return + } + } + } + + # -- Permission Requirements per Module -------------------------------- + # Maps each function to the Azure RBAC role(s) needed and a human-readable reason + $permissionInfo = @{ + '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 = 'MCA Billing account reader or Billing profile reader, or EA Enterprise Administrator (read only)'; Scope = 'Billing account or billing profile'; API = 'Consumption Reservation Summaries + Cost Management Benefit Utilization APIs'; Reason = 'Reservation and savings plan utilization is published at billing scope only; a subscription-scoped read returns 404. Without billing access, the scan reports that no billing scope was resolved rather than reporting zero commitments.' } + 'Get-SavingsRealized' = @{ Role = 'Cost Management Reader + Reader'; Scope = 'Subscription or Management Group'; API = 'Cost Management Query API + Azure Resource Graph'; Reason = 'Requires Microsoft.CostManagement/query/action for commitment spend and Reader access for Azure Hybrid Benefit inventory. Savings amounts use assumed discounts, not measured benefit utilization.' } + 'Get-BudgetStatus' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Consumption Budgets API'; Reason = 'Requires Microsoft.Consumption/budgets/read. Returns empty if no budgets are configured for scanned subscriptions.' } + 'Get-BudgetHistory' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Query API'; Reason = 'Requires Microsoft.CostManagement/query/action to retrieve monthly actuals per budget. Runs only when Budget Status returns budgets.' } + 'Get-AnomalyAlerts' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Alerts API'; Reason = 'Requires Microsoft.CostManagement/alerts/read. Returns empty if no cost anomalies were detected.' } + '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 { + if (-not $Accessible) { + try { Clear-Host } catch { Write-Debug "Clear-Host is unavailable in this host: $_" } + } + + # Version comes from the toolkit so the TUI and the module cannot drift. + # Get-VersionNumber is a sibling private function, absent when this script runs standalone. + if (-not (Get-Command -Name Get-VersionNumber -ErrorAction SilentlyContinue)) { + # Nested Join-Path, not -AdditionalChildPath: that parameter is PowerShell 7+ only. + $verFile = Join-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..') -ChildPath 'Get-VersionNumber.ps1' + if (Test-Path -Path $verFile) { . $verFile } + } + $verText = if (Get-Command -Name Get-VersionNumber -ErrorAction SilentlyContinue) { "v$(Get-VersionNumber)" } else { '' } + # 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 ║ + ║ ║ + ╚════════════════════════════════════════════════════════════════════════╝ + +"@ + foreach ($line in ($banner -split '\r?\n')) { Write-FinOpsConsole $line -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) + } + + # Hosts without a usable console (remoting, CI, some editor terminals) still + # expose $Host.UI.RawUI, and there ReadKey blocks forever rather than failing, + # so probe a real console operation instead of testing for the object. + function Test-FinOpsRichConsole { + if ($Accessible) { return $false } + if ($null -ne $script:FinOpsRichConsole) { return $script:FinOpsRichConsole } + $rich = $true + try { $null = [Console]::CursorTop } catch { $rich = $false } + if ($rich) { + $redirected = $false + try { $redirected = [Console]::IsInputRedirected } catch { $redirected = $false } + if ($redirected) { $rich = $false } + } + $script:FinOpsRichConsole = $rich + if (-not $rich) { + Write-FinOpsConsole "" + Write-FinOpsConsole " This console does not support the arrow-key menus. Using numbered prompts." -ForegroundColor DarkGray + } + return $rich + } + + # Repositioning can still fail after the capability probe passes, for example + # when the buffer shrinks mid-render, so a failure re-renders lower rather + # than surfacing a .NET stack trace. + function Move-FinOpsCursorLine { + param([int]$LinesUp = 0) + try { + $top = [Console]::CursorTop + if ($LinesUp -gt 0) { $top = [math]::Max(0, $top - $LinesUp) } + [Console]::SetCursorPosition(0, $top) + } + catch { + Write-Verbose "Cursor repositioning unavailable: $($_.Exception.Message)" + } + } + + # Read-Host returns an empty string in a host that cannot prompt, which would + # spin a validation loop forever, so every caller needs an attempt ceiling. + function Read-FinOpsAnswer { + param([string]$Prompt) + Write-FinOpsConsole $Prompt -ForegroundColor White -NoNewline + $answer = $null + try { $answer = Read-Host } + catch [System.Management.Automation.PipelineStoppedException] { throw } + catch { $answer = $null } + if ($null -eq $answer) { return '' } + return $answer.Trim() + } + + # ===================================================================== + # DATA SOURCE PICKER + # ===================================================================== + function Select-ExportSource { + param([string]$TenantId, [array]$Subscriptions, [switch]$OfferApiFallback) + + $context = Get-AzContext -ErrorAction Stop + if (-not $context -or $context.Tenant.Id -ne $TenantId -or -not $Subscriptions.Count -or + @($Subscriptions | Where-Object { [string]::IsNullOrWhiteSpace($_.Id) -or $_.TenantId -ne $TenantId }).Count -gt 0) { + throw 'Export discovery requires the verified selected tenant and subscriptions.' + } + Write-FinOpsConsole " Reading export definitions for $($Subscriptions.Count) subscription(s), their management-group ancestors, and linked billing accounts..." -ForegroundColor Cyan + $exports = @() + $discoveryIssues = [Collections.Generic.List[string]]::new() + $environmentName = if ($context.Environment.Name) { $context.Environment.Name } else { 'AzureCloud' } + $definitionWarnings = @() + $storageWarnings = @() + try { $exports = @(Find-CostExport -Subscriptions $Subscriptions -Environment $environmentName -TenantId $TenantId -IncludeManagementGroups -IncludeBillingAccounts -SkipRunHistory -WarningAction SilentlyContinue -WarningVariable definitionWarnings) } + catch { $discoveryIssues.Add("Export definitions: $($_.Exception.Message)") } + if ($definitionWarnings.Count) { Write-FinOpsConsole " Export definition discovery reported $($definitionWarnings.Count) warning(s). Available choices are retained; use -Verbose for details." -ForegroundColor Yellow } + foreach ($warning in @($definitionWarnings)) { Write-Verbose ([regex]::Replace([string]$warning, '[\p{Cc}\p{Cf}]', ' ')) } + Write-FinOpsConsole " Export definitions found: $($exports.Count)." -ForegroundColor DarkGray + $knownKeys = @{} + foreach ($export in $exports) { + if ($export.StorageResourceId -and $export.Container -and $export.Name) { $knownKeys["$($export.StorageResourceId)|$($export.Container)|$(([string]$export.RootFolder).Trim('/'))|$($export.Name)".ToLowerInvariant()] = $true } + } + # Runs even when definitions exist and is deduped against them: a cross-tenant + # scan commonly sees some subscriptions' exports while a central + # management-group export stays invisible to Cost Management. + Write-FinOpsConsole ' Scanning storage accounts in the selected subscriptions for exports Cost Management cannot see...' -ForegroundColor Cyan + $definitionCount = $exports.Count + $storageSkipped = $false + try { + $stores = @(Get-ExportStorageCandidates -Subscriptions $Subscriptions -WarningAction SilentlyContinue -WarningVariable storageWarnings) + if ($stores.Count -gt 100 -and -not $NonInteractive) { + Write-FinOpsConsole " Found $($stores.Count) storage accounts. Azure allows 100 container listings per 5 minutes in each subscription and region, so scanning them all can be slow." -ForegroundColor Yellow + Write-FinOpsConsole " Scan all $($stores.Count) storage accounts? " -ForegroundColor White -NoNewline + Write-FinOpsConsole '(N = skip the storage scan)' -ForegroundColor DarkGray + $storageSkipped = (Read-FinOpsAnswer ' Select [Y/N]: ') -notmatch '^(?i)(y|yes)$' + } + if ($storageSkipped) { $discoveryIssues.Add("Export storage: skipped $($stores.Count) storage accounts by choice, so exports Cost Management can't see weren't checked.") } + else { $exports += @(Find-CostExportFromStorage -Subscriptions $Subscriptions -StorageAccounts $stores -Environment $environmentName -KnownKeys $knownKeys -WarningAction SilentlyContinue -WarningVariable +storageWarnings) } + } + catch { $discoveryIssues.Add("Export storage: $($_.Exception.Message)") } + if ($storageWarnings.Count) { Write-FinOpsConsole " Storage discovery reported $($storageWarnings.Count) warning(s). Unreadable locations were skipped, not treated as empty. Available export choices are retained; use -Verbose for details." -ForegroundColor Yellow } + foreach ($warning in @($storageWarnings)) { Write-Verbose ([regex]::Replace([string]$warning, '[\p{Cc}\p{Cf}]', ' ')) } + if (-not $storageSkipped) { Write-FinOpsConsole " Additional exports found directly in storage: $($exports.Count - $definitionCount)." -ForegroundColor DarkGray } + foreach ($issue in $discoveryIssues) { Write-FinOpsConsole " $issue" -ForegroundColor Yellow } + $seen = @{} + $candidates = @($exports | Where-Object { + if (-not $_ -or -not $_.StorageResourceId -or -not $_.Container -or -not $_.Name) { return $false } + $key = "$($_.StorageResourceId)|$($_.Container)|$(([string]$_.RootFolder).Trim('/'))|$($_.Name)".ToLowerInvariant() + if ($seen.ContainsKey($key)) { return $false } + $seen[$key] = $true + return $true + } | Sort-Object Name, StorageResourceId, Container) + if (-not $candidates.Count) { + if ($OfferApiFallback) { + Write-FinOpsConsole '' + Write-FinOpsConsole ' No export candidates could be verified for the selected subscriptions.' -ForegroundColor Yellow + Write-FinOpsConsole ' Use the live Cost Management API instead? ' -ForegroundColor White -NoNewline + Write-FinOpsConsole '(N = stop without scanning)' -ForegroundColor DarkGray + if ((Read-FinOpsAnswer ' Select [Y/N]: ') -match '^(?i)(y|yes)$') { return @{ Source = 'API'; HubStorage = $null } } + } + throw 'No export candidates could be verified. Some definitions or destinations may be inaccessible; this does not establish that no exports exist. Check access and network connectivity to the intended export destination, then retry with -Verbose for details.' + } + Write-FinOpsConsole ' Export data will be filtered to the selected subscriptions. Missing coverage will not be filled with live API costs.' -ForegroundColor DarkGray + $readable = @($candidates | Where-Object { [string]$_.Format -match '(?i)^csv' -and -not ($_.ScopeKind -ne 'Storage' -and $_.Type -eq 'AmortizedCost') }) + for ($exportIndex = 0; $exportIndex -lt $candidates.Count; $exportIndex++) { + $candidate = $candidates[$exportIndex] + $format = if ($candidate.Format) { $candidate.Format } else { 'Unknown format' } + $scopeLabel = if ($candidate.ScopeLabel) { $candidate.ScopeLabel } elseif ($candidate.SubName) { $candidate.SubName } else { $candidate.ScopeKind } + $readableLabel = if ($readable -contains $candidate) { '' } else { ' | not readable by this source' } + Write-FinOpsConsole " [$($exportIndex + 1)] $($candidate.Name) | $format | $($candidate.Type) | $scopeLabel$readableLabel" -ForegroundColor White + Write-FinOpsConsole " $($candidate.StorageResourceId) / $($candidate.Container) / $($candidate.RootFolder)" -ForegroundColor DarkGray + } + $selectedExport = $null + if ($NonInteractive) { + if ($readable.Count -gt 1) { throw 'Multiple export candidates were found. Run interactively to choose one; exports are not combined automatically.' } + # With nothing readable, keeping the first candidate lets the format and + # cost-basis checks below report the specific reason it was rejected. + $selectedExport = if ($readable.Count -eq 1) { $readable[0] } else { $candidates[0] } + } + else { + for ($attempt = 0; $attempt -lt 3 -and -not $selectedExport; $attempt++) { + $answer = Read-FinOpsAnswer " Select export [1-$($candidates.Count)] or C to cancel: " + if ($answer -eq 'C') { throw 'Export selection cancelled. No export data was read.' } + $selection = 0 + if ([int]::TryParse($answer, [ref]$selection) -and $selection -ge 1 -and $selection -le $candidates.Count) { $selectedExport = $candidates[$selection - 1] } + else { Write-FinOpsConsole ' Invalid export selection.' -ForegroundColor Yellow } + } + } + if (-not $selectedExport) { throw 'No export was selected. No export data was read.' } + if ($selectedExport.Format -notmatch '(?i)^csv') { throw "The selected export uses '$($selectedExport.Format)'. This reader supports CSV and CSV.gz exports; it will not switch to live API costs." } + if ($selectedExport.ScopeKind -ne 'Storage' -and $selectedExport.Type -eq 'AmortizedCost') { throw 'The selected export contains amortized cost. The current export-backed scans require ActualCost or FOCUS BilledCost; no live fallback was attempted.' } + Write-FinOpsConsole ' CSV parts are loaded into local memory. For very large exports, use a compatible FinOps Hub Kusto database.' -ForegroundColor Yellow + return @{ Source = 'Export'; Export = $selectedExport; TenantId = $TenantId; Environment = if ($context.Environment.Name) { $context.Environment.Name } else { 'AzureCloud' }; HubStorage = $null } + } + + function Select-DataSource { + param( + [string]$TenantId, + [array]$Subscriptions, + [string]$Preselected + ) + + if ([string]::IsNullOrWhiteSpace($TenantId) -or -not $Subscriptions.Count -or + @($Subscriptions | Where-Object { [string]::IsNullOrWhiteSpace($_.Id) -or $_.TenantId -ne $TenantId }).Count -gt 0) { + throw 'Every selected subscription must have a verified ID and belong to the selected tenant. No source discovery was started.' + } + $discoveryContext = Get-AzContext -ErrorAction Stop + if (-not $discoveryContext -or $discoveryContext.Tenant.Id -ne $TenantId) { + throw 'The current Azure context does not match the selected tenant. No source discovery was started.' + } + + Write-FinOpsConsole "" + Write-FinOpsConsole " DATA SOURCE" -ForegroundColor Cyan + Write-FinOpsConsole " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray + Write-FinOpsConsole "" + + if ($Preselected -in @('API', 'GraphOnly')) { + Write-FinOpsConsole " Data source set by parameter: $Preselected" -ForegroundColor DarkGray + return @{ Source = $Preselected; HubStorage = $null } + } + if ($Preselected -eq 'Export') { return Select-ExportSource -TenantId $TenantId -Subscriptions $Subscriptions } + if (-not [string]::IsNullOrWhiteSpace($env:FINOPS_HUB_KUSTO_URI)) { + $provider = Resolve-FOHubProvider -Subscriptions @($Subscriptions.Id) + return @{ Source = 'Hub'; HubStorage = $null; HubProvider = $provider } + } + + # Try to detect a FinOps Hub in the selected subscriptions + $hubStorage = $null + $discoveryErrors = [Collections.Generic.List[string]]::new() + Write-FinOpsConsole " 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 -DefaultProfile $discoveryContext -ErrorAction Stop + if ($result -and @($result).Count -gt 0) { + $hubStorage = $result[0] + break + } + } + catch { + $discoveryErrors.Add("$($sub.Name): $($_.Exception.Message)") + Write-FinOpsConsole " Hub discovery failed for $($sub.Name): $($_.Exception.Message)" -ForegroundColor Yellow + } + } + + if (-not $hubStorage -and $discoveryErrors.Count -gt 0) { + if ($Preselected -eq 'Hub') { + throw "FinOps hub discovery is incomplete: $($discoveryErrors -join '; '). Select API or GraphOnly explicitly, or configure FINOPS_HUB_KUSTO_URI." + } + Write-FinOpsConsole ' Hub discovery is incomplete. Continuing with the selected subscriptions only.' -ForegroundColor Yellow + } + + if ($Preselected) { + if ($Preselected -eq 'Hub' -and -not $hubStorage) { + throw 'No FinOps hub was found in the selected subscriptions. Configure FINOPS_HUB_KUSTO_URI or select API for a separate live scan.' + } + Write-FinOpsConsole " Data source set by parameter: $Preselected" -ForegroundColor DarkGray + return @{ Source = $Preselected; HubStorage = $hubStorage } + } + + if ($NonInteractive) { + $autoSource = if ($hubStorage) { 'Hub' } else { 'API' } + Write-FinOpsConsole " Non-interactive run. Using $autoSource." -ForegroundColor DarkGray + return @{ Source = $autoSource; HubStorage = $hubStorage } + } + + if ($hubStorage) { + Write-FinOpsConsole " FinOps Hub detected: " -ForegroundColor Green -NoNewline + Write-FinOpsConsole "$($hubStorage.name)" -ForegroundColor White -NoNewline + Write-FinOpsConsole " ($($hubStorage.resourceGroup))" -ForegroundColor DarkGray + Write-FinOpsConsole "" + Write-FinOpsConsole " [1] FinOps Hub" -ForegroundColor Green -NoNewline + Write-FinOpsConsole " - Pre-processed data from your Hub's ingestion pipeline" -ForegroundColor DarkGray + Write-FinOpsConsole " Faster, consistent, includes normalized/amortized costs" -ForegroundColor DarkGray + Write-FinOpsConsole "" + Write-FinOpsConsole ' [2] Cost Management exports (CSV storage)' -ForegroundColor Cyan + Write-FinOpsConsole ' Discover existing exports without requiring a FinOps Hub' -ForegroundColor DarkGray + Write-FinOpsConsole '' + Write-FinOpsConsole " [3] Cost Management API" -ForegroundColor Yellow -NoNewline + Write-FinOpsConsole " - Query Azure Cost Management REST APIs directly" -ForegroundColor DarkGray + Write-FinOpsConsole " Real-time, no Hub required, subject to API throttling" -ForegroundColor DarkGray + Write-FinOpsConsole " Best for smaller tenants or when no exports exist" -ForegroundColor DarkGray + Write-FinOpsConsole "" + Write-FinOpsConsole " [4] Resource Graph only" -ForegroundColor DarkGray -NoNewline + Write-FinOpsConsole " - Skip cost modules, run governance/optimization scans only" -ForegroundColor DarkGray + Write-FinOpsConsole "" + + $attempts = 0 + while ($true) { + $choice = Read-FinOpsAnswer ' Select [1/2/3/4]: ' + switch ($choice) { + '1' { + # The [1] Hub choice uses the scalable Kusto engine when the + # hub has an ADX/Fabric cluster (auto-discovered) or + # FINOPS_HUB_KUSTO_URI is set (ftklocal). If neither exists, + # cost scans fall back to the STORAGE READER, which loads cost + # rows into PowerShell. + $hubSubIds = @($Subscriptions | ForEach-Object { $_.Id }) + $prov = $null + try { $prov = Resolve-FOHubProvider -Subscriptions $hubSubIds } catch { + Write-FinOpsConsole " FinOps hub provider discovery failed: $($_.Exception.Message) Checking the detected Hub's storage reader instead." -ForegroundColor Yellow + } + if ($prov -and $prov.Found) { + # A scalable Kusto path exists - no warning needed. + return @{ Source = 'Hub'; HubStorage = $hubStorage; HubProvider = $prov } + } + + # Size the hub before judging the reader. An unmeasurable hub + # is treated as large, so a failed probe never downgrades the + # warning. + $hubSize = @{ Known = $false; Reachable = $true; IsLarge = $true; Display = 'unknown size'; Issue = $null } + if ($hubStorage -and $hubStorage.name) { + try { $hubSize = Measure-FinOpsHubSize -StorageAccountName $hubStorage.name } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + } + + if (-not $hubSize.Reachable) { + # Storage refused access, so the reader cannot run at all. + # Speed is not the problem here; reachability is. + Write-FinOpsConsole "" + Write-FinOpsConsole " This FinOps Hub's storage account is not reachable from here." -ForegroundColor Yellow + Write-FinOpsConsole " Hub cost scans need to read the ingestion container, so they will return nothing." -ForegroundColor DarkGray + Write-FinOpsConsole " Common causes: the storage firewall denies this network, public network access is" -ForegroundColor DarkGray + Write-FinOpsConsole " disabled, or the account is reachable only through a private endpoint." -ForegroundColor DarkGray + Write-FinOpsConsole "" + Write-FinOpsConsole " Use the live Cost Management API instead? " -ForegroundColor White -NoNewline + Write-FinOpsConsole "(N = continue with the Hub anyway)" -ForegroundColor DarkGray + $useApi = Read-FinOpsAnswer ' Select [Y/N]: ' + for ($attempt = 1; $useApi -notmatch '^(?i)(y|yes|n|no)$'; $attempt++) { + if ($Accessible -or $attempt -ge 3) { throw 'No valid data source was selected. No scan was started.' } + Write-FinOpsConsole ' Enter Y or N.' -ForegroundColor Yellow + $useApi = Read-FinOpsAnswer ' Select [Y/N]: ' + } + if ($useApi -match '^(?i)(y|yes)$') { + return @{ Source = 'API'; HubStorage = $hubStorage } + } + return @{ Source = 'Hub'; HubStorage = $hubStorage; HubProviderResolved = $true } + } + + if (-not $hubSize.IsLarge) { + # Small enough for the reader. Still name it the small-dataset + # path so it is never mistaken for the scalable engine. + Write-FinOpsConsole "" + Write-FinOpsConsole " Using the FinOps Hub storage reader (small-dataset path; $($hubSize.Display))." -ForegroundColor DarkGray + Write-FinOpsConsole " Larger hubs should query Kusto: deploy ADX/Fabric, or set FINOPS_HUB_KUSTO_URI (ftklocal)." -ForegroundColor DarkGray + return @{ Source = 'Hub'; HubStorage = $hubStorage; HubProviderResolved = $true } + } + + Write-FinOpsConsole "" + Write-FinOpsConsole " Note: no Kusto provider was selected for this FinOps Hub." -ForegroundColor Yellow + if ($hubSize.Known) { + Write-FinOpsConsole " Ingestion data measured at $($hubSize.Display)." -ForegroundColor Yellow + } + Write-FinOpsConsole " Cost scans will use the storage reader, which loads cost rows into" -ForegroundColor DarkGray + Write-FinOpsConsole " memory. On a large hub (tens of GB) this can be slow or run out of" -ForegroundColor DarkGray + Write-FinOpsConsole " memory before completing." -ForegroundColor DarkGray + Write-FinOpsConsole " For the scalable engine path: deploy ADX/Fabric on the hub, or set" -ForegroundColor DarkGray + Write-FinOpsConsole " FINOPS_HUB_KUSTO_URI to a local ftklocal emulator, then re-run." -ForegroundColor DarkGray + Write-FinOpsConsole "" + Write-FinOpsConsole " Switch to the live Cost Management API instead? " -ForegroundColor White -NoNewline + Write-FinOpsConsole "(N = continue with the storage reader)" -ForegroundColor DarkGray + $useApi = Read-FinOpsAnswer ' Select [Y/N]: ' + if ($Accessible -and $useApi -notmatch '^(?i)(y|yes|n|no)$') { throw 'No valid data source was selected. No scan was started.' } + if ($useApi -match '^(?i)(y|yes)$') { + return @{ Source = 'API'; HubStorage = $hubStorage } + } + return @{ Source = 'Hub'; HubStorage = $hubStorage; HubProviderResolved = $true } + } + '2' { return Select-ExportSource -TenantId $TenantId -Subscriptions $Subscriptions -OfferApiFallback } + '3' { return @{ Source = 'API'; HubStorage = $hubStorage } } + '4' { return @{ Source = 'GraphOnly'; HubStorage = $hubStorage } } + default { + $attempts++ + # A console that cannot take input returns empty forever, so only give + # up there. A real terminal keeps asking until it gets an answer. + if ($attempts -ge 3 -and -not (Test-FinOpsRichConsole)) { + if ($Accessible) { throw 'No valid data source was selected. No scan was started.' } + Write-FinOpsConsole " No valid selection. Using the FinOps Hub." -ForegroundColor Yellow + return @{ Source = 'Hub'; HubStorage = $hubStorage } + } + Write-FinOpsConsole " Invalid choice." -ForegroundColor Red + } + } + } + } + else { + if ($discoveryErrors.Count -gt 0) { + Write-FinOpsConsole " A FinOps Hub could not be verified in the selected subscriptions." -ForegroundColor Yellow + } + else { + Write-FinOpsConsole " No FinOps Hub found in selected subscriptions." -ForegroundColor DarkGray + } + Write-FinOpsConsole "" + Write-FinOpsConsole ' [1] Cost Management exports (CSV storage)' -ForegroundColor Cyan + Write-FinOpsConsole ' Discover existing exports without requiring a FinOps Hub' -ForegroundColor DarkGray + Write-FinOpsConsole '' + Write-FinOpsConsole " [2] Cost Management API" -ForegroundColor Yellow -NoNewline + Write-FinOpsConsole " - Query Azure Cost Management REST APIs directly" -ForegroundColor DarkGray + Write-FinOpsConsole " Real-time, subject to API throttling on large tenants" -ForegroundColor DarkGray + Write-FinOpsConsole " Best for smaller tenants or when no exports exist" -ForegroundColor DarkGray + Write-FinOpsConsole "" + Write-FinOpsConsole " [3] Resource Graph only" -ForegroundColor DarkGray -NoNewline + Write-FinOpsConsole " - Skip cost modules, run governance/optimization scans only" -ForegroundColor DarkGray + Write-FinOpsConsole "" + + $attempts = 0 + while ($true) { + $choice = Read-FinOpsAnswer ' Select [1/2/3]: ' + switch ($choice) { + '1' { return Select-ExportSource -TenantId $TenantId -Subscriptions $Subscriptions -OfferApiFallback } + '2' { return @{ Source = 'API'; HubStorage = $null } } + '3' { return @{ Source = 'GraphOnly'; HubStorage = $null } } + default { + $attempts++ + if ($attempts -ge 3 -and -not (Test-FinOpsRichConsole)) { + if ($Accessible) { throw 'No valid data source was selected. No scan was started.' } + Write-FinOpsConsole " No valid selection. Using the Cost Management API." -ForegroundColor Yellow + return @{ Source = 'API'; HubStorage = $null } + } + Write-FinOpsConsole " Invalid choice." -ForegroundColor Red + } + } + } + } + } + + # ===================================================================== + # SUBSCRIPTION PICKER + # ===================================================================== + function Select-Subscription { + param([string]$PreselectedId) + + Write-FinOpsConsole " Checking Azure connection..." -ForegroundColor DarkGray + $ctx = Get-AzContext -ErrorAction SilentlyContinue + if (-not $ctx) { + if ($NonInteractive) { throw 'NonInteractive requires an existing Azure context. Run Connect-AzAccount with the intended identity before starting the scan.' } + Write-FinOpsConsole " Not connected. Launching browser login..." -ForegroundColor Yellow + Connect-AzAccount | Out-Null + $ctx = Get-AzContext + } + if ([string]::IsNullOrWhiteSpace($ctx.Tenant.Id)) { + throw 'The current tenant could not be verified. Sign in to the intended tenant before starting the scan.' + } + Write-FinOpsConsole " Signed in as: $($ctx.Account.Id)" -ForegroundColor Green + Write-FinOpsConsole "" + + # -- Explicit scope: resolve before either picker ------------------ + # An explicit subscription must resolve in the current tenant. Never + # search other tenants or widen the scope when that lookup fails. + if ($PreselectedId) { + $sub = Get-AzSubscription -SubscriptionId $PreselectedId -TenantId $ctx.Tenant.Id -ErrorAction SilentlyContinue -WarningAction SilentlyContinue + if (-not $sub) { + throw "Subscription '$PreselectedId' could not be resolved in the current tenant '$($ctx.Tenant.Id)'. Refusing to widen the scan to all subscriptions. Sign in to the intended tenant before trying again." + } + if (@($sub).Count -ne 1 -or $sub.Id -ne $PreselectedId -or $sub.TenantId -ne $ctx.Tenant.Id) { + throw "Subscription '$PreselectedId' could not be verified in the current tenant. No context change or scan was started." + } + $null = Set-AzContext -SubscriptionId $sub.Id -TenantId $ctx.Tenant.Id -Scope Process -ErrorAction Stop -WarningAction SilentlyContinue + Write-FinOpsConsole " Using subscription: $($sub.Name)" -ForegroundColor Green + Write-FinOpsConsole " Tenant: $($sub.TenantId)" -ForegroundColor Green + Write-FinOpsConsole "" + return @($sub) + } + + # -- Tenant picker ------------------------------------------------ + $tenants = @(Get-AzTenant -ErrorAction SilentlyContinue) + if ($tenants.Count -gt 1 -and ($NonInteractive -or -not (Test-FinOpsRichConsole))) { + # The tenant picker is arrow-key only, so stay in the signed-in tenant + # until the user explicitly signs in to another one. + $currentTenant = (Get-AzContext -ErrorAction SilentlyContinue).Tenant.Id + Write-FinOpsConsole " Tenant: $currentTenant" -ForegroundColor Green + Write-FinOpsConsole " $($tenants.Count) tenants available. Sign in to the intended tenant before targeting another one." -ForegroundColor DarkGray + Write-FinOpsConsole "" + } + elseif ($tenants.Count -gt 1) { + Write-FinOpsConsole " $($tenants.Count) tenants available:" -ForegroundColor White + Write-FinOpsConsole "" + + $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 + Move-FinOpsCursorLine + 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-FinOpsConsole $tLine.PadRight($tWidth) -ForegroundColor $tColor + } + Write-FinOpsConsole "" + Write-FinOpsConsole " ↑↓ 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-FinOpsConsole "" + Write-FinOpsConsole " Switching to tenant: $($selectedTenant.Name)..." -ForegroundColor Yellow + Connect-AzAccount -TenantId $selectedTenant.TenantId | Out-Null + $ctx = Get-AzContext + Write-FinOpsConsole " Connected to: $($ctx.Tenant.Id)" -ForegroundColor Green + } + else { + Write-FinOpsConsole "" + Write-FinOpsConsole " Staying in current tenant." -ForegroundColor Green + } + break + } + 81 { + Write-FinOpsConsole "" + Write-FinOpsConsole " 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 + Move-FinOpsCursorLine -LinesUp $tLinesToClear + } + Write-FinOpsConsole "" + } + 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-FinOpsConsole " Tenant: $tLabel" -ForegroundColor Green + Write-FinOpsConsole "" + } + + # 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 + if ([string]::IsNullOrWhiteSpace($effectiveTenantId) -or $effectiveTenantId -ne $ctx.Tenant.Id) { + throw 'The selected tenant changed or could not be verified. No subscriptions were enumerated.' + } + $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-FinOpsConsole " Using only subscription: $($allSubs[0].Name)" -ForegroundColor Green + return $allSubs + } + + # Multi-sub picker + if ($NonInteractive) { + Write-FinOpsConsole " Non-interactive run. Scanning all $($allSubs.Count) subscriptions." -ForegroundColor Green + return $allSubs + } + + Write-FinOpsConsole " Found $($allSubs.Count) subscriptions. Select scope:" -ForegroundColor White + Write-FinOpsConsole "" + Write-FinOpsConsole " [A] All subscriptions" -ForegroundColor White + Write-FinOpsConsole " [S] Single subscription (pick from list)" -ForegroundColor White + Write-FinOpsConsole "" + $choice = Read-FinOpsAnswer ' Choice (A/S): ' + + if ($choice -match '^(?i)(a|all)$') { + Write-FinOpsConsole " Scanning all $($allSubs.Count) subscriptions" -ForegroundColor Green + return $allSubs + } + + if (-not (Test-FinOpsRichConsole)) { + Write-FinOpsConsole "" + for ($i = 0; $i -lt $allSubs.Count; $i++) { + Write-FinOpsConsole (" [{0}] {1}" -f ($i + 1), $allSubs[$i].Name) + } + Write-FinOpsConsole "" + $pick = Read-FinOpsAnswer ' Subscription number (blank = all): ' + if ($pick -eq '') { return $allSubs } + $pickIndex = 0 + if ([int]::TryParse($pick, [ref]$pickIndex) -and $pickIndex -ge 1 -and $pickIndex -le $allSubs.Count) { + Write-FinOpsConsole " Selected: $($allSubs[$pickIndex - 1].Name)" -ForegroundColor Green + return @($allSubs[$pickIndex - 1]) + } + # Cancel rather than fall through to every subscription; a mistyped number + # should not silently widen the scan to the whole tenant. + Write-FinOpsConsole " '$pick' is not one of the listed numbers. Cancelled." -ForegroundColor Yellow + return $null + } + + # 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 + Move-FinOpsCursorLine + + 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-FinOpsConsole $line.PadRight($width) -ForegroundColor $color + } + Write-FinOpsConsole "" + Write-FinOpsConsole " ↑↓ 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-FinOpsConsole "" + Write-FinOpsConsole " 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 + Move-FinOpsCursorLine -LinesUp $linesToClear + } + } + + # ===================================================================== + # SCAN MODULE PICKER (checkbox menu) + # ===================================================================== + # Numbered alternative to the checkbox menu for hosts that cannot render it. + function Select-ScanModulesLineMode { + param([array]$Modules) + + Write-FinOpsConsole "" + Write-FinOpsConsole " SELECT SCANS" -ForegroundColor White + Write-FinOpsConsole "" + for ($i = 0; $i -lt $Modules.Count; $i++) { + $mark = if ($Modules[$i].Selected) { 'x' } else { ' ' } + Write-FinOpsConsole (" [{0,2}] [{1}] {2} ({3})" -f ($i + 1), $mark, $Modules[$i].Name, $Modules[$i].Category) + } + Write-FinOpsConsole "" + Write-FinOpsConsole " Enter numbers separated by commas, 'all', or blank to keep the [x] defaults." -ForegroundColor DarkGray + $entry = Read-FinOpsAnswer ' Scans: ' + + if ($entry -eq '') { return $Modules } + if ($entry -match '^(?i)all$') { + foreach ($m in $Modules) { $m.Selected = $true } + return $Modules + } + + $picked = @() + $ignored = @() + foreach ($piece in ($entry -split ',')) { + $parsed = 0 + if ([int]::TryParse($piece.Trim(), [ref]$parsed) -and $parsed -ge 1 -and $parsed -le $Modules.Count) { + $picked += ($parsed - 1) + } + elseif ($piece.Trim() -ne '') { + $ignored += $piece.Trim() + } + } + if ($ignored.Count -gt 0) { + Write-FinOpsConsole " Ignored, not a listed number: $($ignored -join ', ')" -ForegroundColor Yellow + } + if ($picked.Count -eq 0) { + Write-FinOpsConsole " No valid numbers. Keeping the default selection." -ForegroundColor Yellow + return $Modules + } + for ($i = 0; $i -lt $Modules.Count; $i++) { $Modules[$i].Selected = ($i -in $picked) } + return $Modules + } + + function Select-ScanModules { + param([array]$Modules) + + # -Scans, or the defaults, already carry the selection when nothing can prompt. + if ($NonInteractive) { return $Modules } + if (-not (Test-FinOpsRichConsole)) { return (Select-ScanModulesLineMode -Modules $Modules) } + + $cursor = 0 + $categories = $Modules | ForEach-Object { $_.Category } | Select-Object -Unique + + while ($true) { + # Build display lines grouped by category + $lines = @() + $lineToIndex = @{} # map display line -> module index + + 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-FinOpsConsole "" + Write-FinOpsConsole " SELECT SCANS" -ForegroundColor White + Write-FinOpsConsole " ↑↓ Move │ Space = Toggle │ A = All │ N = None │ Enter = Run │ Q = Quit" -ForegroundColor DarkGray + Write-FinOpsConsole "" + + $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-FinOpsConsole $lines[$i] -ForegroundColor Yellow + } + else { + Write-FinOpsConsole $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-FinOpsConsole " $pointer $check $($mod.Name)" -ForegroundColor $color + } + } + + Write-FinOpsConsole "" + Write-FinOpsConsole " $selectedCount of $($Modules.Count) scans selected" -ForegroundColor DarkGray + Write-FinOpsConsole "" + + # 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-FinOpsConsole " 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, + [hashtable]$PermissionInfo = @{} + ) + + $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 + $hubScanErrors = @{} + + # Scalable Kusto path: when a FinOps Hub Kusto database is reachable + # (a FINOPS_HUB_KUSTO_URI override for an ftklocal emulator or a pinned + # 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 + $subIdsForDisco = @($Subscriptions | ForEach-Object { $_.Id }) + if ($DataSource.Source -eq 'Hub') { + $kp = $DataSource.HubProvider + if (-not $kp -and -not $DataSource.HubProviderResolved) { + try { $kp = Resolve-FOHubProvider -Subscriptions $subIdsForDisco } + catch { + if (-not $DataSource.HubStorage -or -not [string]::IsNullOrWhiteSpace($env:FINOPS_HUB_KUSTO_URI)) { throw } + Write-FinOpsConsole " FinOps hub provider discovery failed: $($_.Exception.Message) Using the selected Hub's storage reader." -ForegroundColor Yellow + } + } + if ($kp -and $kp.Found) { + $kustoProvider = $kp + $DataSource.HubProvider = $kp + } + } + + if ($DataSource.Source -eq 'Hub') { + $hubPermission = if ($kustoProvider -and $kustoProvider.Mode -eq 'KustoLocal') { + @{ Role = 'None (local emulator)'; Scope = 'Local Kusto endpoint'; API = 'Kusto query API'; Reason = 'Check that the local emulator is running and the configured database is available.' } + } + elseif ($kustoProvider) { + @{ Role = 'Database Viewer'; Scope = 'Kusto database'; API = 'Kusto query API'; Reason = 'Confirm database Viewer access or an equivalent role, and that the Kusto endpoint permits your connection.' } + } + else { + @{ Role = 'Storage Blob Data Reader'; Scope = 'Hub storage account or export container'; API = 'Azure Storage data API'; Reason = 'Confirm Storage Blob Data Reader or equivalent data access, and check the storage firewall or private endpoint connection. Subscription Reader alone does not grant storage data access.' } + } + foreach ($hubScan in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag')) { + $permissionInfo[$hubScan] = $hubPermission + } + } + + if ($kustoProvider) { + Write-FinOpsConsole "" + Write-FinOpsConsole " Querying FinOps Hub Kusto database ($($kustoProvider.Mode))..." -ForegroundColor Green + + # Scope every query to the selected subscriptions. Without this the + # hub returns every subscription it holds, contaminating a report + # the user asked to be scoped to one. + $hubErrors = [System.Collections.Generic.List[string]]::new() + $hubOk = 0 + + $cs = Get-FOHubCostSummary -Provider $kustoProvider -SubscriptionIds $subIdsForDisco + if ($cs -is [System.Collections.IDictionary] -and $cs.Contains('Error') -and $cs.Error) { $hubErrors.Add("cost summary: $($cs.Error)"); $hubScanErrors['Get-CostData'] = $cs.Error } + else { $hubCostData = $cs; $hubOk++ } + + $rc = Get-FOHubResourceCosts -Provider $kustoProvider -SubscriptionIds $subIdsForDisco + if ($rc -is [System.Collections.IDictionary] -and $rc.Contains('Error') -and $rc.Error) { $hubErrors.Add("resource costs: $($rc.Error)"); $hubScanErrors['Get-ResourceCosts'] = $rc.Error } + else { $hubResourceCosts = $rc; $hubOk++ } + + $ct = Get-FOHubCostByTag -Provider $kustoProvider -SubscriptionIds $subIdsForDisco + if ($ct -is [System.Collections.IDictionary] -and $ct.Contains('Error') -and $ct.Error) { $hubErrors.Add("cost by tag: $($ct.Error)"); $hubScanErrors['Get-CostByTag'] = $ct.Error } + else { $hubCostByTag = $ct; $hubOk++ } + + if ($hubOk -gt 0) { + Write-FinOpsConsole " Hub data summarized in-engine (no rows loaded). Forecast is not included; choose API source for live forecast." -ForegroundColor DarkGray + } + foreach ($e in $hubErrors) { + Write-FinOpsConsole " Hub query failed - $e" -ForegroundColor Yellow + } + if ($hubOk -eq 0) { + Write-FinOpsConsole ' Hub cost results are unavailable. Select API as the data source to run a separate live scan.' -ForegroundColor Yellow + } + } + elseif ($DataSource.Source -eq 'Hub' -and $DataSource.HubStorage) { + # Storage reader: small-dataset convenience path (rows loaded into + # PowerShell). For large hubs, the Kusto path above is preferred. + $hub = $DataSource.HubStorage + Write-FinOpsConsole "" + Write-FinOpsConsole " Loading cost data from FinOps Hub storage (small-dataset reader)..." -ForegroundColor Green + Write-FinOpsConsole " For large hubs, query the Kusto database instead (ADX/Fabric, or set FINOPS_HUB_KUSTO_URI for ftklocal)." -ForegroundColor DarkGray + try { + $hubRaw = Read-FinOpsHubData -StorageAccountName $hub.name -ResourceGroupName $hub.resourceGroup -Months 1 -SubscriptionIds $subIdsForDisco + } + catch { + Write-FinOpsConsole " Hub data load failed: $($_.Exception.Message)" -ForegroundColor Yellow + foreach ($scan in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-AIWorkloadMetrics')) { $hubScanErrors[$scan] = $_.Exception.Message } + $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-FinOpsConsole " Tag coverage corrected via Resource Graph: $argCoverage% ($argTagged/$argTotal)" -ForegroundColor DarkGray + } + } + } + } + catch { + Write-FinOpsConsole " Could not verify tag coverage via ARG: $($_.Exception.Message)" -ForegroundColor DarkGray + } + + try { + $hubCostData = ConvertTo-CostDataFromHub -HubData $hubRaw + } + catch { + $hubScanErrors['Get-CostData'] = $_.Exception.Message + $hubCostData = $null + } + try { + $hubResourceCosts = ConvertTo-ResourceCostsFromHub -HubData $hubRaw + } + catch { + $hubScanErrors['Get-ResourceCosts'] = $_.Exception.Message + $hubResourceCosts = $null + } + $currentMonth = (Get-Date).ToUniversalTime() + $currentMonth = $currentMonth.Date.AddDays(1 - $currentMonth.Day) + $forecastSubscriptions = @($Subscriptions | Where-Object { + $entry = if ($hubCostData) { $hubCostData[$_.Id] } else { $null } + $entry -and $null -ne $entry.ActualPeriodStart -and $null -ne $entry.ActualPeriodEnd -and + $entry.ActualPeriodStart -ge $currentMonth -and $entry.ActualPeriodEnd -lt $currentMonth.AddMonths(1) + }) + if ($hubCostData -and $forecastSubscriptions.Count -gt 0) { + try { + $liveCost = Get-CostData -TenantId $TenantId -Subscriptions $forecastSubscriptions -RestrictToSelected + foreach ($subId in $forecastSubscriptions.Id) { + $forecast = $liveCost[$subId] + if ($forecast -and $forecast.ForecastSource -eq 'Forecast' -and $forecast.Currency -eq $hubCostData[$subId].Currency) { + $hubCostData[$subId].Forecast = $forecast.Forecast + $hubCostData[$subId].ForecastSource = 'Cost Management API (current month)' + } + } + } + catch { Write-FinOpsConsole " Live forecast unavailable; hub actuals remain available. $($_.Exception.Message)" -ForegroundColor Yellow } + } + + Write-FinOpsConsole " Hub data loaded: $(@($hubRaw).Count) cost records, $($hubTagInventory.TagCount) tags, $($hubTagInventory.TagCoverage)% coverage" -ForegroundColor Green + } + else { + $hubRaw = $null + foreach ($scan in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-AIWorkloadMetrics')) { + if (-not $hubScanErrors.ContainsKey($scan)) { $hubScanErrors[$scan] = 'No hub data is available; cost coverage is incomplete.' } + } + Write-FinOpsConsole ' Hub data is unavailable. Select API as the data source to run a separate live scan.' -ForegroundColor Yellow + } + Write-FinOpsConsole "" + } + + $exportData = $null + $exportIssue = $null + $exportSubscriptions = @() + $exportCoverage = $null + $exportScans = @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-CostTrend') + if ($DataSource.Source -eq 'Export') { + $exportContext = Get-AzContext -ErrorAction Stop + if (-not $DataSource.Export -or $DataSource.TenantId -ne $TenantId -or $exportContext.Tenant.Id -ne $TenantId -or + -not $Subscriptions.Count -or @($Subscriptions | Where-Object { $_.TenantId -ne $TenantId -or [string]::IsNullOrWhiteSpace($_.Id) }).Count -gt 0) { + throw 'The selected tenant or subscriptions changed before the export read. No export data was read.' + } + foreach ($scan in $exportScans) { + $permissionInfo[$scan] = @{ Role = 'Storage Blob Data Reader'; Scope = 'Selected export container'; API = 'Azure Storage data API'; Reason = 'Export scans require the selected destination to be readable. Failed reads do not switch to live Cost Management queries.' } + } + try { + $rawExport = Get-CostExportData -Export $DataSource.Export -Environment $DataSource.Environment + $exportData = Select-CostExportData -ExportData $rawExport -Subscriptions $Subscriptions -SkipCoverageCheck + if (-not $exportData.Rows.Count) { throw 'No cost rows match the selected subscriptions.' } + $exportSubscriptions = @($Subscriptions | Where-Object { $_.Id -in $exportData.CoveredSubscriptionIds }) + $missingIds = @($Subscriptions.Id | Where-Object { $_ -notin $exportData.CoveredSubscriptionIds }) + $unattributedNote = '' + if ($exportData.UnattributedRowCount -gt 0) { + $rowLabel = if ($exportData.UnattributedRowCount -eq 1) { 'row' } else { 'rows' } + $amounts = @($exportData.UnattributedCost | ForEach-Object { Format-BudgetAmount -Value $_.Cost -Currency $_.Currency }) -join '; ' + $unattributedNote = " Not included in these subscription totals: $($exportData.UnattributedRowCount) export $rowLabel with no subscription ($amounts), such as purchases or refunds billed outside a subscription." + } + $exportCoverage = [pscustomobject]@{ + Name = $DataSource.Export.Name; CoverageIncomplete = ($missingIds.Count -gt 0) + CoveredSubscriptionIds = @($exportData.CoveredSubscriptionIds); UnverifiedSubscriptionIds = $missingIds + TotalSubs = $Subscriptions.Count; ScannedSubs = $exportSubscriptions.Count + UnattributedRowCount = $exportData.UnattributedRowCount; UnattributedCost = @($exportData.UnattributedCost) + ActualPeriod = $exportData.ActualPeriod; DataDate = $exportData.DataDate + Note = "Export rows cover $($exportSubscriptions.Count) of $($Subscriptions.Count) selected subscriptions. Subscriptions without returned rows are unverified, not zero cost. Export period: $($exportData.ActualPeriod).$unattributedNote" + } + $results['_source_Export'] = $exportCoverage + $DataSource.CoverageNote = $exportCoverage.Note + Write-FinOpsConsole " Export loaded: $($exportData.RowCount) selected-scope rows; period $($exportData.ActualPeriod)." -ForegroundColor Green + Write-FinOpsConsole " $($exportCoverage.Note)" -ForegroundColor $(if ($missingIds.Count -or $exportData.UnattributedRowCount) { 'Yellow' } else { 'DarkGray' }) + } + catch { $exportIssue = "Selected export data is unavailable or incomplete: $($_.Exception.Message)" } + } + + $srcLabel = switch ($DataSource.Source) { + 'Hub' { if ($kustoProvider) { "FinOps Hub ($($kustoProvider.ClusterUri), $($kustoProvider.Database))" } else { "FinOps Hub ($($DataSource.HubStorage.name))" } } + 'Export' { "Cost Management export ($($DataSource.Export.Name); selected subscriptions only)" } + 'API' { "Cost Management API (real-time)" } + 'GraphOnly' { "Resource Graph only" } + } + Write-SectionHeader "RUNNING $total SCANS" + $srcColor = switch ($DataSource.Source) { 'Hub' { 'Green' } 'Export' { 'Cyan' } 'API' { 'Yellow' } 'GraphOnly' { 'DarkGray' } } + Write-FinOpsConsole " $srcLabel" -ForegroundColor $srcColor + Write-FinOpsConsole "" + + foreach ($mod in $selected) { + $current++ + $pct = [math]::Round(($current / $total) * 100) + $bar = ('█' * [math]::Floor($pct / 5)).PadRight(20, '░') + + Write-FinOpsConsole " [$bar] $pct% ($current/$total) $($mod.Name)" -ForegroundColor White + + $sw = [System.Diagnostics.Stopwatch]::StartNew() + try { + $fn = $mod.Fn + $output = $null + if ($hubScanErrors.ContainsKey($fn)) { throw $hubScanErrors[$fn] } + if ($DataSource.Source -eq 'Export' -and $fn -in @('Get-AIWorkloadMetrics', 'Get-UnitEconomics', 'Get-SavingsRealized', 'Get-BudgetHistory', 'Get-BudgetStatus', 'Get-CommitmentUtilization', 'Get-ReservationAdvice', 'Get-AnomalyAlerts', 'Get-BillingStructure', 'Get-ContractInfo', 'Get-MaccCommitment', 'Get-VmCostBreakdown', 'Get-SharedCostAllocation', 'Get-UsageProportionalAllocation')) { + throw "$($mod.Name) is not supported by the selected CSV export source. Select API or a supported Hub source explicitly for a separate scan; no live cost fallback was attempted." + } + + # Route parameters based on what each function expects + # Hub shortcut: return pre-loaded Hub data for cost/tag modules + switch ($fn) { + { $DataSource.Source -eq 'Export' -and $_ -in $exportScans } { + if ($exportIssue) { throw $exportIssue } + switch ($fn) { + 'Get-CostData' { + $output = ConvertTo-CostDataFromExport -ExportData $exportData -Subscriptions $exportSubscriptions + foreach ($entry in $output.Values) { $entry.CoverageIncomplete = $exportCoverage.CoverageIncomplete; $entry.Note = $exportCoverage.Note; $entry.CostBasis = 'ActualCost' } + } + 'Get-ResourceCosts' { + $output = @(ConvertTo-ResourceCostsFromExport -ExportData $exportData -Subscriptions $exportSubscriptions) + foreach ($row in $output) { $row | Add-Member -NotePropertyName CoverageIncomplete -NotePropertyValue $exportCoverage.CoverageIncomplete; $row | Add-Member -NotePropertyName Note -NotePropertyValue $exportCoverage.Note } + } + 'Get-CostByTag' { $output = ConvertTo-CostByTagFromExport -ExportData $exportData -Subscriptions $exportSubscriptions } + 'Get-CostTrend' { + $output = ConvertTo-CostTrendFromExport -ExportData $exportData -Subscriptions $exportSubscriptions + $output | Add-Member -NotePropertyMembers @{ SelectedSubscriptionCount = $Subscriptions.Count; SubscriptionsWithData = $exportSubscriptions.Count; UnverifiedSubscriptionIds = $exportCoverage.UnverifiedSubscriptionIds; NoDataSubscriptionIds = @(); CostBasis = 'ActualCost'; QueryScope = "Selected export: $($DataSource.Export.Name)" } + } + } + if ($fn -in @('Get-CostByTag', 'Get-CostTrend')) { + $output | Add-Member -NotePropertyMembers @{ CoverageIncomplete = $exportCoverage.CoverageIncomplete; Note = $exportCoverage.Note; ActualPeriod = $exportData.ActualPeriod; ExportDataDate = $exportData.DataDate; Source = 'Export' } + } + break + } + { $_ -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' { + $tagInventory = if ($results.ContainsKey('Get-TagInventory')) { $results['Get-TagInventory'] } else { $hubTagInventory } + if ($results.ContainsKey('_error_Get-TagInventory') -or -not $tagInventory -or $tagInventory.CoverageIncomplete) { + throw 'Tag recommendations are unavailable because tag inventory is incomplete. No tags are assumed missing.' + } + $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' { + if ($results.ContainsKey('_error_Get-PolicyInventory') -or -not $results.ContainsKey('Get-PolicyInventory') -or + $results['Get-PolicyInventory'].CoverageIncomplete) { + throw 'Policy recommendations are unavailable because the policy inventory did not complete. No policies are assumed missing.' + } + # Keep an empty array from becoming null at parameter binding. + $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 } + if ($results.ContainsKey('_error_Get-BudgetStatus') -or -not $budgetResult) { + throw "Budget history is unavailable because the budget inventory failed. $($results['_error_Get-BudgetStatus'])" + } + $budgetRows = if ($budgetResult -and $budgetResult.Budgets) { @($budgetResult.Budgets) } else { @() } + if ($budgetResult.CoverageIncomplete -and $budgetRows.Count -eq 0) { + throw "Budget history is unavailable because the budget inventory is incomplete. $($budgetResult.Note)" + } + 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 + if ($budgetResult.CoverageIncomplete) { + $coverageNote = "Budget history covers only the available budget definitions. $($budgetResult.Note)".TrimEnd() + if (-not $output) { throw "Budget history is unavailable because the budget inventory is incomplete. $($budgetResult.Note)" } + foreach ($historyRow in @($output)) { + $historyNote = (@($historyRow.Note, $coverageNote) | Where-Object { $_ }) -join ' ' + $historyRow | Add-Member -NotePropertyMembers @{ CoverageIncomplete = $true; Note = $historyNote } -Force + } + } + } + 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 } { + if ($results.ContainsKey('_error_Get-TagInventory') -or $results['Get-TagInventory'].CoverageIncomplete) { + throw 'Cost by tag is unavailable because the tag inventory is incomplete.' + } + # 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 (-not $hubRaw -or @($hubRaw).Count -eq 0)) { + throw 'AI metrics are unavailable for the selected Kusto hub source. Select API as the data source for a separate live scan.' + } + if ($DataSource.Source -eq 'Hub' -and $hubRaw -and @($hubRaw).Count -gt 0) { + $aiParams['HubData'] = $hubRaw + } + $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 + } + # The user picked a subscription set, so management-group + # scope queries must be filtered back down to it. + if ($cmdInfo -and $cmdInfo.Parameters.ContainsKey('RestrictToSelected')) { + $params['RestrictToSelected'] = $true + } + if ($fn -eq 'Get-OrphanedResources' -and $DataSource.Source -in @('GraphOnly', 'Export')) { $params.SkipCost = $true } + if ($fn -eq 'Get-ResourceCosts' -and $results['Get-CostData'] -is [hashtable]) { $params.CostData = $results['Get-CostData'] } + $output = & $fn @params + } + } + + $sw.Stop() + $count = if ($output) { @($output).Count } else { 0 } + $results[$fn] = $output + + Write-FinOpsConsole " Completed: $($mod.Name) - $count results ($([math]::Round($sw.Elapsed.TotalSeconds, 1))s)" -ForegroundColor Green + } + catch { + $sw.Stop() + Write-FinOpsConsole " FAILED: $($mod.Name)" -ForegroundColor Red + Write-FinOpsConsole " $($_.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-FinOpsConsole "" + Write-FinOpsConsole " $line" -ForegroundColor $Color + Write-FinOpsConsole " $Title" -ForegroundColor $Color + Write-FinOpsConsole " $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-FinOpsConsole $part -ForegroundColor $MoneyColor -NoNewline + } + else { + Write-FinOpsConsole $part -ForegroundColor $DefaultColor -NoNewline + } + } + Write-FinOpsConsole "" + } + 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-FinOpsConsole " [!] ACCESS DENIED$what (the API returned access denied, not empty results)." -ForegroundColor Red + if ($pInfo) { + Write-FinOpsConsole " Required role: $($pInfo.Role)" -ForegroundColor Yellow + Write-FinOpsConsole " Scope: $($pInfo.Scope)" -ForegroundColor Yellow + Write-FinOpsConsole " API: $($pInfo.API)" -ForegroundColor DarkGray + Write-FinOpsConsole " $($pInfo.Reason)" -ForegroundColor DarkGray + Write-FinOpsConsole " Ask a billing or subscription admin to assign the matching role, then re-scan." -ForegroundColor DarkGray + } + } + + # A cell opening with =, +, -, @, tab, or CR is treated as a formula by + # spreadsheet apps, and resource names, tags, and policy display names are + # all controlled by whoever created the resource. + function Protect-FinOpsExportText { + param([string]$Text) + if ($Text -match '^(?:[\s\p{Cf}]*[=+\-@]|[\t\r\n])') { return "'" + $Text } + return $Text + } + + # CSV cells must be scalars. Anything else lands as "System.Collections.Hashtable" + # or "System.Object[]" in the file. + function ConvertTo-FinOpsExportCell { + param($Value) + + if ($null -eq $Value) { return '' } + # Numbers, booleans, and dates carry no formula risk, and prefixing one + # would stop a negative cost being read as a number. + if ($Value -is [datetime] -or $Value -is [datetimeoffset]) { + return $Value.ToString('o', [System.Globalization.CultureInfo]::InvariantCulture) + } + if ($Value -is [ValueType]) { + return [System.Convert]::ToString($Value, [System.Globalization.CultureInfo]::InvariantCulture) + } + if ($Value -is [string]) { return Protect-FinOpsExportText $Value } + return Protect-FinOpsExportText (ConvertTo-Json -InputObject $Value -Depth 30 -Compress -ErrorAction Stop) + } + + # Scan results are wrapper objects whose payload is a nested collection or a + # hashtable keyed by subscription. Exporting the wrapper directly produces + # object-type cells, and for the cost contracts it turns subscription IDs + # into columns. Project each result to flat rows before writing CSV. + function ConvertTo-FinOpsExportRows { + param( + [string]$Fn, + $Data + ) + + if ($null -eq $Data) { return @() } + + $rows = $null + $payloadsByScan = @{ + 'Get-AHBOpportunities' = @('WindowsVMs', 'SQLVMs', 'SQLDatabases') + 'Get-AIWorkloadMetrics' = @('ByModel', 'ByAccount') + 'Get-AnomalyAlerts' = @('TriggeredAlerts', 'ConfiguredRules') + 'Get-BillingAccount' = @('Accounts') + 'Get-BillingStructure' = @('BillingAccounts', 'BillingProfiles', 'InvoiceSections', 'EADepartments', 'CostAllocationRules') + 'Get-BudgetStatus' = @('Budgets') + 'Get-CarbonMetrics' = @('MonthlyTrend', 'BySubscription') + 'Get-CommitmentUtilization' = @('Reservations', 'SavingsPlans') + 'Get-CostByTag' = @('CostByTag') + 'Get-CostTrend' = @('Months', 'BySubscription') + 'Get-IdleVMs' = @('IdleVMs') + 'Get-LegacyResources' = @('LegacyResources') + 'Get-MaccCommitment' = @('Commitments') + 'Get-OptimizationAdvice' = @('Recommendations') + 'Get-OrphanedResources' = @('Orphans') + 'Get-PolicyInventory' = @('Assignments', 'ComplianceBySubMap') + 'Get-PolicyRecommendations' = @('Analysis') + 'Get-ReservationAdvice' = @('AdvisorRecommendations', 'ReservationRecommendations') + 'Get-SavingsRealized' = @('Details') + 'Get-SharedCostAllocation' = @('Allocations', 'RuleTargets') + 'Get-StorageTierAdvice' = @('Recommendations') + 'Get-TagInventory' = @('TagNames', 'CaseVariants', 'UntaggedResources') + 'Get-TagRecommendations' = @('Analysis') + 'Get-UsageProportionalAllocation' = @('Allocations', 'RuleTargets') + 'Get-VmCostBreakdown' = @('Breakdown') + } + + $metadata = [ordered]@{} + $summaryCollections = [ordered]@{} + if ($payloadsByScan.ContainsKey($Fn)) { + $payloadNames = $payloadsByScan[$Fn] + if ($Data -is [System.Collections.IDictionary]) { + foreach ($field in $Data.GetEnumerator()) { + if ($field.Key -notin $payloadNames) { $metadata["Summary.$($field.Key)"] = $field.Value } + } + } + else { + foreach ($property in $Data.PSObject.Properties) { + if ($property.Name -notin $payloadNames) { $metadata["Summary.$($property.Name)"] = $property.Value } + } + } + foreach ($name in @($metadata.Keys)) { + $value = $metadata[$name] + if ($null -ne $value -and $value -isnot [string] -and $value -isnot [ValueType]) { + $summaryCollections[$name] = $value + $metadata.Remove($name) + } + } + } + + # Contracts whose payload is not a plain collection. + if ($Fn -eq 'Get-CostData' -and $Data -is [System.Collections.IDictionary]) { + $rows = @($Data.GetEnumerator() | ForEach-Object { + $record = [ordered]@{ SubscriptionId = $_.Key } + foreach ($field in $_.Value.GetEnumerator()) { $record[$field.Key] = $field.Value } + [PSCustomObject]$record + }) + } + elseif ($payloadsByScan.ContainsKey($Fn)) { + $rows = @( + if ($Fn -eq 'Get-CostByTag' -and $Data.CostByTag) { + foreach ($tag in $Data.CostByTag.GetEnumerator()) { + foreach ($entry in @($tag.Value)) { + $record = [ordered]@{ + RecordType = 'CostByTag' + TagKey = $tag.Key + TagValue = $entry.TagValue + Cost = $entry.Cost + Currency = $entry.Currency + } + foreach ($field in $metadata.GetEnumerator()) { $record[$field.Key] = $field.Value } + [PSCustomObject]$record + } + } + } + foreach ($collection in @($payloadNames | Where-Object { $_ -ne 'CostByTag' }) + @($summaryCollections.Keys)) { + $payload = if ($summaryCollections.Contains($collection)) { $summaryCollections[$collection] } else { $Data.$collection } + $entries = if ($payload -is [System.Collections.IDictionary]) { + foreach ($group in $payload.GetEnumerator()) { + foreach ($entry in @($group.Value | Where-Object { $null -ne $_ })) { + $record = [ordered]@{ Key = $group.Key } + if ($collection -eq 'BySubscription') { $record = [ordered]@{ SubscriptionId = $group.Key } } + elseif ($collection -eq 'TagNames') { $record = [ordered]@{ TagKey = $group.Key } } + if ($entry -is [System.Collections.IDictionary]) { + foreach ($field in $entry.GetEnumerator()) { $record[$field.Key] = $field.Value } + } + elseif ($entry -is [string] -or $entry -is [ValueType]) { $record['Value'] = $entry } + else { foreach ($property in $entry.PSObject.Properties) { $record[$property.Name] = $property.Value } } + [PSCustomObject]$record + } + } + } + else { @($payload | Where-Object { $null -ne $_ }) } + foreach ($entry in $entries) { + $record = [ordered]@{ RecordType = $collection } + if ($entry -is [System.Collections.IDictionary]) { + foreach ($field in $entry.GetEnumerator()) { $record[$field.Key] = $field.Value } + } + elseif ($entry -is [string] -or $entry -is [ValueType]) { $record['Value'] = $entry } + else { foreach ($property in $entry.PSObject.Properties) { $record[$property.Name] = $property.Value } } + foreach ($field in $metadata.GetEnumerator()) { $record[$field.Key] = $field.Value } + [PSCustomObject]$record + } + }) + } + elseif ($Data -is [System.Collections.IDictionary]) { + $rows = @($Data.GetEnumerator() | ForEach-Object { + [PSCustomObject]@{ Key = $_.Key; Value = $_.Value } + }) + } + elseif ($Data -is [System.Collections.IEnumerable] -and $Data -isnot [string]) { + $rows = @($Data) + } + else { + $rows = @($Data) + } + + if ($payloadsByScan.ContainsKey($Fn)) { + $primaryRows = @($rows | Where-Object { $_.RecordType -in $payloadNames }) + if ($primaryRows.Count -eq 0) { + $record = [ordered]@{ + RecordType = 'Status' + Scan = $Fn + Status = if ($Data.AccessDenied -or $Data.Error) { 'Error' } else { 'No data' } + Error = if ($Data.Error) { $Data.Error } elseif ($Data.AccessDenied) { $Data.Note } else { $null } + } + foreach ($field in $metadata.GetEnumerator()) { $record[$field.Key] = $field.Value } + $rows = @([PSCustomObject]$record) + @($rows) + } + } + + # Whatever projection was chosen, guarantee scalar cells. + $flatRows = @($rows | Where-Object { $null -ne $_ } | ForEach-Object { + $row = $_ + if ($row -is [System.Collections.IDictionary]) { + $ordered = [ordered]@{} + foreach ($k in $row.Keys) { $ordered[[string]$k] = ConvertTo-FinOpsExportCell $row[$k] } + [PSCustomObject]$ordered + } + elseif ($row.PSObject.Properties.Count -gt 0 -and $row -isnot [string] -and $row -isnot [ValueType]) { + $ordered = [ordered]@{} + foreach ($p in $row.PSObject.Properties) { $ordered[$p.Name] = ConvertTo-FinOpsExportCell $p.Value } + [PSCustomObject]$ordered + } + else { + [PSCustomObject]@{ Value = ConvertTo-FinOpsExportCell $row } + } + }) + $columnNames = [System.Collections.Generic.List[string]]::new() + $seenColumns = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($row in $flatRows) { + foreach ($property in $row.PSObject.Properties) { + if ((Protect-FinOpsExportText $property.Name) -cne $property.Name) { throw 'CSV column header contains an unsafe formula prefix.' } + if ($seenColumns.Add($property.Name)) { [void]$columnNames.Add($property.Name) } + } + } + if ($flatRows.Count -eq 0) { return @() } + return @($flatRows | Select-Object -Property $columnNames.ToArray()) + } + + function Get-FinOpsReportRoot { + $localData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData, [Environment+SpecialFolderOption]::DoNotVerify) + if ([string]::IsNullOrWhiteSpace($localData)) { + throw 'Local application data is unavailable. Specify a local OutputPath outside any Git repository.' + } + return (Join-Path $localData 'FinOpsToolkit/Multitool/Reports') + } + + function Assert-FinOpsReportPath { + param([Parameter(Mandatory)][string]$Path) + + if ($Path -match '^[\\/]{2}|::|[\x00-\x1f]' -or ($Path.Contains(':') -and $Path -notmatch '^[A-Za-z]:[\\/][^:]*$')) { + throw 'Reports require a local filesystem path, not a network, device, or provider path.' + } + $provider = $null + $drive = $null + $fullPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path, [ref]$provider, [ref]$drive) + if ($provider.Name -ne 'FileSystem' -or $fullPath -match '^[\\/]{2}') { + throw 'Reports require a local filesystem path.' + } + $fullPath = [System.IO.Path]::GetFullPath($fullPath) + if ($IsWindows -and ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($fullPath))).DriveType -eq [System.IO.DriveType]::Network) { + throw 'Reports require a local drive, not a mapped network drive.' + } + $ancestor = $fullPath + while ($ancestor) { + if ([System.IO.Path]::GetFileName($ancestor) -ieq '.git') { + throw 'Reports cannot be saved in a Git metadata directory.' + } + try { + $attributes = [System.IO.File]::GetAttributes($ancestor) + if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Report paths cannot contain symbolic links or junctions.' + } + if (($attributes -band [System.IO.FileAttributes]::Directory) -eq 0) { + throw 'The report destination must be a local directory.' + } + $gitMarker = Join-Path $ancestor '.git' + $hasGitMarker = $false + try { + $null = [System.IO.File]::GetAttributes($gitMarker) + $hasGitMarker = $true + } + catch [System.IO.FileNotFoundException] { $hasGitMarker = $false } + catch [System.IO.DirectoryNotFoundException] { $hasGitMarker = $false } + if ($hasGitMarker -or ([System.IO.File]::Exists((Join-Path $ancestor 'HEAD')) -and [System.IO.Directory]::Exists((Join-Path $ancestor 'objects')))) { + throw 'Reports cannot be saved inside a Git repository or worktree. Choose a different local OutputPath.' + } + } + catch [System.IO.FileNotFoundException] { Write-Verbose "The report path '$ancestor' does not exist yet." } + catch [System.IO.DirectoryNotFoundException] { Write-Verbose "The report path '$ancestor' does not exist yet." } + $ancestor = [System.IO.Path]::GetDirectoryName($ancestor) + } + return $fullPath + } + + function New-FinOpsReportDirectory { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates only a new private report directory for the requested scan.')] + [CmdletBinding()] + param([string]$OutputPath) + + $basePath = if ([string]::IsNullOrWhiteSpace($OutputPath)) { Get-FinOpsReportRoot } else { $OutputPath } + $basePath = Assert-FinOpsReportPath -Path $basePath + [void][System.IO.Directory]::CreateDirectory($basePath) + $runName = '{0}-{1}' -f [datetime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ', [cultureinfo]::InvariantCulture), [guid]::NewGuid().ToString('N') + $runPath = Assert-FinOpsReportPath -Path (Join-Path $basePath $runName) + if (Test-Path -LiteralPath $runPath) { throw 'The report run directory already exists. No files were written.' } + + if ($IsWindows) { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + try { + $security = [System.Security.AccessControl.DirectorySecurity]::new() + $security.SetAccessRuleProtection($true, $false) + $security.SetOwner($identity.User) + $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $security.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + $identity.User, [System.Security.AccessControl.FileSystemRights]::FullControl, + $inheritance, [System.Security.AccessControl.PropagationFlags]::None, [System.Security.AccessControl.AccessControlType]::Allow)) + [System.IO.FileSystemAclExtensions]::Create([System.IO.DirectoryInfo]::new($runPath), $security) + } + finally { $identity.Dispose() } + } + else { + $unixModeType = 'System.IO.UnixFileMode' -as [type] + if ($unixModeType) { + [void][System.IO.Directory]::CreateDirectory($runPath, [Enum]::ToObject($unixModeType, 448)) + } + else { + $mkdir = Get-Command -Name mkdir -CommandType Application -ErrorAction Stop + & $mkdir.Source -m 700 $runPath + if ($LASTEXITCODE -ne 0) { throw 'Could not create a private report directory.' } + } + } + $runPath = Assert-FinOpsReportPath -Path $runPath + Write-FinOpsReportFile -Directory $runPath -Name '.gitignore' -Lines @('*') + return $runPath + } + + function Write-FinOpsReportFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Directory, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][AllowEmptyCollection()][AllowEmptyString()][string[]]$Lines + ) + + $Directory = Assert-FinOpsReportPath -Path $Directory + if ($Name -notmatch '^(?:[A-Za-z0-9][A-Za-z0-9._-]*|\.gitignore)$') { throw 'Invalid report file name.' } + $path = Join-Path $Directory $Name + $stream = [System.IO.FileStream]::new($path, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) + try { + if (-not $IsWindows) { + $unixModeType = 'System.IO.UnixFileMode' -as [type] + if ($unixModeType) { [System.IO.File]::SetUnixFileMode($path, [Enum]::ToObject($unixModeType, 384)) } + else { + $chmod = Get-Command -Name chmod -CommandType Application -ErrorAction Stop + & $chmod.Source 600 $path + if ($LASTEXITCODE -ne 0) { throw 'Could not restrict report file permissions.' } + } + } + $writer = [System.IO.StreamWriter]::new($stream, [System.Text.UTF8Encoding]::new($false)) + try { foreach ($line in $Lines) { $writer.WriteLine($line) } } + finally { $writer.Dispose() } + } + finally { $stream.Dispose() } + } + + function Show-ResultsSummary { + param( + [hashtable]$Results, + [array]$Modules, + [string]$ExportPath, + [array]$Subscriptions, + + [string]$DataSourceLabel + ) + + function Format-ReportMetric { + param($Value, [string]$Format = 'N0', [string]$Suffix = '') + $number = 0.0 + if ($null -eq $Value -or -not [double]::TryParse([Convert]::ToString($Value, [cultureinfo]::InvariantCulture), [Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$number) -or + [double]::IsNaN($number) -or [double]::IsInfinity($number)) { return 'Unavailable' } + $number.ToString($Format, [cultureinfo]::InvariantCulture) + $Suffix + } + + function ConvertTo-ReportTableControlHtml { + param([string]$TableId, [string]$Title, [int]$RowCount) + + if ($RowCount -le 25) { return '' } + $filterId = [System.Net.WebUtility]::HtmlEncode(($TableId -replace '^table-', 'filter-')) + $encodedId = [System.Net.WebUtility]::HtmlEncode($TableId) + $encodedTitle = [System.Net.WebUtility]::HtmlEncode($Title) + return "
$RowCount rows
" + } + + function ConvertTo-ResourceIdentityHtml { + param([object]$Resource) + + $label = if ($Resource.ResourceName) { [string]$Resource.ResourceName } + elseif ($Resource.ResourcePath) { [string]$Resource.ResourcePath } + else { 'No resource ID recorded' } + $html = [System.Net.WebUtility]::HtmlEncode($label) + if ($Resource.ResourceName -and $Resource.ResourcePath -and $Resource.ResourceName -ne $Resource.ResourcePath) { + $html += "
Resource ID
$([System.Net.WebUtility]::HtmlEncode([string]$Resource.ResourcePath))
" + } + return $html + } + + function ConvertTo-PolicyScopeHtml { + param([object]$Assignment) + + $scopeId = [string]$Assignment.Scope + $label = if ($Assignment.ScopeDisplayName) { [string]$Assignment.ScopeDisplayName } + elseif ($scopeId -match '^/subscriptions/([^/]+)$' -and $subNameLookup.ContainsKey($Matches[1])) { [string]$subNameLookup[$Matches[1]] } + elseif ($scopeId) { $scopeId } + else { 'Scope not recorded' } + $html = [System.Net.WebUtility]::HtmlEncode($label) + if ($scopeId -and $label -ne $scopeId) { + $html += "
Scope ID
$([System.Net.WebUtility]::HtmlEncode($scopeId))
" + } + return $html + } + + # 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-FinOpsConsole "" + + $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-FinOpsConsole " $icon $($mod.Name.PadRight(30)) $suffix" -ForegroundColor $color + } + + Write-FinOpsConsole "" + Write-FinOpsConsole " Total findings: $totalFindings" -ForegroundColor White + Write-FinOpsConsole "" + + # -- Display results per module ------------------------------------ + # Guidance is built per scan during this pass; the HTML report is written + # later, so keep it here rather than recomputing the whole switch. + $guidanceByFn = @{} + # KPIs are collected across every scan so the report can retell them by + # FinOps domain rather than scattered under the scan that produced them. + $kpiCollected = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($mod in ($Modules | Where-Object { $_.Selected })) { + $data = $Results[$mod.Fn] + if (-not $data -or @($data).Count -eq 0) { + # 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-FinOpsConsole " [!] ACCESS DENIED" -ForegroundColor Red + Write-FinOpsConsole " $errorMsg" -ForegroundColor DarkGray + Write-FinOpsConsole "" + Write-FinOpsConsole " Required role: $($pInfo.Role)" -ForegroundColor Yellow + Write-FinOpsConsole " Scope: $($pInfo.Scope)" -ForegroundColor Yellow + Write-FinOpsConsole " API: $($pInfo.API)" -ForegroundColor DarkGray + Write-FinOpsConsole " $($pInfo.Reason)" -ForegroundColor DarkGray + } + else { + Write-FinOpsConsole " [!] ERROR: $errorMsg" -ForegroundColor Red + if ($pInfo) { + Write-FinOpsConsole " If this is a permissions issue:" -ForegroundColor DarkGray + Write-FinOpsConsole " Required role: $($pInfo.Role) at $($pInfo.Scope) scope" -ForegroundColor DarkGray + } + } + } + else { + # No error but no data — could be legitimately empty + Write-FinOpsConsole " No data returned." -ForegroundColor DarkGray + if ($pInfo) { + Write-FinOpsConsole " Possible reasons:" -ForegroundColor DarkGray + Write-FinOpsConsole " - $($pInfo.Reason)" -ForegroundColor DarkGray + Write-FinOpsConsole " - Required role: $($pInfo.Role) at $($pInfo.Scope) scope" -ForegroundColor DarkGray + } + } + Write-FinOpsConsole "" + continue + } + + Write-SectionHeader $mod.Name + + # Extract the displayable rows and columns per module + $rows = $null + $cols = $null + + switch ($mod.Fn) { + 'Get-OrphanedResources' { + if ($data.MonthlyCost) { + Write-FinOpsConsole " Observed cost ($($data.CostPeriod)): $(Format-BudgetAmount -Value $data.MonthlyCost -Currency $data.Currency) across $($data.CostedCount) of $($data.TotalCount) resources" -ForegroundColor White + } + if ($data.CostIssue) { + Write-FinOpsConsole " Cost column incomplete - $($data.CostIssue)" -ForegroundColor Yellow + } + # 'n/a' when the lookup failed, '-' when it succeeded and the resource simply had no spend. + $noCost = if ($data.CostAvailable) { '-' } else { 'n/a' } + $costCol = if ($data.CostPeriod) { [string]$data.CostPeriod } else { 'Cost' } + $rows = $data.Orphans | ForEach-Object { + $o = [ordered]@{ + Category = $_.Category + ResourceName = $_.ResourceName + ResourceGroup = $_.ResourceGroup + } + $o[$costCol] = if ($null -ne $_.MonthlyCost) { Format-BudgetAmount -Value $_.MonthlyCost -Currency $_.Currency } else { $noCost } + $o['Detail'] = $_.Detail + [PSCustomObject]$o + } + $cols = @('Category', 'ResourceName', 'ResourceGroup', $costCol, 'Detail') + } + 'Get-IdleVMs' { + $scanned = if ($data.ScannedVMs) { $data.ScannedVMs } else { 0 } + if ($data.MetricFailures -gt 0) { Write-FinOpsConsole " $($data.MetricFailures) of $scanned VMs could not be evaluated. $($data.Note)" -ForegroundColor Yellow } + if ($data.IdleVMs -and @($data.IdleVMs).Count -gt 0) { + Write-FinOpsConsole " Scanned $scanned running VMs — $(@($data.IdleVMs).Count) idle/underutilized" -ForegroundColor White + $rows = $data.IdleVMs + $cols = @('VMName', 'ResourceGroup', 'VMSize', 'AvgCPU14d', 'Classification') + } + elseif ($data.MetricFailures -eq 0) { + Write-FinOpsConsole " Scanned $scanned running VMs — no idle or underutilized VMs detected" -ForegroundColor Green + } + } + 'Get-StorageTierAdvice' { + $hotCount = if ($data.TotalHotAccounts) { $data.TotalHotAccounts } else { 0 } + if ($data.MetricFailures -gt 0) { Write-FinOpsConsole " $($data.MetricFailures) of $hotCount storage accounts could not be evaluated. Review the metric errors." -ForegroundColor Yellow } + if ($data.Recommendations -and @($data.Recommendations).Count -gt 0) { + Write-FinOpsConsole " $hotCount Hot-tier accounts scanned - $(@($data.Recommendations).Count) candidates for tier review" -ForegroundColor White + $rows = $data.Recommendations + $cols = @('StorageAccount', 'ResourceGroup', 'CurrentTier', 'CapacityGB', 'Recommendation') + } + else { + Write-FinOpsConsole " $hotCount Hot-tier accounts found. No tier recommendation was produced from the available measurements." -ForegroundColor White + } + } + 'Get-AHBOpportunities' { + $rows = @() + if ($data.WindowsVMs) { + $rows += @($data.WindowsVMs) | ForEach-Object { + $est = if ($null -ne $_.estMonthlySavings) { "$(Format-BudgetAmount -Value $_.estMonthlySavings -Currency $data.SavingsCurrency)/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] } + # Console width is tight once SKU/Region/Qty are shown. + if ($resLabel.Length -gt 22) { $resLabel = $resLabel.Substring(0, 21) + [char]0x2026 } + [PSCustomObject]@{ + Resource = $resLabel + Type = ($_.ResourceType -split '/')[-1] + SKU = $_.SKU + Region = $_.Region + Qty = $_.Qty + Term = $_.Term + Savings = Format-BudgetAmount -Value $_.AnnualSavings -Currency $_.Currency + Impact = $_.Impact + } + } + $cols = @('Resource', 'Type', 'SKU', 'Region', 'Qty', 'Term', 'Savings', 'Impact') + Write-ColorizedLine -Text " Est. annual savings: $(Format-BudgetAmount -Value $data.EstimatedAnnualSavings -Currency $data.Currency)" -DefaultColor 'White' + if ($data.CostIssue) { Write-FinOpsConsole " $($data.CostIssue)" -ForegroundColor Yellow } + } + } + 'Get-CommitmentUtilization' { + if ($data.HasData) { + Write-FinOpsConsole " Reservations: $($data.RICount) (average $(Format-ReportMetric $data.RIAvgUtilization -Format '0.#' -Suffix '%')) | Savings plans: $($data.SPCount) (average $(Format-ReportMetric $data.SPAvgUtilization -Format '0.#' -Suffix '%'))" -ForegroundColor White + $rows = $data.Reservations | ForEach-Object { + [PSCustomObject]@{ Reservation = if ($_.Name) { $_.Name } else { $_.ReservationId }; SKU = if ($_.SkuName) { $_.SkuName } else { 'Not returned' }; Kind = if ($_.Kind) { $_.Kind } else { 'Not returned' }; AvgUtil = Format-ReportMetric $_.AvgUtilization -Format '0.#' -Suffix '%'; UsageDate = $_.UsageDate } + } + $cols = @('Reservation', 'SKU', 'Kind', 'AvgUtil', 'UsageDate') + if ($data.Note) { Write-FinOpsConsole " $($data.Note)" -ForegroundColor DarkGray } + } + elseif ($data.AccessDenied) { + Show-PermissionReadout -Fn 'Get-CommitmentUtilization' -PermissionInfo $permissionInfo -Activity 'reservation / savings plan utilization' + } + else { + Write-FinOpsConsole " $(if ($data.Note) { $data.Note } else { 'No reservation or savings plan results returned.' })" -ForegroundColor DarkGray + } + } + 'Get-SavingsRealized' { + Write-FinOpsConsole " Estimated savings (separate periods):" -ForegroundColor White + Write-FinOpsConsole " Commitment period: $($data.Period)" -ForegroundColor DarkGray + Write-ColorizedLine -Text " RI: $(Format-BudgetAmount -Value $data.RISavingsMonthToDate -Currency $data.Currency) SP: $(Format-BudgetAmount -Value $data.SPSavingsMonthToDate -Currency $data.Currency)" -DefaultColor 'Cyan' + Write-ColorizedLine -Text " Commitment estimate: $(Format-BudgetAmount -Value $data.CommitmentSavingsMonthToDate -Currency $data.Currency)" -DefaultColor 'White' + Write-ColorizedLine -Text " AHB: $(Format-BudgetAmount -Value $data.AHBSavingsMonthly -Currency $data.AHBCurrency) ($($data.AHBPeriod))" -DefaultColor 'Cyan' + if ($data.AHBIssue) { Write-FinOpsConsole " $($data.AHBIssue)" -ForegroundColor Yellow } + if ($data.EstimateBasis) { + Write-FinOpsConsole " $($data.EstimateBasis)" -ForegroundColor DarkGray + } + $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 = Format-BudgetAmount -Value $_.Value.Actual -Currency $_.Value.Currency + ActualPeriod = if ($_.Value.ActualPeriod) { $_.Value.ActualPeriod } else { 'Current month' } + Forecast = if ($_.Value.ForecastSource -eq 'Actual') { 'Unavailable' } else { Format-BudgetAmount -Value $_.Value.Forecast -Currency $_.Value.Currency } + ForecastSource = if ($_.Value.ForecastSource) { $_.Value.ForecastSource } else { 'Unavailable' } + Currency = $_.Value.Currency + } + } + $cols = @('Subscription', 'Actual', 'ActualPeriod', 'Forecast', 'ForecastSource', '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 = Format-BudgetAmount -Value $_.Actual -Currency $_.Currency + } + } + $cols = @('Resource', 'ResourceGroup', 'ResourceType', 'Cost') + if (@($data).Count -gt 50) { + Write-FinOpsConsole " (showing top 50 of $(@($data).Count) resources by cost)" -ForegroundColor DarkGray + } + } + 'Get-CostByTag' { + if ($data.CoverageIncomplete) { Write-FinOpsConsole " $($data.Note)" -ForegroundColor Yellow } + if ($data.CostByTag -and $data.CostByTag.Count -gt 0) { + if ($data.Source) { Write-FinOpsConsole " 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 = Format-BudgetAmount -Value $v.Cost -Currency $v.Currency } + } + } + $cols = @('Tag', 'Value', 'Cost') + } + elseif ($data.NoTagsFound) { + Write-FinOpsConsole " 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-FinOpsConsole " Tags queried: $tagCount, results: $cbtCount — no cost data returned." -ForegroundColor DarkGray + if ($data.UsedTimeframe) { Write-FinOpsConsole " 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-FinOpsConsole " 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-FinOpsConsole " $subName" -ForegroundColor White + $subRows = $subEntry.Value | ForEach-Object { + [PSCustomObject]@{ Month = $_.Month; Cost = Format-BudgetAmount -Value $_.Cost -Currency $_.Currency; Currency = $_.Currency } + } + @($subRows) | Format-Table -AutoSize | Out-String | ForEach-Object { + $lines = $_.TrimEnd() -split '\r?\n' | Where-Object { $_.Trim() } + $hdrDone = $false + foreach ($ln in $lines) { + if (-not $hdrDone) { + if ($ln -match '^[\s\-]+$') { Write-FinOpsConsole " $ln" -ForegroundColor DarkCyan; $hdrDone = $true } + else { Write-FinOpsConsole " $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-FinOpsConsole " $subNames" -ForegroundColor White + } + $rows = $data.Months | ForEach-Object { + [PSCustomObject]@{ Month = $_.Month; Cost = Format-BudgetAmount -Value $_.Cost -Currency $_.Currency; Currency = $_.Currency } + } + $cols = @('Month', 'Cost', 'Currency') + } + } + 'Get-TagInventory' { + $tagCountText = if ($data.SpellingCount -and $data.SpellingCount -ne $data.TagCount) { "$($data.TagCount) unique tag keys ($($data.SpellingCount) spellings)" } else { "$($data.TagCount) unique tags" } + $coverageLabel = if ($data.CoverageIncomplete -or $null -eq $data.TagCoverage) { 'Unverified' } else { "$($data.TagCoverage)%" } + Write-FinOpsConsole " Coverage: $coverageLabel | $($data.TaggedCount) tagged / $($data.UntaggedCount) untagged | $tagCountText" -ForegroundColor White + if ($data.CoverageIncomplete) { Write-FinOpsConsole " $($data.Note)" -ForegroundColor Yellow } + if ($data.CaseVariants -and @($data.CaseVariants).Count -gt 0) { + Write-FinOpsConsole " Case-variant keys (Azure treats these as one tag):" -ForegroundColor Yellow + foreach ($cv in @($data.CaseVariants)) { + Write-FinOpsConsole " $($cv.TagKey): $($cv.Detail)" -ForegroundColor DarkGray + } + } + if ($data.TagNames -and $data.TagNames.Count -gt 0) { + $rows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | Select-Object -First 15 | ForEach-Object { + $vals = @($_.Value.Values | Sort-Object ResourceCount -Descending) + $shown = @($vals | Select-Object -First 3 | ForEach-Object { "$($_.Value) ($($_.ResourceCount))" }) + $more = $vals.Count - $shown.Count + $valText = ($shown -join ', ') + $(if ($more -gt 0) { ", +$more more" } else { '' }) + [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; Values = $vals.Count; 'Top values' = $valText } + } + $cols = @('Tag', 'Resources', 'Values', 'Top values') + } + } + '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-FinOpsConsole " Compliance: $($data.CompliancePercent)%" -ForegroundColor White + } + 'Get-PolicyInventory' { + $hasComplianceData = if ($null -ne $data.HasComplianceData) { $data.HasComplianceData } else { (($data.TotalCompliant + $data.TotalNonCompliant) -gt 0) } + if ($data.DefinitionCoverageIncomplete) { + Write-FinOpsConsole ' Policy definition coverage is incomplete. Some effects could not be resolved.' -ForegroundColor Yellow + } + if ($data.ComplianceCoverageIncomplete) { + Write-FinOpsConsole " Assignments: $($data.AssignmentCount) | Compliance: unverified because some selected scopes could not be read" -ForegroundColor Yellow + } + elseif ($hasComplianceData) { + Write-FinOpsConsole " Assignments: $($data.AssignmentCount) | Compliance: $($data.CompliancePct)% ($($data.TotalCompliant) compliant, $($data.TotalNonCompliant) non-compliant)" -ForegroundColor White + } + else { + Write-FinOpsConsole " 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-FinOpsConsole " (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') + $assignmentCoverage = if ($data.CoverageIncomplete -or $null -eq $data.CompliancePct) { 'unverified' } else { "$($data.CompliancePct)%" } + Write-FinOpsConsole " Assignment coverage: $assignmentCoverage (recommended definition IDs found, not resource compliance)" -ForegroundColor White + foreach ($issue in @($data.InitiativeErrors)) { + Write-FinOpsConsole " Initiative lookup unavailable: $($issue.InitiativeId) - $($issue.Error)" -ForegroundColor Yellow + } + } + 'Get-BudgetStatus' { + Write-FinOpsConsole " Budgets: $($data.TotalBudgets) | " -ForegroundColor White -NoNewline + Write-FinOpsConsole "At risk: $($data.AtRiskCount)" -ForegroundColor $(if ($data.AtRiskCount -gt 0) { 'Yellow' } else { 'Green' }) -NoNewline + Write-FinOpsConsole " | " -ForegroundColor White -NoNewline + Write-FinOpsConsole "Over budget: $($data.OverBudgetCount)" -ForegroundColor $(if ($data.OverBudgetCount -gt 0) { 'Red' } else { 'Green' }) -NoNewline + if ($data.CoverageIncomplete) { + Write-FinOpsConsole " | Coverage: unverified (read $($data.ScannedSubs) of $($data.TotalSubs) subs)" -ForegroundColor Yellow + } + else { + Write-FinOpsConsole " | Coverage: $($data.BudgetCoverage)%" -ForegroundColor White + } + $rows = $data.Budgets | ForEach-Object { + [PSCustomObject]@{ + Budget = $_.BudgetName + Amount = Format-BudgetAmount -Value $_.Amount -Currency $_.Currency + Spent = Format-BudgetAmount -Value $_.ActualSpend -Currency $_.Currency + Forecast = Format-BudgetAmount -Value $_.Forecast -Currency $_.Currency + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } + Risk = $_.Risk + Note = $_.Note + } + } + $cols = @('Budget', 'Amount', 'Spent', 'Forecast', 'PctUsed', 'Risk', 'Note') + } + 'Get-BudgetHistory' { + if ($data -and @($data).Count -gt 0) { + $rows = @($data) | ForEach-Object { + [PSCustomObject]@{ + Subscription = $_.Subscription + Budget = $_.BudgetName + Month = $_.Month + Budgeted = Format-BudgetAmount -Value $_.BudgetAmount -Currency $_.Currency + Actual = Format-BudgetAmount -Value $_.ActualSpend -Currency $_.Currency + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } + Status = $_.Status + Note = $_.Note + } + } + $cols = @('Subscription', 'Budget', 'Month', 'Budgeted', 'Actual', 'PctUsed', 'Status', 'Note') + } + else { + Write-FinOpsConsole " No budget history available (no budgets configured, or no cost data for the period)." -ForegroundColor DarkGray + } + } + 'Get-AnomalyAlerts' { + Write-FinOpsConsole " 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; Country = $_.SoldToCountry; Status = $_.AccountStatus } + } + $cols = @('Account', 'Agreement', 'Type', 'Country', 'Status') + } + 'Get-MaccCommitment' { + if ($data.CoverageIncomplete) { Write-FinOpsConsole " $($data.Reason)" -ForegroundColor Yellow } + if (-not $data.Applicable) { + Write-FinOpsConsole " $($data.Reason)" -ForegroundColor DarkGray + } + elseif ($data.HasMacc -and @($data.Commitments).Count -gt 0) { + $rows = @($data.Commitments) | ForEach-Object { + [PSCustomObject]@{ + Account = $_.BillingAccount + Commitment = Format-BudgetAmount -Value $_.Commitment -Currency $_.Currency + Consumed = Format-BudgetAmount -Value $_.Consumed -Currency $_.Currency + Remaining = Format-BudgetAmount -Value $_.Remaining -Currency $_.Currency + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } + Status = $_.Status + Expires = $_.ExpirationDate + } + } + $cols = @('Account', 'Commitment', 'Consumed', 'Remaining', 'PctUsed', 'Status', 'Expires') + } + else { + Write-FinOpsConsole " $($data.Reason)" -ForegroundColor DarkGray + } + } + 'Get-OptimizationAdvice' { + Write-ColorizedLine -Text " Est. annual savings: $(Format-BudgetAmount -Value $data.EstimatedAnnualSavings -Currency $data.Currency) | $($data.TotalCount) recommendations" -DefaultColor 'White' + if ($data.CostIssue) { Write-FinOpsConsole " $($data.CostIssue)" -ForegroundColor Yellow } + $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 = "$(Format-BudgetAmount -Value $_.AnnualSavings -Currency $_.Currency)/yr" + } + } + $cols = @('Category', 'Impact', 'Resource', 'Problem', 'Savings') + if ($data.TotalCount -gt 15) { + Write-FinOpsConsole " (showing top 15 of $($data.TotalCount) by savings)" -ForegroundColor DarkGray + } + } + 'Get-CarbonMetrics' { + $emissions = if ($null -ne $data.TotalEmissionsKg) { "$($data.TotalEmissionsKg) $($data.Unit)" } else { 'Unavailable' } + $changeLabel = if ($null -ne $data.ChangeRatio) { "$($data.ChangeRatio)%" } else { 'Unavailable' } + Write-ColorizedLine -Text " Latest month ($($data.LatestMonth)): $emissions | Month-over-month change: $changeLabel" -DefaultColor 'White' + if ($data.Note) { Write-FinOpsConsole " $($data.Note)" -ForegroundColor Yellow } + $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' { + $computeShare = if ($null -ne $data.ComputeSharePct) { "$($data.ComputeSharePct)% of VM compute + storage spend" } else { 'Unavailable' } + $storageShare = if ($null -ne $data.StorageSharePct) { "$($data.StorageSharePct)% of VM compute + storage spend" } else { 'Unavailable' } + Write-ColorizedLine -Text " Compute: $(Format-BudgetAmount -Value $data.ComputeCost -Currency $data.Currency) ($computeShare) over $($data.VmCount) VMs / $($data.TotalVCpu) vCPU / $($data.TotalMemoryGb) GB RAM" -DefaultColor 'White' + Write-ColorizedLine -Text " Storage: $(Format-BudgetAmount -Value $data.StorageCost -Currency $data.Currency) ($storageShare) over $($data.TotalStorageGb) GB ($($data.DiskGb) GB disk + $($data.BlobFileGb) GB blob/file)" -DefaultColor 'White' + $unitContext = Get-FinOpsUnitCostContext -Data $data + Write-FinOpsConsole " $($unitContext.Summary)" -ForegroundColor DarkGray + if ($data.Note) { Write-FinOpsConsole " $($data.Note)" -ForegroundColor DarkGray } + $rows = @( + [PSCustomObject]@{ Metric = 'Cost per vCPU'; Value = (Format-FinOpsUnitRate -Value $data.CostPerVCpu -Currency $data.Currency) } + [PSCustomObject]@{ Metric = 'Cost per GB RAM'; Value = (Format-FinOpsUnitRate -Value $data.CostPerGbRam -Currency $data.Currency) } + [PSCustomObject]@{ Metric = 'Cost per VM'; Value = (Format-FinOpsUnitRate -Value $data.CostPerVm -Currency $data.Currency) } + [PSCustomObject]@{ Metric = 'Cost per GB stored'; Value = (Format-FinOpsUnitRate -Value $data.CostPerGb -Currency $data.Currency) } + ) + $cols = @('Metric', 'Value') + } + 'Get-AIWorkloadMetrics' { + if (-not $data.HasData) { + Write-FinOpsConsole " No AI workloads detected — AI KPIs skipped." -ForegroundColor Green + } + else { + $fp = $data.AIFootprint + $periodLabel = if ($data.Period -eq 'MonthToDate') { 'Month to date' } elseif ($data.Period) { [string]$data.Period } else { 'Unknown period' } + Write-ColorizedLine -Text " AI footprint — OpenAI/AIServices: $($fp.OpenAIAccounts + $fp.AIServices) ML workspaces: $($fp.MLWorkspaces) AI Search: $($fp.SearchServices) GPU VMs: $($fp.GpuVmCount)" -DefaultColor 'White' + Write-ColorizedLine -Text " Period: $periodLabel" -DefaultColor 'White' + Write-ColorizedLine -Text " Tokens: $(Format-ReportMetric $data.TotalTokens) | Requests: $(Format-ReportMetric $data.TotalRequests)" -DefaultColor 'White' + Write-ColorizedLine -Text " AI spend: $(Format-BudgetAmount -Value $data.TotalAICost -Currency $data.Currency) | $(Format-FinOpsUnitRate -Value $data.CostPer1KTokens -Currency $data.Currency)/1K tokens | $(Format-FinOpsUnitRate -Value $data.CostPerRequest -Currency $data.Currency)/request" -DefaultColor 'White' + if ($data.Note) { Write-FinOpsConsole " $($data.Note)" -ForegroundColor DarkGray } + if ($data.ByModel -and @($data.ByModel).Count -gt 0) { + $rows = $data.ByModel + $cols = @('Account', 'Deployment', 'PromptTokens', 'GeneratedTokens', 'TotalTokens', 'TokenBasis') + } + 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 '\r?\n' | Where-Object { $_.Trim() } } + if ($headerStr.Count -ge 2) { + Write-FinOpsConsole " $($headerStr[0])" -ForegroundColor Cyan + Write-FinOpsConsole " $($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 '\r?\n' | Where-Object { $_.Trim() } } + $headerDone = $false + foreach ($line in $tableLines) { + if (-not $headerDone) { + # First two lines are header + separator + if ($line -match '^[\s\-]+$') { + Write-FinOpsConsole " $line" -ForegroundColor DarkCyan + $headerDone = $true + } + else { + Write-FinOpsConsole " $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-FinOpsConsole " (no findings)" -ForegroundColor DarkGray + } + + $scanContext = Get-FinOpsScanContext -FunctionName $mod.Fn -Data $data + if ($scanContext) { + Write-FinOpsConsole " $($scanContext.Summary)" -ForegroundColor DarkGray + foreach ($description in $scanContext.Details) { Write-FinOpsConsole " $description" -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 = @() } + foreach ($kpi in $kpiInsights) { + # One entry per KPI. A computed value replaces an informational one. + $existingKpi = $kpiCollected | Where-Object { $_.kpiId -eq $kpi.kpiId } | Select-Object -First 1 + if (-not $existingKpi) { + [void]$kpiCollected.Add($kpi) + } + elseif ($existingKpi.status -ne 'computed' -and $kpi.status -eq 'computed') { + [void]$kpiCollected.Remove($existingKpi) + [void]$kpiCollected.Add($kpi) + } + } + if ($kpiInsights.Count -gt 0) { + Write-FinOpsConsole "" + Write-FinOpsConsole " FinOps KPIs:" -ForegroundColor Cyan + foreach ($kpi in $kpiInsights) { + if ($kpi.status -eq 'computed' -and $kpi.yourValue) { + Write-FinOpsConsole " - $($kpi.kpiName): " -ForegroundColor White -NoNewline + Write-FinOpsConsole "$($kpi.yourValue)" -ForegroundColor Green -NoNewline + Write-FinOpsConsole " [$($kpi.domain)]" -ForegroundColor DarkGray + } + else { + Write-FinOpsConsole " - $($kpi.kpiName) " -ForegroundColor DarkGray -NoNewline + Write-FinOpsConsole "(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 ($data.MetricFailures -gt 0) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = "VM utilization coverage is incomplete: $($data.MetricFailures) VMs have unavailable metrics. Review the $idleCount candidates found among evaluated VMs." }) + } + elseif ($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 = 'Yellow'; Message = "No VMs met the idle thresholds in the available measurements. This does not establish that compute spend is optimized." } + ) + } + } + 'Get-StorageTierAdvice' { + $recoCount = if ($data.Recommendations) { @($data.Recommendations).Count } else { 0 } + if ($data.MetricFailures -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Storage tier assessment is incomplete: $($data.MetricFailures) account(s) have unavailable metrics. $recoCount candidate(s) were found among evaluated accounts; unread accounts are not assumed optimized." } + ) + } + elseif ($recoCount -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$recoCount storage accounts meet the scan's activity thresholds for a tier review. Validate blob access patterns, retrieval needs, retention charges, and supported tiers before making changes." } + @{ Severity = 'Yellow'; Message = 'Review lifecycle management rules for eligible blobs; account-level metrics alone do not prove a tier change will save money.'; Docs = 'https://learn.microsoft.com/azure/storage/blobs/lifecycle-management-overview' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'No tier candidates met the scan thresholds in the available measurements. This does not establish that every blob is appropriately tiered.' } + ) + } + } + '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 ($data.CoverageIncomplete -or $null -eq $data.TagCoverage) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = if ($data.Note) { [string]$data.Note } else { 'Tag coverage is unverified because inventory measurements are incomplete.' } }) + } + elseif ($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' } + ) + } + + # Case variants are independent of coverage, so append rather than replace. + if ($data.CaseVariants -and @($data.CaseVariants).Count -gt 0) { + $cvCount = @($data.CaseVariants).Count + $cvWord = if ($cvCount -eq 1) { 'tag key is' } else { 'tag keys are' } + $guidanceItems += @{ Severity = 'Yellow'; Message = "$cvCount $cvWord applied under more than one spelling. Azure resolves tag keys case-insensitively, so these are a single key to Azure, but Resource Graph and cost exports report each spelling separately." } + foreach ($cv in @($data.CaseVariants)) { + $guidanceItems += @{ Severity = 'Yellow'; Message = "$($cv.TagKey): $($cv.Detail). Standardize on one spelling, then retag the others." } + } + $guidanceItems += @{ Severity = 'Yellow'; Message = "Azure Policy 'Require a tag and its value' enforces the key name at deployment, which prevents new variants."; Docs = 'https://learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-tagging' } + } + } + 'Get-CostByTag' { + if ($data.CoverageIncomplete) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = [string]$data.Note } + ) + } + elseif ($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', 'ApplicationName', + 'Owner', 'BusinessUnit', 'Department', 'Team', 'OpsTeam', 'Service', 'WorkloadName') + } + # Prefer the per-resource allocation figure so the guidance + # and the FinOps KPI report the same number. Falls back to + # the largest single-tag gap when a run aggregated + # server-side and never walked resources. + $maxUntaggedCost = 0 + $maxUntaggedTag = '' + $seenCost = $data.ResourceCostSeen + $unallocCost = $data.UnallocatedCost + $tagCostRows = @($data.CostByTag.Values | ForEach-Object { $_ } | Where-Object { $null -ne $_.Cost }) + $tagCurrencies = @($tagCostRows.Currency | Where-Object { $_ } | Select-Object -Unique) + $guidanceCurrency = if ($tagCurrencies.Count -eq 1) { $tagCurrencies[0] } else { $null } + $allocationTags = @($data.CostByTag.Keys | Where-Object { $allocTags -contains $_ }) + $haveResourceTotals = $null -ne $seenCost -and $null -ne $unallocCost + $haveCostData = $haveResourceTotals -or $tagCostRows.Count -gt 0 + $havePositiveCost = [double]$seenCost -gt 0 + $hasCredits = @($tagCostRows | Where-Object { [double]$_.Cost -lt 0 }).Count -gt 0 + if ($haveResourceTotals) { + $maxUntaggedCost = [double]$unallocCost + $maxUntaggedTag = 'any allocation tag' + $hasCredits = $hasCredits -or [double]$unallocCost -lt 0 -or [double]$unallocCost -gt [double]$seenCost + } + else { + foreach ($tag in $data.CostByTag.GetEnumerator()) { + if (($tag.Value | Measure-Object Cost -Sum).Sum -gt 0) { $havePositiveCost = $true } + if ($allocTags -notcontains $tag.Key) { continue } + foreach ($tagValue in $tag.Value) { + if ($tagValue.TagValue -eq '(untagged)' -and [double]$tagValue.Cost -gt $maxUntaggedCost) { + $maxUntaggedCost = [double]$tagValue.Cost + $maxUntaggedTag = $tag.Key + } + } + } + } + if (-not $haveCostData) { + # Nothing to attribute. Blaming the tags here would be wrong: + # the tag inventory is fine, the cost side came back empty. + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "No cost data was returned for this period, so spend cannot be split by tag. Tag coverage itself is unaffected - check the data source, permissions, and that the period has usage." } + ) + } + elseif ($allocationTags.Count -eq 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "No CAF allocation tag (CostCenter, Customer, Project, Environment, Owner, ...) is in use, so spend cannot be attributed. Add an allocation tag and deploy inheritance to make cost traceable." } + ) + } + elseif ($hasCredits) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Cost data includes credits or negative net costs. Review the amounts by tag; allocation percentages might not be comparable.' } + ) + } + elseif (-not $havePositiveCost) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Cost data is available, but there is no positive net cost for allocation percentages.' } + ) + } + elseif ($maxUntaggedCost -gt 1000) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Untagged spend: $(Format-BudgetAmount -Value $maxUntaggedCost -Currency $guidanceCurrency) not allocated by '$maxUntaggedTag'. Review the missing allocation evidence." } + @{ 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: $(Format-BudgetAmount -Value $maxUntaggedCost -Currency $guidanceCurrency) not allocated by '$maxUntaggedTag'. Tag remaining resources for full cost traceability." } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = 'No positive untagged cost was found for the allocation tags in this result.' } + ) + } + } + 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' { + $nowUtc = if ($data.CostPeriodEndUtc) { ([datetime]$data.CostPeriodEndUtc).ToUniversalTime() } else { (Get-Date).ToUniversalTime() } + $currentMonthStart = $nowUtc.Date.AddDays(1 - $nowUtc.Day) + $completedMonths = @($data.Months | Where-Object { $_.MonthDate -and [datetime]$_.MonthDate -lt $currentMonthStart } | + Sort-Object { [datetime]$_.MonthDate } -Descending | Select-Object -First 2) + if ($completedMonths.Count -lt 2) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = 'A month-over-month comparison needs two completed months. The current month is partial and is excluded.' }) + } + elseif (-not $completedMonths[0].Currency -or -not $completedMonths[1].Currency -or + $completedMonths[0].Currency -eq 'Mixed' -or $completedMonths[0].Currency -ne $completedMonths[1].Currency) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = 'The completed months have unknown or different currencies. A month-over-month percentage is unavailable.' }) + } + else { + $latestDate = [datetime]$completedMonths[0].MonthDate + $previousDate = [datetime]$completedMonths[1].MonthDate + $latestMonth = $latestDate.Date.AddDays(1 - $latestDate.Day) + $previousMonth = $previousDate.Date.AddDays(1 - $previousDate.Day) + if ($previousMonth.AddMonths(1) -ne $latestMonth) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = 'The result does not contain two consecutive completed months. A month-over-month percentage is unavailable.' }) + } + elseif ([double]$completedMonths[1].Cost -le 0) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = 'The previous completed month has no positive net cost. A month-over-month percentage is unavailable.' }) + } + else { + $change = [math]::Round((([double]$completedMonths[0].Cost - [double]$completedMonths[1].Cost) / [double]$completedMonths[1].Cost) * 100, 1) + $direction = if ($change -lt 0) { 'decreased' } elseif ($change -gt 0) { 'increased' } else { 'changed' } + $previousLabel = $previousMonth.ToString('MMM yyyy', [cultureinfo]::InvariantCulture) + $latestLabel = $latestMonth.ToString('MMM yyyy', [cultureinfo]::InvariantCulture) + $guidanceItems = @( + @{ Severity = $(if ($change -gt 20) { 'Red' } else { 'Yellow' }); Message = "Observed spend $direction $([math]::Abs($change))% from $previousLabel to $latestLabel ($($completedMonths[0].Currency)). The partial current month is excluded." } + @{ Severity = 'Yellow'; Message = 'A change in spend can reflect usage, prices, credits, or optimization. Review the cost drivers before attributing savings.' } + ) + } + } + if ($data.CoverageIncomplete) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = 'The trend covers returned rows, not a verified total for every selected subscription. Changes may reflect differences in coverage.' }) + $guidanceItems + } + } + 'Get-ReservationAdvice' { + if ($data.CostIssue) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = [string]$data.CostIssue }) + } + elseif ($data.AdvisorRecommendations -and @($data.AdvisorRecommendations).Count -gt 0) { + $totalSavings = if ($data.EstimatedAnnualSavings) { $data.EstimatedAnnualSavings } else { 0 } + if ($totalSavings -gt 10000) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Estimated reservation savings: $(Format-BudgetAmount -Value $data.EstimatedAnnualSavings -Currency $data.Currency)/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 = "Estimated reservation savings: $(Format-BudgetAmount -Value $data.EstimatedAnnualSavings -Currency $data.Currency)/year. Review the individual recommendations 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) { + $knownUtilization = @( + if ($data.RICount -gt 0 -and $null -ne $data.RIAvgUtilization) { [double]$data.RIAvgUtilization } + if ($data.SPCount -gt 0 -and $null -ne $data.SPAvgUtilization) { [double]$data.SPAvgUtilization } + ) + $minimumAverage = ($knownUtilization | Measure-Object -Minimum).Minimum + if ($data.CoverageIncomplete -or $data.UnscopedFallback -or $knownUtilization.Count -eq 0) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = 'Overall commitment utilization is unavailable or its scope is unverified. Review the returned commitments and coverage notes; missing values are not zero utilization.' }) + } + elseif ($minimumAverage -lt 80) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = 'Reported average commitment utilization is below 80%. Review eligible usage, scope, and SKU alignment before changing purchases.' } + @{ Severity = 'Yellow'; Message = 'Exchange and refund options depend on the reservation product and current eligibility rules.'; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/manage-reserved-vm-instance' } + ) + } + elseif ($minimumAverage -lt 95) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Reported average commitment utilization is below 95%. Review whether benefit scope and eligible demand still match the purchase.' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = 'Reported average commitment utilization is at least 95%. This is utilization evidence, not a measurement of financial savings.' } + ) + } + } + 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.CommitmentSavingsMonthToDate -gt 0 -or $data.AHBSavingsMonthly -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Estimated savings use assumed discounts. Commitment amounts cover the reported month-to-date period; AHB uses a separate 730-hour estimate. They are not combined or annualized.' } + @{ Severity = 'Yellow'; Message = 'Validate the estimate against matching pay-as-you-go rates and benefit usage before reporting savings.' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'No positive savings estimate is available from this scan. Review commitment usage and data access before drawing a conclusion.' } + @{ Severity = 'Yellow'; Message = "FinOps Practice: Commitment discounts are the #1 cost optimization lever (30-60% savings)."; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/reservations/save-compute-costs-reservations' } + ) + } + } + '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 ($data.CoverageIncomplete) { + if ($data.Sampled) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "No budgets were returned from $($data.ScannedSubs) sampled subscriptions; the remaining selections were not queried. This is a sampling limit, not evidence of denied access." } + @{ Severity = 'Yellow'; Message = 'Run smaller subscription selections to query every selected subscription. This scan reads subscription budgets, not resource-group, management-group, or billing-scope budgets.'; Docs = 'https://learn.microsoft.com/azure/cost-management-billing/costs/tutorial-acm-create-budgets' } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Budgets were read for $($data.ScannedSubs) of $($data.TotalSubs) subscriptions. Review the recorded failures before concluding whether access, throttling, or another error caused the gap." } + ) + } + } + elseif (@($data.Budgets | Where-Object { $null -eq $_.Amount -or $null -eq $_.ActualSpend -or $null -eq $_.Forecast -or $_.Risk -in @('Unknown', 'Forecast unavailable') }).Count -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Budget health is unverified because an amount, current spend, or forecast is unavailable. Review the notes for each budget.' } + ) + } + elseif ($bCoverage -lt 50) { + $guidanceItems = @( + @{ Severity = 'Red'; Message = "Budget coverage is only $bCoverage%. Most subscriptions have no budget — spending is untracked." } + @{ 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 ($data.CoverageIncomplete) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = [string]$data.Note }) + } + elseif ($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) { + $noRuleMessage = if (@($data.ConfiguredRules).Count -gt 0) { 'Anomaly alert rules exist but none are enabled. Enable a rule for early spend warnings.' } else { 'No anomaly detection rules configured. Set up Cost Management anomaly alerts for early spend warnings.' } + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = $noRuleMessage } + @{ 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 ($data.ComplianceCoverageIncomplete) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Policy compliance coverage is incomplete. Review failed scopes before using a compliance percentage or concluding that no violations exist.' } + ) + } + elseif (-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." } + ) + } + if ($data.DefinitionCoverageIncomplete) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Policy definition coverage is incomplete. Review the unreadable definitions before drawing conclusions about unresolved effects.' } + ) + @($guidanceItems | Where-Object { $_.Severity -ne 'Green' }) + } + } + 'Get-PolicyRecommendations' { + if ($data.Analysis -and @($data.Analysis).Count -gt 0) { + $missing = @($data.Analysis | Where-Object { $_.Status -eq 'Missing' }) + if ($data.CoverageIncomplete) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Some initiative definitions could not be read. Unmatched policies are Unknown, not confirmed missing. Review the initiative lookup errors.' } + ) + } + elseif ($missing.Count -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "$($missing.Count) recommended definition IDs were not found in the reported assignments or their initiatives. Check equivalent custom policies and intended scopes before making changes." } + ) + } + else { + $guidanceItems = @( + @{ Severity = 'Green'; Message = 'All recommended definition IDs were found in the reported assignments or their initiatives.' } + ) + } + $guidanceItems += @{ Severity = 'Yellow'; Message = 'Assignment presence does not prove enforcement or compliance. Review scopes, exclusions, parameters, and enforcement modes.'; Docs = 'https://learn.microsoft.com/azure/governance/policy/concepts/initiative-definition-structure' } + } + } + 'Get-OptimizationAdvice' { + if ($data.CoverageIncomplete) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = [string]$data.Note }) + } + elseif ($data.CostIssue) { + $guidanceItems = @(@{ Severity = 'Yellow'; Message = [string]$data.CostIssue }) + } + elseif ($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) { + $guidanceItems = @( + @{ Severity = 'Green'; Message = 'Actual costs and forecasts are separate amounts. Compare subscriptions only when their currencies and reporting periods match.' } + @{ Severity = 'Green'; Message = "FinOps Practice: Review actual vs. forecast regularly. Pair this data with Budget Status to track variance." } + ) + } + } + 'Get-ResourceCosts' { + $topCount = if ($data) { @($data).Count } else { 0 } + if ($topCount -gt 0) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = 'Review the largest resource costs for changes in demand or unused capacity. A high cost alone does not establish waste.' } + ) + } + } + 'Get-AIWorkloadMetrics' { + $periodLabel = if ($data.Period -eq 'MonthToDate') { 'Month to date' } elseif ($data.Period) { [string]$data.Period } else { 'Unknown period' } + if (-not $data.HasData) { + $guidanceItems = @( + @{ Severity = 'Green'; Message = "No AI/LLM workloads detected. No AI-specific cost optimization needed right now." } + ) + } + elseif ($data.CostIssue -or $data.RateIssue) { + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = if ($data.RateIssue) { [string]$data.RateIssue } else { [string]$data.CostIssue } } + ) + } + elseif ($data.CostPer1KTokens -gt 0) { + $periodLabel = if ($data.Period -eq 'MonthToDate') { 'Month to date' } elseif ($data.Period) { [string]$data.Period } else { 'Unknown period' } + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Effective AI rate: $($data.Currency) $($data.CostPer1KTokens) per 1K tokens across $($data.TotalTokens) tokens ($periodLabel). Track this as your core AI unit-economics KPI." } + @{ Severity = 'Yellow'; Message = "Compare the model deployments in the token usage table — shift high-volume traffic to cheaper SKUs (e.g., gpt-4o-mini) and reserve premium models for tasks that need them." } + @{ Severity = 'Yellow'; Message = "For steady, predictable token volume, evaluate Provisioned Throughput Units (PTUs) — they can beat pay-as-you-go at scale."; Docs = 'https://learn.microsoft.com/azure/ai-services/openai/concepts/provisioned-throughput' } + ) + } + elseif ($data.TotalTokens -gt 0) { + $periodLabel = if ($data.Period -eq 'MonthToDate') { 'Month to date' } elseif ($data.Period) { [string]$data.Period } else { 'Unknown period' } + $guidanceItems = @( + @{ Severity = 'Yellow'; Message = "Token usage detected ($($data.TotalTokens), $periodLabel) 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) { + $guidanceByFn[$mod.Fn] = $guidanceItems + Write-FinOpsConsole "" + # 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-FinOpsConsole " $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-FinOpsConsole " $icon $($item.Message)" -ForegroundColor $color + if ($item.Docs) { + Write-FinOpsConsole " $($item.Docs)" -ForegroundColor DarkCyan + } + } + } + + Write-FinOpsConsole "" + } + + $exportDir = $null + try { + $exportDir = New-FinOpsReportDirectory -OutputPath $ExportPath -ErrorAction Stop + + # -- CSV exports per module -- + foreach ($mod in ($Modules | Where-Object { $_.Selected })) { + $data = $Results[$mod.Fn] + $hasScanError = $Results.ContainsKey("_error_$($mod.Fn)") + $exportRows = @(if (-not $hasScanError) { ConvertTo-FinOpsExportRows -Fn $mod.Fn -Data $data }) + if ($exportRows.Count -eq 0) { + $errorMessage = $Results["_error_$($mod.Fn)"] + $statusRow = [pscustomobject]@{ + RecordType = 'Status' + Scan = $mod.Fn + Status = if ($hasScanError) { 'Error' } else { 'No data' } + Error = $errorMessage + } + $exportRows = @(ConvertTo-FinOpsExportRows -Fn 'ReportStatus' -Data $statusRow) + } + $safeName = $mod.Fn -replace '[^a-zA-Z0-9\-]', '' + Write-FinOpsReportFile -Directory $exportDir -Name "$safeName.csv" -Lines @($exportRows | ConvertTo-Csv -NoTypeInformation -ErrorAction Stop) -ErrorAction Stop + } + + # -- HTML report -- + $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", [cultureinfo]::InvariantCulture) + $subList = if ($Subscriptions) { ($Subscriptions | ForEach-Object { if ($_.Name) { $_.Name } else { $_.Id } }) -join ', ' } else { 'N/A' } + $headerScope = if (@($Subscriptions).Count -gt 5) { "$(@($Subscriptions).Count) selected" } else { $subList } + $htmlSb = [System.Text.StringBuilder]::new() + [void]$htmlSb.Append(@" + + + + +FinOps Multitool Report — $timestamp + + + + +
+
FinOps Toolkit
+

FinOps Multitool report

+

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

+
+
+"@) + + $selectedMods = @($Modules | Where-Object { $_.Selected }) + $scanEvidence = @(foreach ($selectedMod in $selectedMods) { + $scanData = $Results[$selectedMod.Fn] + $notes = @(@($scanData.Note; $scanData.Reason; $scanData.CostIssue; $scanData.RateIssue; $scanData.AHBIssue; $scanData.Error) | + Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) + $state = 'Data returned' + if ($Results.ContainsKey("_error_$($selectedMod.Fn)")) { + $state = 'Failed' + $notes = @([string]$Results["_error_$($selectedMod.Fn)"]) + } + elseif (-not $scanData -or @($scanData).Count -eq 0 -or $scanData.HasData -contains $false) { $state = 'No data' } + if ($state -ne 'Failed' -and $Results['_source_Export'] -and $selectedMod.Fn -in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-CostTrend')) { + $notes += [string]$Results['_source_Export'].Note + if ($Results['_source_Export'].CoverageIncomplete) { $state = 'Limited data' } + } + if ($state -ne 'Failed' -and ($scanData.CoverageIncomplete -contains $true -or $scanData.ComplianceCoverageIncomplete -contains $true -or $scanData.DefinitionCoverageIncomplete -contains $true -or $scanData.AccessDenied -contains $true -or + @(@($scanData.CostIssue; $scanData.RateIssue; $scanData.AHBIssue; $scanData.Error) | Where-Object { $_ }).Count -gt 0 -or + @($scanData.MetricFailures | Where-Object { $_ -gt 0 }).Count -gt 0 -or + @($scanData.Status | Where-Object { $_ -in @('Unavailable', 'Unknown') }).Count -gt 0)) { + $state = 'Limited data' + } + if ($state -ne 'Failed' -and $selectedMod.Fn -eq 'Get-CostData' -and $scanData -is [System.Collections.IDictionary]) { + foreach ($entry in $scanData.GetEnumerator()) { + $entryName = if ($entry.Value.Name) { [string]$entry.Value.Name } elseif ($subNameLookup.ContainsKey($entry.Key)) { $subNameLookup[$entry.Key] } else { [string]$entry.Key } + if ($entry.Value.Currency -eq 'Mixed' -or (Format-BudgetAmount -Value $entry.Value.Actual -Currency $entry.Value.Currency) -eq 'Unavailable') { + $state = 'Limited data' + $notes += "${entryName}: Actual cost or billing currency unavailable." + } + if (-not $entry.Value.ActualPeriod -or $entry.Value.ActualPeriod -eq 'Unknown') { + $state = 'Limited data' + $notes += "${entryName}: Observed period not recorded." + } + if (-not $entry.Value.ForecastSource -or $entry.Value.ForecastSource -in @('Actual', 'Unavailable') -or + (Format-BudgetAmount -Value $entry.Value.Forecast -Currency $entry.Value.Currency) -eq 'Unavailable') { + $state = 'Limited data' + $notes += "${entryName}: Full-month forecast unavailable." + } + } + } + if ($state -eq 'No data' -and @($notes).Count -eq 0) { $notes = @('This scan returned no data; that is not a measured zero.') } + if ($state -eq 'Limited data' -and @($notes).Count -eq 0) { $notes = @('Some data or coverage could not be verified. Review the scan details.') } + [pscustomobject]@{ + Name = $selectedMod.Name + Function = $selectedMod.Fn + Target = 'tab-' + ($selectedMod.Category -replace '[^A-Za-z0-9]', '') + Anchor = 'scan-' + ($selectedMod.Fn -replace '[^a-zA-Z0-9\-]', '') + Status = $state + Note = ($notes -join ' ') + } + }) + $errorCount = @($scanEvidence | Where-Object Status -EQ 'Failed').Count + $dataGapCount = @($scanEvidence | Where-Object { $_.Status -in @('Limited data', 'No data') }).Count + [void]$htmlSb.Append('
') + [void]$htmlSb.Append("
Scans run
$($selectedMods.Count)
") + [void]$htmlSb.Append("
Scans with gaps
$dataGapCount
") + if ($errorCount -gt 0) { + [void]$htmlSb.Append("
Errors
$errorCount
") + } + [void]$htmlSb.Append('
') + + # Per-module sections, grouped into one tab per category + $presentCats = @($selectedMods | ForEach-Object { $_.Category } | Select-Object -Unique) + # Cost Analysis leads; the rest sort alphabetically so a new category + # lands in a predictable spot instead of being appended. + $leadCat = 'Cost Analysis' + $tabCats = @($presentCats | Where-Object { $_ -eq $leadCat }) + + @($presentCats | Where-Object { $_ -ne $leadCat } | Sort-Object) + + $storyCatalog = $null + if (Get-Command Get-KpiCatalog -ErrorAction SilentlyContinue) { + try { $storyCatalog = Get-KpiCatalog } catch { $storyCatalog = $null } + } + $hasStory = $true + $kpiReferenceIssue = $null + try { $kpiReference = @(Get-FinOpsKpiReference -Results $Results -Modules $Modules -Insights $kpiCollected) } + catch { + $kpiReference = @() + $kpiReferenceIssue = 'KPI reference unavailable: definitions could not be loaded. Scan results remain available.' + } + + [void]$htmlSb.Append('') + if ($kpiReferenceIssue) { + [void]$htmlSb.Append("

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

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

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

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

Observed spend

') + $costData = $Results['Get-CostData'] + if (-not $Results.ContainsKey('_error_Get-CostData') -and $costData -is [System.Collections.IDictionary] -and $costData.Count -gt 0) { + [void]$htmlSb.Append((ConvertTo-ReportTableControlHtml -TableId 'table-story-costs' -Title 'observed spend' -RowCount $costData.Count)) + [void]$htmlSb.Append('
') + foreach ($entry in $costData.GetEnumerator() | Sort-Object Key) { + $currency = if ($entry.Value.Currency -eq 'Mixed') { '' } else { [string]$entry.Value.Currency } + $forecastSource = if ($entry.Value.ForecastSource) { [string]$entry.Value.ForecastSource } else { 'Unavailable' } + $forecastText = if ($forecastSource -in @('Unavailable', 'Actual')) { 'Unavailable' } else { Format-BudgetAmount -Value $entry.Value.Forecast -Currency $currency } + $cells = @( + $(if ($entry.Value.Name) { [string]$entry.Value.Name } elseif ($subNameLookup.ContainsKey($entry.Key)) { $subNameLookup[$entry.Key] } else { [string]$entry.Key }) + (Format-BudgetAmount -Value $entry.Value.Actual -Currency $currency) + $(if ($entry.Value.ActualPeriod) { [string]$entry.Value.ActualPeriod } else { 'Not recorded' }) + $forecastText + $forecastSource + ) + [void]$htmlSb.Append('') + for ($cellIndex = 0; $cellIndex -lt $cells.Count; $cellIndex++) { + $cellClass = if ($cellIndex -in @(1, 3)) { ' class="numeric-cell"' } else { '' } + [void]$htmlSb.Append("$([System.Net.WebUtility]::HtmlEncode([string]$cells[$cellIndex]))") + } + [void]$htmlSb.Append('') + } + [void]$htmlSb.Append('
SubscriptionActual costObserved periodFull-month forecastForecast source
') + [void]$htmlSb.Append('

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

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

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

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

Largest resource costs

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

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

') + if ($driverRows.Count -gt 0) { + $driverGroups = @($driverRows | Group-Object -Property @{ + Expression = { + if ($_.SubscriptionId) { [string]$_.SubscriptionId } + elseif ($_.ResourcePath -match '^/subscriptions/([^/]+)/') { $Matches[1] } + else { [string]$_.Subscription } + } + }, Currency, ActualPeriod | Sort-Object Name) + $driverCount = ($driverGroups | ForEach-Object { [math]::Min(5, $_.Count) } | Measure-Object -Sum).Sum + [void]$htmlSb.Append((ConvertTo-ReportTableControlHtml -TableId 'table-story-resources' -Title 'largest resource costs' -RowCount $driverCount)) + [void]$htmlSb.Append('
') + foreach ($group in $driverGroups) { + foreach ($resource in $group.Group | Sort-Object { [double]$_.Actual } -Descending | Select-Object -First 5) { + [void]$htmlSb.Append('') + $driverCells = @( + $(if ($resource.Subscription) { [string]$resource.Subscription } else { 'Not attributed' }) + $(if ($resource.ResourcePath) { [string]$resource.ResourcePath } else { 'No resource ID recorded' }) + [string]$resource.ResourceType + (Format-BudgetAmount -Value $resource.Actual -Currency $resource.Currency) + $(if ($resource.ActualPeriod) { [string]$resource.ActualPeriod } else { 'Not recorded' }) + ) + for ($cellIndex = 0; $cellIndex -lt $driverCells.Count; $cellIndex++) { + $cellClass = if ($cellIndex -eq 3) { ' class="numeric-cell"' } else { '' } + $cellHtml = if ($cellIndex -eq 1) { ConvertTo-ResourceIdentityHtml -Resource $resource } else { [System.Net.WebUtility]::HtmlEncode([string]$driverCells[$cellIndex]) } + [void]$htmlSb.Append("$cellHtml") + } + [void]$htmlSb.Append('') + } + } + [void]$htmlSb.Append('
SubscriptionResource or chargeTypeActual costCost period
') + } + else { [void]$htmlSb.Append('

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

') } + if (@($driverRows | Where-Object ActualPeriodSource -EQ 'Query window').Count -gt 0) { + [void]$htmlSb.Append('

API cost periods are the requested UTC query window. Query windows are not proof that billing data is complete through the end timestamp.

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

All returned resource costs

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

Scan status

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

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

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

Review next

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

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

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

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

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

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

") + + $measured = @($domKpis | Where-Object { $_.status -eq 'computed' -and $_.yourValue }) + foreach ($kpi in $measured) { + [void]$htmlSb.Append('
') + # The formal FinOps definition sits on the title so the card stays readable. + $defAttr = if ($kpi.definition) { " title=`"$([System.Net.WebUtility]::HtmlEncode([string]$kpi.definition))`"" } else { '' } + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$kpi.kpiName))
") + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$kpi.yourValue))
") + if ($kpi.plainLanguage) { + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$kpi.plainLanguage))
") + } + if ($kpi.exploreHint) { + [void]$htmlSb.Append("
Next step $([System.Net.WebUtility]::HtmlEncode([string]$kpi.exploreHint))
") + } + [void]$htmlSb.Append('
') + } + + $notMeasured = @($domKpis | Where-Object { $_.status -ne 'computed' -or -not $_.yourValue }) + if ($notMeasured.Count -gt 0) { + [void]$htmlSb.Append('

Not measured

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

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

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

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

") + } + [void]$htmlSb.Append('
') + } + + if ($kpiReference.Count -gt 0) { + [void]$htmlSb.Append('

KPI reference

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

No universal healthy value applies across workloads. Compare matched scope, currency, reporting period, cost basis, and service requirements. Computed means a value was derived, not that the environment is optimized; some values are estimates or proxies.

') + [void]$htmlSb.Append('
') + [void]$htmlSb.Append("$($kpiReference.Count) of $($kpiReference.Count) KPIs
") + [void]$htmlSb.Append('
') + foreach ($entry in $kpiReference) { + $referenceId = 'kpi-' + ($entry.Id -replace '[^A-Za-z0-9-]', '') + $searchText = [System.Net.WebUtility]::HtmlEncode((@($entry.Name, $entry.Definition, $entry.Domain, $entry.SourceName, $entry.Calculation, ($entry.RequiredInputs -join ' ')) -join ' ')) + $statusText = [System.Net.WebUtility]::HtmlEncode($entry.Status) + [void]$htmlSb.Append("") + } + [void]$htmlSb.Append('
Metric and definitionCurrent resultSource scan
$([System.Net.WebUtility]::HtmlEncode($entry.Name))

$([System.Net.WebUtility]::HtmlEncode($entry.Definition))

Calculation and interpretation
Calculation
$([System.Net.WebUtility]::HtmlEncode($entry.Calculation))
Required inputs
    ") + foreach ($inputName in $entry.RequiredInputs) { [void]$htmlSb.Append("
  • $([System.Net.WebUtility]::HtmlEncode($inputName))
  • ") } + [void]$htmlSb.Append("
Interpretation and target
$([System.Net.WebUtility]::HtmlEncode($entry.Interpretation))
Limits
$([System.Net.WebUtility]::HtmlEncode($entry.Limitations))
$statusText

$([System.Net.WebUtility]::HtmlEncode($entry.Value))

") + if ($entry.Context) { [void]$htmlSb.Append("

$([System.Net.WebUtility]::HtmlEncode($entry.Context))

") } + [void]$htmlSb.Append('
') + if ($entry.SourceSelected) { + $target = 'tab-' + ($entry.SourceCategory -replace '[^A-Za-z0-9]', '') + $anchor = 'scan-' + ($entry.SourceFunction -replace '[^a-zA-Z0-9\-]', '') + [void]$htmlSb.Append("$([System.Net.WebUtility]::HtmlEncode($entry.SourceName))") + } + else { [void]$htmlSb.Append([System.Net.WebUtility]::HtmlEncode($entry.SourceName)) } + [void]$htmlSb.Append("

$([System.Net.WebUtility]::HtmlEncode($entry.Unit))

') + } + + $orderedMods = @(foreach ($c in $tabCats) { $selectedMods | Where-Object { $_.Category -eq $c } }) + $currentCat = $null + foreach ($mod in $orderedMods) { + if ($mod.Category -ne $currentCat) { + if ($null -ne $currentCat) { [void]$htmlSb.Append('') } + $currentCat = $mod.Category + $paneCls = if (-not $hasStory -and $tabCats[0] -eq $currentCat) { 'tabpane active' } else { 'tabpane' } + $paneId = 'tab-' + ($currentCat -replace '[^A-Za-z0-9]', '') + [void]$htmlSb.Append("
") + } + $fn = $mod.Fn + $data = $Results[$fn] + $eName = [System.Net.WebUtility]::HtmlEncode($mod.Name) + $scanAnchor = 'scan-' + ($fn -replace '[^a-zA-Z0-9\-]', '') + [void]$htmlSb.Append("

$eName

") + # Anything appended past this point counts as content for the section. + $sectionMark = $htmlSb.Length + # Summaries, notes, and guidance render together in one scrollable panel under the heading. + $notesSb = [System.Text.StringBuilder]::new() + + $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 + $tableNote = $null + $htmlTableClass = 'report-table' + switch ($fn) { + 'Get-OrphanedResources' { + if ($data.MonthlyCost) { + [void]$notesSb.Append("

Observed cost ($([System.Net.WebUtility]::HtmlEncode([string]$data.CostPeriod))): $([System.Net.WebUtility]::HtmlEncode((Format-BudgetAmount -Value $data.MonthlyCost -Currency $data.Currency))) across $($data.CostedCount) of $($data.TotalCount) resources

") + } + if ($data.CostIssue) { + [void]$notesSb.Append("
Cost column incomplete: $([System.Net.WebUtility]::HtmlEncode([string]$data.CostIssue)). An empty cost cell below means the lookup failed, not that the resource is free.
") + } + # 'n/a' when the lookup failed, '-' when it succeeded and the resource simply had no spend. + $noCostHtml = if ($data.CostAvailable) { '-' } else { 'n/a' } + $costColHtml = if ($data.CostPeriod) { [string]$data.CostPeriod } else { 'Cost' } + $htmlRows = $data.Orphans | ForEach-Object { + $o = [ordered]@{ + Category = $_.Category + ResourceName = $_.ResourceName + ResourceGroup = $_.ResourceGroup + } + $o[$costColHtml] = if ($null -ne $_.MonthlyCost) { Format-BudgetAmount -Value $_.MonthlyCost -Currency $_.Currency } else { $noCostHtml } + $o['Detail'] = $_.Detail + [PSCustomObject]$o + } + $htmlCols = @('Category', 'ResourceName', 'ResourceGroup', $costColHtml, 'Detail') + $tableNote = 'Cost is actual billed spend over the stated period, not a projection. A deallocated VM bills nothing on the VM object itself, so its attached managed disks are rolled into its row - those disks are excluded from the orphaned disk rows, so nothing is double counted. A resource stopped part way through the period shows what it incurred while still running, so the ongoing saving is lower than the figure shown.' + } + 'Get-IdleVMs' { + [void]$notesSb.Append("

Scanned $($data.ScannedVMs) running VMs

") + $htmlRows = $data.IdleVMs + $htmlCols = @('VMName', 'ResourceGroup', 'VMSize', 'AvgCPU14d', 'Classification') + } + 'Get-StorageTierAdvice' { + [void]$notesSb.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 ($null -ne $_.estMonthlySavings) { "$(Format-BudgetAmount -Value $_.estMonthlySavings -Currency $data.SavingsCurrency)/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' { + $tagCountHtml = if ($data.SpellingCount -and $data.SpellingCount -ne $data.TagCount) { "$($data.TagCount) unique tag keys ($($data.SpellingCount) spellings)" } else { "$($data.TagCount) unique tags" } + $coverageLabel = if ($data.CoverageIncomplete -or $null -eq $data.TagCoverage) { 'Unverified' } else { [System.Net.WebUtility]::HtmlEncode("$($data.TagCoverage)%") } + $taggedLabel = if ($null -ne $data.TaggedCount) { [System.Net.WebUtility]::HtmlEncode([string]$data.TaggedCount) } else { 'Unknown' } + $untaggedLabel = if ($null -ne $data.UntaggedCount) { [System.Net.WebUtility]::HtmlEncode([string]$data.UntaggedCount) } else { 'Unknown' } + [void]$notesSb.Append("

Coverage: $coverageLabel  |  $taggedLabel tagged / $untaggedLabel untagged  |  $tagCountHtml

") + if ($data.CaseVariants -and @($data.CaseVariants).Count -gt 0) { + [void]$notesSb.Append("
$(@($data.CaseVariants).Count) tag-key spelling groups

Azure resolves tag keys case-insensitively. Resource Graph and cost exports can report their spellings separately.

") + foreach ($variant in $data.CaseVariants) { + [void]$notesSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$variant.TagKey))
$([System.Net.WebUtility]::HtmlEncode([string]$variant.Detail))
") + } + [void]$notesSb.Append('
') + } + if ($data.TagNames) { + $htmlRows = $data.TagNames.GetEnumerator() | Sort-Object { $_.Value.TotalResources } -Descending | ForEach-Object { + $vals = @($_.Value.Values | Sort-Object ResourceCount -Descending) + $valText = (@($vals | Select-Object -First 5 | ForEach-Object { "$($_.Value) ($($_.ResourceCount))" }) -join ', ') + [PSCustomObject]@{ Tag = $_.Key; Resources = $_.Value.TotalResources; Values = $vals.Count; 'Top values' = $valText; _MoreValues = @($vals | Select-Object -Skip 5) } + } + $htmlCols = @('Tag', 'Resources', 'Values', 'Top values') + $htmlTableClass = 'report-table table-tags' + $tableNote = 'Values are the distinct tag values in use, with the resource count for each. A tag with a single value provides no allocation granularity; a tag with many near-identical values indicates inconsistent tagging.' + if ($data.CoverageIncomplete) { $tableNote = "$($data.Note) $tableNote" } + } + } + 'Get-CostData' { + if ($data -is [hashtable]) { + $htmlRows = $data.GetEnumerator() | ForEach-Object { + $sl = if ($subNameLookup.ContainsKey($_.Key)) { $subNameLookup[$_.Key] } else { $_.Key } + [PSCustomObject]@{ + Subscription = $sl + Actual = Format-BudgetAmount -Value $_.Value.Actual -Currency $_.Value.Currency + ActualPeriod = if ($_.Value.ActualPeriod) { $_.Value.ActualPeriod } else { 'Current month' } + Forecast = if ($_.Value.ForecastSource -eq 'Actual') { 'Unavailable' } else { Format-BudgetAmount -Value $_.Value.Forecast -Currency $_.Value.Currency } + ForecastSource = if ($_.Value.ForecastSource) { $_.Value.ForecastSource } else { 'Unavailable' } + Currency = $_.Value.Currency + } + } + $htmlCols = @('Subscription', 'Actual', 'ActualPeriod', 'Forecast', 'ForecastSource', 'Currency') + } + } + 'Get-ResourceCosts' { + $htmlRows = @($data) | Sort-Object { $_.Actual } -Descending | ForEach-Object { + [PSCustomObject]@{ + Subscription = if ($_.Subscription) { $_.Subscription } else { 'Not attributed' } + Resource = if ($_.ResourceName) { $_.ResourceName } elseif ($_.ResourcePath) { $_.ResourcePath } else { 'No resource ID recorded' } + ResourceGroup = $_.ResourceGroup + ResourceType = $_.ResourceType + Cost = Format-BudgetAmount -Value $_.Actual -Currency $_.Currency + ActualPeriod = if ($_.ActualPeriod) { $_.ActualPeriod } else { 'Not recorded' } + 'Cost period' = if ($_.ActualPeriod) { $_.ActualPeriod } else { 'Not recorded' } + ResourceName = $_.ResourceName + ResourcePath = $_.ResourcePath + } + } + $htmlCols = @('Subscription', 'Resource', 'ResourceGroup', 'ResourceType', 'Cost', 'Cost period') + if (@($data | Where-Object ActualPeriodSource -EQ 'Query window').Count -gt 0) { + $tableNote = 'API cost periods are the requested UTC query window. Query windows are not proof that billing data is complete through the end timestamp.' + } + } + '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 = Format-BudgetAmount -Value $v.Cost -Currency $v.Currency } + } + } + $htmlCols = @('Tag', 'Value', 'Cost') + $htmlTableClass = 'report-table table-cost-by-tag' + $tableNote = 'Each tag is measured on its own, so a resource missing that tag counts as (untagged) for it and appears once per tag it lacks. Costs overlap between tags and do not sum to total spend.' + if ($data.CoverageIncomplete) { $tableNote = "$($data.Note) $tableNote" } + } + } + 'Get-CostTrend' { + $trendNames = @{} + foreach ($subscription in $Subscriptions) { + if ($subscription.Id) { $trendNames[[string]$subscription.Id] = if ($subscription.Name) { [string]$subscription.Name } else { [string]$subscription.Id } } + } + if ($trendNames.Count -eq 0 -and $data.SubscriptionNames) { + foreach ($subscriptionId in $data.SubscriptionNames.Keys) { $trendNames[$subscriptionId] = [string]$data.SubscriptionNames[$subscriptionId] } + } + if ($trendNames.Count -eq 0 -and $data.BySubscription) { + foreach ($subscriptionId in $data.BySubscription.Keys) { $trendNames[$subscriptionId] = [string]$subscriptionId } + } + $trendIds = @($trendNames.Keys | Sort-Object { $trendNames[$_] }, { $_ }) + $coverageRecorded = $null -ne $data.SelectedSubscriptionCount -and $null -ne $data.CoverageIncomplete + $selectedCount = if ($coverageRecorded) { $data.SelectedSubscriptionCount } else { @($Subscriptions).Count } + $returnedCount = @($trendIds | Where-Object { $data.BySubscription -and @($data.BySubscription[$_] | Where-Object { $_ }).Count -gt 0 }).Count + $coverageLabel = if ($selectedCount -gt 0) { "Returned rows: $returnedCount of $selectedCount selected subscriptions" } else { "Returned rows: $returnedCount subscriptions; selected scope not recorded" } + $basisLabel = switch ($data.CostBasis) { 'ActualCost' { 'Actual cost' } 'AmortizedCost' { 'Amortized cost' } default { 'Cost basis not recorded' } } + $periodLabel = if ($data.Source -eq 'Export' -and $data.ActualPeriod) { "Export data period: $($data.ActualPeriod)" } else { 'Query window not recorded' } + $partialMonth = $null + if ($data.CostPeriodStartUtc -and $data.CostPeriodEndUtc) { + $periodStart = ([datetime]$data.CostPeriodStartUtc).ToUniversalTime() + $periodEnd = ([datetime]$data.CostPeriodEndUtc).ToUniversalTime() + $periodLabel = $periodStart.ToString('yyyy-MM-dd HH:mm:ss', [cultureinfo]::InvariantCulture) + ' to ' + $periodEnd.ToString('yyyy-MM-dd HH:mm:ss', [cultureinfo]::InvariantCulture) + ' UTC' + $partialMonth = $periodEnd.Date.AddDays(1 - $periodEnd.Day) + } + [void]$notesSb.Append("

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

$basisLabel
$([System.Net.WebUtility]::HtmlEncode($periodLabel))

") + if (-not $coverageRecorded) { + [void]$notesSb.Append('

Coverage metadata not recorded. The aggregate is based on returned rows and is not a verified selected-scope or whole-tenant total.

') + } + else { + $individualCount = @($data.IndividuallyQueriedIds | Where-Object { $_ }).Count + $individualLabel = if ($individualCount -gt 0) { "Queried individually: $individualCount. " } else { '' } + [void]$notesSb.Append("

${individualLabel}Confirmed empty: $(@($data.NoDataSubscriptionIds).Count). Unverified: $(@($data.UnverifiedSubscriptionIds).Count).

") + if ($data.Note) { [void]$notesSb.Append("

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

") } + $trendQueryErrors = @($data.QueryErrors | Where-Object { $_ }) + if ($trendQueryErrors.Count -gt 0) { + [void]$notesSb.Append("
Subscriptions not added ($($trendQueryErrors.Count))
    ") + foreach ($queryError in $trendQueryErrors) { [void]$notesSb.Append("
  • $([System.Net.WebUtility]::HtmlEncode([string]$queryError))
  • ") } + [void]$notesSb.Append('
') + } + } + if ($data.QueryScope) { + [void]$notesSb.Append("
Query scope
$([System.Net.WebUtility]::HtmlEncode([string]$data.QueryScope))
") + } + [void]$notesSb.Append('

Query windows do not establish billing-data completeness. Unreturned months are not filled with zero cost.

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

') + $aggregateLabel = if ($coverageRecorded -and -not $data.CoverageIncomplete) { 'Selected-scope aggregate' } else { 'Returned aggregate (coverage not verified)' } + $trendSeries = @([pscustomobject]@{ Id = 'aggregate'; Label = $aggregateLabel; Months = @($data.Months | Where-Object { $_ }); EmptyMessage = 'No cost rows were returned for the trend period.' }) + foreach ($subscriptionId in $trendIds) { + $emptyMessage = if ($subscriptionId -in $data.NoDataSubscriptionIds) { 'No cost rows were returned for this subscription.' } else { 'Coverage is not verified for this subscription.' } + $subscriptionMonths = if ($data.BySubscription) { @($data.BySubscription[$subscriptionId] | Where-Object { $_ }) } else { @() } + $trendSeries += [pscustomobject]@{ Id = $subscriptionId; Label = $trendNames[$subscriptionId]; Months = @($subscriptionMonths); EmptyMessage = $emptyMessage } + } + foreach ($series in $trendSeries) { + $encodedId = [System.Net.WebUtility]::HtmlEncode([string]$series.Id) + $encodedLabel = [System.Net.WebUtility]::HtmlEncode([string]$series.Label) + $hidden = if ($series.Id -eq 'aggregate') { '' } else { ' hidden' } + [void]$htmlSb.Append("

$encodedLabel

") + if ($series.Id -ne 'aggregate') { [void]$htmlSb.Append("

$encodedId

") } + if ($series.Months.Count -gt 0) { + [void]$htmlSb.Append('
') + foreach ($trendMonth in ($series.Months | Sort-Object MonthDate)) { + $monthLabel = [string]$trendMonth.Month + if ($null -ne $partialMonth -and $trendMonth.MonthDate -and ([datetime]$trendMonth.MonthDate).Date -eq $partialMonth) { $monthLabel += ' (partial)' } + $amount = Format-BudgetAmount -Value $trendMonth.Cost -Currency $trendMonth.Currency + [void]$htmlSb.Append("") + } + [void]$htmlSb.Append('
MonthCostCurrency
$([System.Net.WebUtility]::HtmlEncode($monthLabel))$([System.Net.WebUtility]::HtmlEncode($amount))$([System.Net.WebUtility]::HtmlEncode([string]$trendMonth.Currency))
') + } + else { [void]$htmlSb.Append("

$([System.Net.WebUtility]::HtmlEncode([string]$series.EmptyMessage)) No zero-valued months were added.

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

Est. annual savings: $([System.Net.WebUtility]::HtmlEncode((Format-BudgetAmount -Value $data.EstimatedAnnualSavings -Currency $data.Currency)))

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

Reservation purchase detail

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

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

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

Advisor recommendations

') + } + + $htmlRows = $data.AdvisorRecommendations | ForEach-Object { + [PSCustomObject]@{ + Resource = ($_.ResourceName -split '/')[-1] + Type = ($_.ResourceType -split '/')[-1] + SKU = $_.SKU + Region = $_.Region + Qty = $_.Qty + Term = $_.Term + Savings = Format-BudgetAmount -Value $_.AnnualSavings -Currency $_.Currency + Impact = $_.Impact + } + } + $htmlCols = @('Resource', 'Type', 'SKU', 'Region', 'Qty', 'Term', 'Savings', 'Impact') + } + 'Get-CommitmentUtilization' { + [void]$notesSb.Append("

Reservations: $($data.RICount) (average $(Format-ReportMetric $data.RIAvgUtilization -Format '0.#' -Suffix '%'))  |  Savings plans: $($data.SPCount) (average $(Format-ReportMetric $data.SPAvgUtilization -Format '0.#' -Suffix '%'))

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

Billing-scope results can include commitments beyond the selected subscriptions. Averages are unweighted and use the latest returned period per commitment. Missing metadata is not proof of denied access.

') + $htmlRows = @( + foreach ($reservation in @($data.Reservations | Where-Object { $_ })) { + $name = if ($reservation.Name) { $reservation.Name } else { $reservation.ReservationId } + [pscustomobject]@{ Type = 'Reservation'; Commitment = $name; ResourceName = $name; ResourcePath = $reservation.ResourceId; SKU = if ($reservation.SkuName) { $reservation.SkuName } else { 'Not returned' }; Kind = if ($reservation.Kind) { $reservation.Kind } else { 'Not returned' }; 'Avg utilization' = Format-ReportMetric $reservation.AvgUtilization -Format '0.#' -Suffix '%'; 'Usage period' = $reservation.UsageDate } + } + foreach ($plan in @($data.SavingsPlans | Where-Object { $_ })) { + $identity = if ($plan.BenefitId) { $plan.BenefitId } else { $plan.BenefitOrderId } + [pscustomobject]@{ Type = 'Savings plan'; Commitment = $identity; ResourceName = $identity; ResourcePath = $identity; SKU = 'Not returned'; Kind = $plan.BenefitType; 'Avg utilization' = Format-ReportMetric $plan.AvgUtilization -Format '0.#' -Suffix '%'; 'Usage period' = $plan.UsageDate } + } + ) + $htmlCols = @('Type', 'Commitment', 'SKU', 'Kind', 'Avg utilization', 'Usage period') + if ($data.Note) { [void]$notesSb.Append("

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

") } + if ($data.MetadataErrors) { [void]$notesSb.Append("

Metadata could not be verified for $(@($data.MetadataErrors).Count) reservation(s). Available utilization and reservation IDs are retained.

") } + } + 'Get-SavingsRealized' { + [void]$notesSb.Append("

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

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

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

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

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

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

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

") } + if ($data.EstimateBasis) { $tableNote = [string]$data.EstimateBasis } + } + 'Get-BudgetStatus' { + $htmlCoverage = if ($data.CoverageIncomplete) { + "unverified (read $($data.ScannedSubs) of $($data.TotalSubs) subs)" + } + else { "$($data.BudgetCoverage)%" } + [void]$notesSb.Append("

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

") + $htmlRows = $data.Budgets | ForEach-Object { + $riskClass = switch ($_.Risk) { 'Over Budget' { 'severity-red' } 'On Track' { 'severity-green' } default { 'severity-yellow' } } + [PSCustomObject]@{ + Budget = $_.BudgetName + Amount = Format-BudgetAmount -Value $_.Amount -Currency $_.Currency + Spent = Format-BudgetAmount -Value $_.ActualSpend -Currency $_.Currency + Forecast = Format-BudgetAmount -Value $_.Forecast -Currency $_.Currency + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } + Risk = $_.Risk; Note = $_.Note; _riskClass = $riskClass + } + } + $htmlCols = @('Budget', 'Amount', 'Spent', 'Forecast', 'PctUsed', 'Risk', 'Note') + } + 'Get-AnomalyAlerts' { + [void]$notesSb.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' { + [void]$notesSb.Append("

Est. annual savings: $([System.Net.WebUtility]::HtmlEncode((Format-BudgetAmount -Value $data.EstimatedAnnualSavings -Currency $data.Currency)))  |  $($data.TotalCount) recommendations

") + if ($data.CostIssue) { $tableNote = [string]$data.CostIssue } + $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 = "$(Format-BudgetAmount -Value $_.AnnualSavings -Currency $_.Currency)/yr" } + } + $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; ScopeDisplayName = $_.ScopeDisplayName } } + $htmlCols = @('Name', 'Effect', 'Enforcement', 'Scope') + } + 'Get-PolicyRecommendations' { + $htmlRows = $data.Analysis | ForEach-Object { + $assignmentLabels = @($_.MatchedAssignments | ForEach-Object { "$($_.AssignmentName) [$($_.Source); $($_.EnforcementMode); $($_.Scope)]" }) + $detailLabel = if ($assignmentLabels.Count -eq 1) { '1 assignment' } elseif ($assignmentLabels.Count -gt 1) { "$($assignmentLabels.Count) assignments" } else { 'Policy details' } + [PSCustomObject]@{ Policy = $_.DisplayName; Status = $_.Status; Category = $_.Category; Priority = $_.Priority; Effect = $_.DefaultEffect; Assignments = ($assignmentLabels -join '; '); Purpose = $_.Purpose; Note = $_.Note; Details = $detailLabel; _AssignmentDetails = @($_.MatchedAssignments) } + } + $htmlCols = @('Policy', 'Status', 'Priority', 'Effect', 'Details') + $htmlTableClass = 'report-table table-policies' + $tableNote = 'Assignment coverage compares recommended definition IDs with the reported assignments and their initiative members. It does not measure enforcement or resource compliance.' + } + 'Get-BillingStructure' { + $htmlRows = $data.BillingAccounts | ForEach-Object { [PSCustomObject]@{ Account = $_.DisplayName; Agreement = $_.AgreementType; Type = $_.AccountType; Status = $_.AccountStatus } } + $htmlCols = @('Account', 'Agreement', 'Type', 'Status') + } + 'Get-ContractInfo' { + $htmlRows = @($data) | ForEach-Object { [PSCustomObject]@{ Account = $_.AccountName; Agreement = $_.AgreementType; Type = $_.FriendlyType; Country = $_.SoldToCountry; Status = $_.AccountStatus } } + $htmlCols = @('Account', 'Agreement', 'Type', 'Country', 'Status') + } + 'Get-BudgetHistory' { + $htmlRows = @($data) | Where-Object { $_ } | ForEach-Object { + [PSCustomObject]@{ + Subscription = $_.Subscription + Budget = $_.BudgetName + Month = $_.Month + Budgeted = Format-BudgetAmount -Value $_.BudgetAmount -Currency $_.Currency + Actual = Format-BudgetAmount -Value $_.ActualSpend -Currency $_.Currency + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } + Status = $_.Status + Note = $_.Note + } + } + $htmlCols = @('Subscription', 'Budget', 'Month', 'Budgeted', 'Actual', 'PctUsed', 'Status', 'Note') + } + 'Get-CarbonMetrics' { + $cLatest = [System.Net.WebUtility]::HtmlEncode([string]$data.LatestMonth) + $cUnit = [System.Net.WebUtility]::HtmlEncode([string]$data.Unit) + $emissions = if ($null -ne $data.TotalEmissionsKg) { "$($data.TotalEmissionsKg) $cUnit" } else { 'Unavailable' } + $changeLabel = if ($null -ne $data.ChangeRatio) { "$($data.ChangeRatio)%" } else { 'Unavailable' } + [void]$notesSb.Append("

Latest month ($cLatest): $emissions  |  month over month $changeLabel

") + if ($data.Note) { $tableNote = [string]$data.Note } + $htmlRows = $data.BySubscription | Where-Object { $_ } | ForEach-Object { + [PSCustomObject]@{ Subscription = $_.Subscription; Emissions = "$($_.EmissionsKg) kg" } + } + $htmlCols = @('Subscription', 'Emissions') + } + 'Get-UnitEconomics' { + $computeAmount = [System.Net.WebUtility]::HtmlEncode((Format-BudgetAmount -Value $data.ComputeCost -Currency $data.Currency)) + $storageAmount = [System.Net.WebUtility]::HtmlEncode((Format-BudgetAmount -Value $data.StorageCost -Currency $data.Currency)) + $computeShare = if ($null -ne $data.ComputeSharePct) { [System.Net.WebUtility]::HtmlEncode("$($data.ComputeSharePct)% of VM compute + storage spend") } else { 'Unavailable' } + $storageShare = if ($null -ne $data.StorageSharePct) { [System.Net.WebUtility]::HtmlEncode("$($data.StorageSharePct)% of VM compute + storage spend") } else { 'Unavailable' } + [void]$notesSb.Append("

Compute: $computeAmount ($computeShare) over $($data.VmCount) VMs, $($data.TotalVCpu) vCPU, $($data.TotalMemoryGb) GB RAM

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

Storage: $storageAmount ($storageShare) over $($data.TotalStorageGb) GB

") + $unitContext = Get-FinOpsUnitCostContext -Data $data + [void]$notesSb.Append("

$([System.Net.WebUtility]::HtmlEncode($unitContext.Summary))

") + [void]$notesSb.Append('
Calculation and thresholds') + foreach ($description in @($unitContext.Formula, $unitContext.Capacity, $unitContext.Target)) { + [void]$notesSb.Append("

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

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

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

") + $htmlRows = $data.LegacyResources | Where-Object { $_ } | ForEach-Object { + [PSCustomObject]@{ Category = $_.Category; Resource = $_.ResourceName; Detail = $_.Detail; Impact = $_.Impact } + } + $htmlCols = @('Category', 'Resource', 'Detail', 'Impact') + } + 'Get-AIWorkloadMetrics' { + if ($data.HasData) { + $fp = $data.AIFootprint + $aiAmount = [System.Net.WebUtility]::HtmlEncode((Format-BudgetAmount -Value $data.TotalAICost -Currency $data.Currency)) + $periodLabel = if ($data.Period -eq 'MonthToDate') { 'Month to date' } elseif ($data.Period) { [string]$data.Period } else { 'Unknown period' } + if ($data.UsagePeriodStartUtc -and $data.UsagePeriodEndUtc) { + $periodLabel = ([datetime]$data.UsagePeriodStartUtc).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss', [cultureinfo]::InvariantCulture) + ' to ' + ([datetime]$data.UsagePeriodEndUtc).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss', [cultureinfo]::InvariantCulture) + ' UTC' + } + $aPeriod = [System.Net.WebUtility]::HtmlEncode($periodLabel) + [void]$notesSb.Append("

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

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

Period: $aPeriod
Tokens: $(Format-ReportMetric $data.TotalTokens)  |  Requests: $(Format-ReportMetric $data.TotalRequests)  |  AI account cost: $aiAmount

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

Cost covers Microsoft.CognitiveServices/accounts, not the ML, Search, or GPU inventory. Effective account rates are not per-model prices or a billing reconciliation. Incomplete usage leaves rates unavailable.

') + $accountRows = @($data.ByAccount | Where-Object { $_ }) + if ($accountRows.Count -gt 0) { + [void]$htmlSb.Append('

Account costs

') + [void]$htmlSb.Append((ConvertTo-ReportTableControlHtml -TableId 'table-ai-accounts' -Title 'AI account costs' -RowCount $accountRows.Count)) + [void]$htmlSb.Append('
') + foreach ($account in $accountRows) { + $identity = [pscustomobject]@{ ResourceName = $account.Name; ResourcePath = $account.ResourceId } + [void]$htmlSb.Append("") + $cells = @((Format-ReportMetric $account.Tokens), (Format-ReportMetric $account.Requests), (Format-BudgetAmount -Value $account.Cost -Currency $account.Currency), (Format-FinOpsUnitRate -Value $account.CostPer1KTokens -Currency $account.Currency)) + foreach ($cell in $cells) { [void]$htmlSb.Append("") } + $measurements = if ($data.Source -eq 'FinOpsHub') { 'Billed token quantity; requests not recorded' } elseif ($account.MetricsComplete -eq $true) { 'Returned' } elseif ($account.MetricsComplete -eq $false) { 'Incomplete' } else { 'Not recorded' } + [void]$htmlSb.Append("") + } + [void]$htmlSb.Append('
AccountTokensRequestsCostCost per 1K tokensMeasurements
$(ConvertTo-ResourceIdentityHtml -Resource $identity)$([System.Net.WebUtility]::HtmlEncode([string]$cell))$measurements
') + } + if ($data.ByModel -and @($data.ByModel | Where-Object { $_ }).Count -gt 0) { + [void]$htmlSb.Append('

Token usage

') + $htmlRows = $data.ByModel | ForEach-Object { [pscustomobject]@{ 'Deployment or model' = $_.Deployment; Account = if ($_.Account) { $_.Account } else { 'Not recorded' }; ResourceName = $_.Account; ResourcePath = $_.ResourceId; 'Input tokens' = Format-ReportMetric $_.PromptTokens; 'Output tokens' = Format-ReportMetric $_.GeneratedTokens; 'Total tokens' = Format-ReportMetric $_.TotalTokens; 'Token share' = Format-ReportMetric $_.PctOfTokens -Format '0.#' -Suffix '%'; 'Token basis' = if ($_.TokenBasis) { $_.TokenBasis } else { 'Not recorded' } } } + $htmlCols = @('Deployment or model', 'Account', 'Input tokens', 'Output tokens', 'Total tokens', 'Token share', 'Token basis') + $htmlTableClass = 'report-table table-ai-tokens' + } + if ($data.Note) { $tableNote = [string]$data.Note } + } + else { + [void]$htmlSb.Append('
No AI workloads detected.
') + } + } + 'Get-MaccCommitment' { + if ($data.CoverageIncomplete) { $tableNote = [string]$data.Reason } + if ($data.Applicable -and $data.HasMacc -and @($data.Commitments | Where-Object { $_ }).Count -gt 0) { + $htmlRows = @($data.Commitments) | ForEach-Object { + [PSCustomObject]@{ + Account = $_.BillingAccount + Commitment = Format-BudgetAmount -Value $_.Commitment -Currency $_.Currency + Consumed = Format-BudgetAmount -Value $_.Consumed -Currency $_.Currency + Remaining = Format-BudgetAmount -Value $_.Remaining -Currency $_.Currency + PctUsed = if ($null -ne $_.PctUsed) { "$($_.PctUsed)%" } else { 'Unavailable' } + Status = $_.Status + Expires = $_.ExpirationDate + } + } + $htmlCols = @('Account', 'Commitment', 'Consumed', 'Remaining', 'PctUsed', 'Status', 'Expires') + } + elseif ($data.Reason) { + [void]$htmlSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$data.Reason))
") + } + } + } + + # Render HTML table + if ($htmlRows -and $htmlCols) { + $tableId = 'table-' + ($fn -replace '[^a-zA-Z0-9\-]', '') + [void]$htmlSb.Append((ConvertTo-ReportTableControlHtml -TableId $tableId -Title $mod.Name -RowCount @($htmlRows).Count)) + [void]$htmlSb.Append("
") + if ($fn -in @('Get-TagInventory', 'Get-PolicyRecommendations')) { + [void]$htmlSb.Append('') + foreach ($column in $htmlCols) { [void]$htmlSb.Append('') } + [void]$htmlSb.Append('') + } + [void]$htmlSb.Append('') + foreach ($c in $htmlCols) { + $columnLabel = if ($c -eq 'AvgCPU14d') { 'Avg CPU (14 days)' } else { $c } + [void]$htmlSb.Append("") + } + [void]$htmlSb.Append('') + foreach ($r in $htmlRows) { + [void]$htmlSb.Append('') + foreach ($c in $htmlCols) { + $val = $r.$c + $raw = [string]$val + if ($fn -eq 'Get-IdleVMs' -and $c -eq 'AvgCPU14d') { $raw = Format-ReportMetric $val -Format '0.#' -Suffix '%' } + $enc = [System.Net.WebUtility]::HtmlEncode($raw) + # Colorize money values and risk/severity + if ($enc -match '^\$') { $enc = "$enc" } + if ($c -eq 'Risk' -and $r.PSObject.Properties['_riskClass']) { $enc = "$enc" } + if ($c -eq 'Impact') { + $impClass = switch ($val) { 'High' { 'severity-red' } 'Medium' { 'severity-yellow' } default { 'severity-green' } } + $enc = "$enc" + } + $cellClass = if ($c -in @('Resources', 'Values', 'Cost', 'Actual', 'Forecast', 'Amount', 'Spent', 'PctUsed', 'AvgCPU14d', 'AvgUtil', 'MinUtil', 'PromptTokens', 'GeneratedTokens', 'TotalTokens', 'PctOfTokens', 'Input tokens', 'Output tokens', 'Total tokens', 'Token share', 'Avg utilization')) { ' class="numeric-cell"' } else { '' } + [void]$htmlSb.Append("") + if ($fn -eq 'Get-ResourceCosts' -and $c -eq 'Resource') { + [void]$htmlSb.Append((ConvertTo-ResourceIdentityHtml -Resource $r)) + } + elseif (($fn -eq 'Get-CommitmentUtilization' -and $c -eq 'Commitment') -or ($fn -eq 'Get-AIWorkloadMetrics' -and $c -eq 'Account' -and $r.ResourcePath)) { + [void]$htmlSb.Append((ConvertTo-ResourceIdentityHtml -Resource $r)) + } + elseif ($fn -eq 'Get-PolicyInventory' -and $c -eq 'Scope') { + [void]$htmlSb.Append((ConvertTo-PolicyScopeHtml -Assignment $r)) + } + elseif ($fn -eq 'Get-TagInventory' -and $c -eq 'Top values') { + [void]$htmlSb.Append("

$enc

") + if ($r._MoreValues.Count -gt 0) { + [void]$htmlSb.Append("
$($r._MoreValues.Count) more values
    ") + foreach ($tagValue in $r._MoreValues) { + [void]$htmlSb.Append("
  • $([System.Net.WebUtility]::HtmlEncode([string]$tagValue.Value)) ($([System.Net.WebUtility]::HtmlEncode([string]$tagValue.ResourceCount)))
  • ") + } + [void]$htmlSb.Append('
') + } + } + elseif ($fn -eq 'Get-PolicyRecommendations' -and $c -eq 'Details') { + [void]$htmlSb.Append("
$enc
") + foreach ($detailName in @('Category', 'Purpose', 'Note')) { + if ($r.$detailName) { + [void]$htmlSb.Append("
$detailName
$([System.Net.WebUtility]::HtmlEncode([string]$r.$detailName))
") + } + } + [void]$htmlSb.Append('
') + if ($r._AssignmentDetails.Count -gt 0) { + [void]$htmlSb.Append('
    ') + foreach ($assignment in $r._AssignmentDetails) { + [void]$htmlSb.Append("
  • $([System.Net.WebUtility]::HtmlEncode([string]$assignment.AssignmentName))
    $([System.Net.WebUtility]::HtmlEncode([string]$assignment.Source)); $([System.Net.WebUtility]::HtmlEncode([string]$assignment.EnforcementMode))
    $(ConvertTo-PolicyScopeHtml -Assignment $assignment)
  • ") + } + [void]$htmlSb.Append('
') + } + [void]$htmlSb.Append('
') + } + else { [void]$htmlSb.Append($enc) } + [void]$htmlSb.Append('') + } + [void]$htmlSb.Append('
') + } + [void]$htmlSb.Append('
$([System.Net.WebUtility]::HtmlEncode($columnLabel))
') + if ($tableNote) { + [void]$notesSb.Append("

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

") + } + } + + $scanContext = Get-FinOpsScanContext -FunctionName $fn -Data $data + if ($scanContext) { + [void]$notesSb.Append("

$([System.Net.WebUtility]::HtmlEncode($scanContext.Summary))

") + [void]$notesSb.Append('
Calculation and thresholds') + foreach ($description in $scanContext.Details) { + [void]$notesSb.Append("

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

") + } + [void]$notesSb.Append('
') + } + + # Render guidance + if ($guidanceByFn.ContainsKey($fn)) { + foreach ($item in $guidanceByFn[$fn]) { + $gClass = switch ($item.Severity) { 'Red' { 'guidance red' } 'Yellow' { 'guidance yellow' } 'Green' { 'guidance green' } default { 'guidance' } } + [void]$notesSb.Append("
$([System.Net.WebUtility]::HtmlEncode([string]$item.Message))") + if ($item.Docs) { + $eDocs = [System.Net.WebUtility]::HtmlEncode([string]$item.Docs) + if ($item.Docs -match '^https?://') { + [void]$notesSb.Append("
$eDocs") + } + else { + [void]$notesSb.Append("
$eDocs") + } + } + [void]$notesSb.Append('
') + } + } + + if ($notesSb.Length -gt 0) { + [void]$htmlSb.Insert($sectionMark, "
$($notesSb.ToString())
") + } + # A scan that produced no table and no summary would otherwise be a bare heading. + if ($htmlSb.Length -eq $sectionMark) { + [void]$htmlSb.Append('
This scan ran but returned nothing to display.
') + } + } + + if ($null -ne $currentCat) { [void]$htmlSb.Append('
') } + [void]$htmlSb.Append('') + [void]$htmlSb.Append('
') + [void]$htmlSb.Append(@' + + +'@) + + Write-FinOpsReportFile -Directory $exportDir -Name 'FinOpsReport.html' -Lines @($htmlSb.ToString()) -ErrorAction Stop + + # Summary text file + $summaryLines = @( + "FinOps Multitool Scan Summary" + "Generated: $timestamp" + "Subscriptions: $subList" + "Total findings: $totalFindings" + if ($kpiReferenceIssue) { $kpiReferenceIssue } + "" + ) + foreach ($mod in ($Modules | Where-Object { $_.Selected })) { + $count = if ($Results[$mod.Fn]) { @($Results[$mod.Fn]).Count } else { 0 } + $errorKey = "_error_$($mod.Fn)" + $summaryData = $Results[$mod.Fn] + $summaryNote = @(@($summaryData.Note; $summaryData.Reason; $summaryData.CostIssue; $summaryData.RateIssue; $summaryData.AHBIssue; $summaryData.Error) | + Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) -join ' ' + $status = if ($Results.ContainsKey($errorKey)) { "ERROR: $($Results[$errorKey])" } + elseif ($Results['_source_Export'].CoverageIncomplete -and $mod.Fn -in @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-CostTrend')) { "Limited data: $($Results['_source_Export'].Note)" } + elseif ($summaryData.CoverageIncomplete -contains $true -or $summaryData.ComplianceCoverageIncomplete -contains $true -or $summaryData.DefinitionCoverageIncomplete -contains $true -or + $summaryData.AccessDenied -contains $true -or @(@($summaryData.CostIssue; $summaryData.RateIssue; $summaryData.AHBIssue; $summaryData.Error) | Where-Object { $_ }).Count -gt 0 -or + @($summaryData.MetricFailures | Where-Object { $_ -gt 0 }).Count -gt 0) { "Limited data: $(if ($summaryNote) { $summaryNote } else { 'Some evidence could not be verified.' })" } + elseif ($count -eq 0) { 'No data' } + else { "$count findings" } + $summaryLines += "$($mod.Name): $status" + } + Write-FinOpsReportFile -Directory $exportDir -Name 'ScanSummary.txt' -Lines $summaryLines -ErrorAction Stop + + Write-FinOpsConsole "" + Write-FinOpsConsole " Exported to: $exportDir" -ForegroundColor Green + $csvCount = @(Get-ChildItem -LiteralPath $exportDir -Filter '*.csv').Count + Write-FinOpsConsole " Files: $csvCount CSVs + FinOpsReport.html + ScanSummary.txt" -ForegroundColor DarkGray + } + catch { + $partialLocation = if ($exportDir) { " Incomplete reports may remain in '$exportDir'." } else { '' } + Write-Error -Message "Automatic report saving failed: $($_.Exception.Message). Results remain in `$FinOpsResults.$partialLocation" -ErrorId 'FinOpsReportExportFailed' -Category WriteError + } + + # Interactive drill-down + Write-FinOpsConsole "" + Write-FinOpsConsole " ─────────────────────────────────────────────────────" -ForegroundColor DarkGray + Write-FinOpsConsole " Results are stored in `$FinOpsResults. Examples:" -ForegroundColor DarkGray + Write-FinOpsConsole ' $FinOpsResults["Get-OrphanedResources"] | Format-Table' -ForegroundColor DarkGray + Write-FinOpsConsole ' $FinOpsResults["Get-IdleVMs"] | Where-Object Impact -eq "High"' -ForegroundColor DarkGray + Write-FinOpsConsole "" + + return $Results + } + + # ===================================================================== + # MAIN FLOW + # ===================================================================== + Show-Banner + + # Step 1: Connect & pick subscription + $subs = Select-Subscription -PreselectedId $SubscriptionId + if (-not $subs) { + Write-FinOpsConsole " Cancelled." -ForegroundColor Yellow + return + } + + # Capture tenant ID from current context + $tenantId = (Get-AzContext).Tenant.Id + + # Step 2: Pick data source + # Must not be named $dataSource: PowerShell variable names are case-insensitive, + # so that would reassign the -DataSource parameter and trip its ValidateSet. + $sourceChoice = Select-DataSource -TenantId $tenantId -Subscriptions $subs -Preselected $DataSource + + # 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-BudgetHistory', 'Get-AnomalyAlerts', 'Get-BillingStructure', 'Get-ContractInfo', + 'Get-UnitEconomics', 'Get-AIWorkloadMetrics', 'Get-MaccCommitment', + 'Get-VmCostBreakdown', 'Get-SharedCostAllocation', 'Get-UsageProportionalAllocation') + if ($sourceChoice.Source -eq 'GraphOnly') { + $scanModules = @($scanModules | Where-Object { $_.Fn -notin $costModuleFns }) + if (-not ($scanModules | Where-Object { $_.Selected })) { + Write-Warning "Every selected scan needs cost data, which the 'Resource Graph only' source excludes. Nothing left to run." + return + } + } + if ($sourceChoice.Source -eq 'Export') { + $unsupportedExportScans = @($costModuleFns | Where-Object { $_ -notin @('Get-CostData', 'Get-ResourceCosts', 'Get-CostByTag', 'Get-CostTrend') }) + if ($Scans -and $Scans -notcontains 'All') { + $requestedUnsupported = @($scanModules | Where-Object { $_.Selected -and $_.Fn -in $unsupportedExportScans }) + if ($requestedUnsupported.Count) { throw "The requested scan(s) are not supported by the CSV export source: $($requestedUnsupported.Fn -join ', '). Select API or Hub explicitly for those scans." } + } + $scanModules = @($scanModules | Where-Object { $_.Fn -notin $unsupportedExportScans }) + Write-FinOpsConsole ' Export mode supports cost totals, resource costs, cost by tag, and the months present in the selected export.' -ForegroundColor Cyan + Write-FinOpsConsole ' Other available scans read live inventory or metrics. Financial scans requiring separate APIs are excluded.' -ForegroundColor DarkGray + } + + # Show active data source + $sourceLabel = switch ($sourceChoice.Source) { + 'Hub' { if ($sourceChoice.HubProvider) { "FinOps Hub ($($sourceChoice.HubProvider.ClusterUri), $($sourceChoice.HubProvider.Database))" } else { "FinOps Hub ($($sourceChoice.HubStorage.name))" } } + 'Export' { "Cost Management export ($($sourceChoice.Export.Name); CSV storage)" } + 'API' { 'Cost Management API (real-time)' } + 'GraphOnly' { 'Resource Graph only (no cost data)' } + } + $sourceColor = switch ($sourceChoice.Source) { 'Hub' { 'Green' } 'Export' { 'Cyan' } 'API' { 'Yellow' } 'GraphOnly' { 'DarkGray' } } + Write-FinOpsConsole "" + Write-FinOpsConsole " Data source: $sourceLabel" -ForegroundColor $sourceColor + Write-FinOpsConsole "" + + # Step 3: Pick scans + $finalModules = Select-ScanModules -Modules $scanModules + if (-not $finalModules) { + Write-FinOpsConsole " 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') + } + if ($sourceChoice.Source -eq 'Export') { $deps['Get-CostByTag'] = @('Get-CostData') } + 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-FinOpsConsole " Auto-enabled: $($mod.Name) (required by $($depEntry.Key -replace 'Get-',''))" -ForegroundColor DarkGray + } + } + } + } + } + + if ($sourceChoice.Source -eq 'GraphOnly' -and @($finalModules | Where-Object { $_.Selected -and $_.Fn -in $costModuleFns }).Count -gt 0) { + throw 'Resource Graph only cannot run a scan or dependency that requires cost data.' + } + + # Step 4: Run + $results = Invoke-SelectedScans -Modules $finalModules -Subscriptions $subs -TenantId $tenantId -DataSource $sourceChoice -PermissionInfo $permissionInfo + # Keep the documented drill-down name; each run overwrites any global value, including one set by the caller. + $global:FinOpsResults = $results + + # Step 5: Summary + export + $effectiveSource = switch ($sourceChoice.Source) { + 'Hub' { if ($sourceChoice.HubProvider) { "FinOps Hub ($($sourceChoice.HubProvider.ClusterUri), $($sourceChoice.HubProvider.Database))" } else { "FinOps Hub ($($sourceChoice.HubStorage.name))" } } + 'Export' { "Cost Management export ($($sourceChoice.Export.Name)). $($sourceChoice.CoverageNote)" } + 'API' { 'Cost Management API (real-time)' } + 'GraphOnly' { 'Resource Graph only (no cost data)' } + default { [string]$sourceChoice.Source } + } + $null = Show-ResultsSummary -Results $results -Modules $finalModules -ExportPath $OutputPath -Subscriptions $subs -DataSourceLabel $effectiveSource + + Write-FinOpsConsole " Done. Results available in `$FinOpsResults" -ForegroundColor Green + Write-FinOpsConsole "" +} + +# 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..8d6271fcc --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/README.md @@ -0,0 +1,522 @@ + + +# FinOps multitool terminal UI (TUI) + +Terminal interface for running FinOps scans against Azure subscriptions without GUI dependencies. PowerShell 7 is required. + +The [PR test workflow](../../../../.github/workflows/dev.yml) defines Windows, macOS, and Ubuntu jobs for the multitool suites and packaged launcher. Its signed Parquet integration jobs target Windows and Ubuntu. The jobs require no Azure sign-in or deployment credentials. Use each platform's result for the tested commit; Windows results don't establish native compatibility. + +NuGet signed-package verification [isn't supported on macOS](https://learn.microsoft.com/dotnet/core/tools/nuget-signed-package-verification#macos). Parquet setup stops with that reason before invoking a package client or downloading packages. Use a configured Kusto endpoint or available CSV exports there. The reader doesn't bypass signature verification to load Parquet. + +## Quick start + +```powershell +# From the FinOpsMultitool directory +. .\Invoke-FinOpsMultitool.ps1 +Invoke-FinOpsMultitool +``` + +Or target a specific subscription: + +```powershell +Invoke-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' +``` + +## Requirements + +| Requirement | Details | +| -------------------------------------------- | ------------------------------------------------------------------------------------ | +| PowerShell | 7.0 or later (Windows, macOS, Linux) | +| Az modules | `Az.Accounts`, `Az.ResourceGraph`, `Az.Storage` | +| Azure role-based access control (Azure RBAC) | Reader and Cost Management Reader on the target scope | +| FinOps hub storage (optional) | Storage Blob Data Reader on the hub storage account | +| FinOps hub Kusto (optional) | Query access to the hub database. A local ftklocal instance uses its local endpoint. | + +Install Az modules if needed: + +```powershell +Install-Module Az.Accounts, Az.ResourceGraph, Az.Storage -Scope CurrentUser +``` + +## How it works + +### 1. Authentication + +On an interactive launch, the TUI checks for an existing `Az.Accounts` session and starts `Connect-AzAccount` when needed. `-NonInteractive` requires an existing Azure context; authenticate with the intended identity first. If you supply `-SubscriptionId`, the subscription must resolve in the current tenant. A failed or mismatched lookup stops without searching other tenants or widening the scan. A valid lookup selects that subscription for the current process only. Otherwise, the tool offers a tenant menu when supported and discovers subscriptions in the selected tenant. A missing or changed tenant stops subscription enumeration, and every selected subscription must belong to that tenant before source discovery. + +### 2. Data source selection + +During interactive source selection, you can choose Cost Management API, Resource Graph only, or **Cost Management exports (CSV storage)**. FinOps Hub is also offered when a hub is detected. Exports don't require a Hub. If you choose exports and none can be verified, you're asked whether to use the Cost Management API instead; `-DataSource Export` stops without switching sources. + +Automatic Hub discovery queries only the selected subscriptions in the verified Azure context. If a Hub can't be verified, discovery failures remain visible, interactive runs still offer API or GraphOnly, and `-NonInteractive` defaults to API without changing scope. This applies even when every discovery probe fails. An explicit Hub request still stops if no configured endpoint or detected storage account is available. Explicit API and GraphOnly selections skip Hub discovery. + +If provider discovery throws for a detected Hub, the tool warns and checks that Hub's storage reader. Storage sizing and reachability warnings still apply. The selected storage path is carried into the scan runner without repeating provider discovery. A configured Kusto endpoint or a failed Kusto cost query doesn't silently switch to storage or API. + +The internal Kusto lookup warns when Resource Graph fails or returns an unreadable response; a verified empty lookup remains distinct. Kusto transport rejects malformed tables, invalid row widths, colliding column names, and partial query failures before returning data. Valid empty results, measured zero, and credits remain valid. + +| Source | Description | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **FinOps Hub** | Reads available cost data from the hub. Kusto summarizes data in the engine; the storage reader is for small datasets. | +| **Cost Management exports** | Discovers existing CSV/CSV.gz exports and reads one selected destination. Uses ActualCost or FOCUS BilledCost, filters row subscriptions, and leaves missing coverage unverified. | +| **Cost Management API** | Queries currently available cost data through the Cost Management REST API. Doesn't preload hub data. | +| **Resource Graph only** | Excludes cost-dependent scans and orphan cost enrichment. Remaining scans can still use Azure Monitor metrics, Advisor, policy, and carbon APIs. | + +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. + +Use `-DataSource Export` to request ordinary export discovery explicitly. The picker first reads export definitions for the selected subscriptions, their management-group ancestors, and billing accounts linked to those subscriptions, reporting progress per scope and the number found. Storage-first discovery then runs even when definitions are found, deduped against them, because a cross-tenant scan can see some subscriptions' exports while a central management-group export stays invisible to Cost Management. With more than 100 storage accounts, interactive runs ask before scanning them, because Azure allows 100 container listings per 5 minutes in each subscription and region; a skipped scan is reported. It uses Azure Resource Manager container metadata before blob-service enumeration, probes containers whose names contain `export`, `msexports`, `ingestion`, `finops`, `cost`, or `focus` plus any container a visible definition names, and reports progress per storage account. You choose an export; you don't need to know its container name. A definition found at a management-group or billing-account scope can deliver to storage outside the selected subscriptions, and that destination is read. Readable candidates remain available when other probes fail, and candidates this path cannot read are listed as such. Discovery warnings are summarized, with details under `-Verbose`. Storage Blob Data Reader or equivalent data access and storage network access are still required to read the chosen data. This path does not create or run exports, ingest local files, or parse Parquet. Reads stay inside the chosen export folder; when the selected run has a manifest, every declared partition must be readable or the run is reported as incomplete. It loads CSV parts into memory, so use a compatible Kusto database for very large datasets. + +Choose one export when prompted; exports aren't combined automatically. Unattended Export mode requires exactly one candidate. Cost totals, resource costs, cost by tag, and trends use only matching export rows. Trend history is limited to the chosen run's dates. Separate live financial scans are excluded; inventory and metrics scans can still query Azure in the selected scope. Failed or unsupported export reads never switch to API costs. Missing subscriptions are unverified, not zero, and neither blob timestamps nor observed row dates prove billing completeness. Rows with no subscription, such as purchases or refunds billed outside a subscription, aren't included in subscription totals; the export coverage note reports their count and amount in each currency. + +### 3. Scan selection + +Use arrow-key menus to select scans when your host supports them. Other hosts use numbered prompts. Use `-Accessible` to select numbered prompts in any console without clearing the screen or repainting menu rows. This mode stays in the signed-in tenant; sign in separately to use another tenant. For automation, use `-NonInteractive` with `-Scans`, `-DataSource`, and `-SubscriptionId`. `-NonInteractive` disables prompts even when `-Accessible` is also set. Reports are saved automatically; `-OutputPath` changes their parent folder. All menu scans are selected by default except **Billing Structure**. + +In accessible mode, three invalid or blank source-menu answers cancel the run instead of choosing a source. A Hub-to-API confirmation requires an explicit yes or no; invalid or blank answers cancel without starting a scan. + +Use `-Scans All` on its own to select every menu scan. GraphOnly removes cost-dependent scans from that selection, including budget history, unit economics, AI workload metrics, and MACC. Dependencies can't re-enable excluded scans. + +An explicit null or empty `-Scans` list is rejected before the tool starts. Omit the parameter to use the normal menu or unattended defaults. + +| 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. Supported scans reuse available hub summaries or preloaded rows. The tool reports hub query failures as scan errors and doesn't silently switch data sources. It reports AI metrics from a Kusto-only hub as unavailable. Select **Cost Management API** to run a separate live AI scan. + +Incomplete billing-scope discovery leaves overall commitment utilization unavailable. Unreadable Hub tags and malformed budget records remain unverified, rather than counting as missing tags or confirmed budget coverage. Budget history retains usable rows from a partial inventory with a coverage note; failed or incomplete inventory with no usable budgets produces an error instead of a clean empty result. + +Inventory scans that request all Resource Graph pages fail when a page is unreadable, a continuation token repeats, or the page limit is reached. A full page without a continuation token is also unverified, even if the true result happens to equal the page size. Resource queries retain `id`, which [Resource Graph requires for continuation tokens](https://learn.microsoft.com/powershell/module/az.resourcegraph/search-azgraph#example-3). These scans don't report an unverified inventory as complete. + +### 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") + +Each completed run automatically saves one CSV file per selected scan, a `FinOpsReport.html` summary, and a `ScanSummary.txt` text summary on the machine running the multitool. Failed or empty scans have a CSV status record. There's no export prompt or format picker. + +The HTML report opens with the **FinOps story**: selected tenant and subscriptions, observed spend, largest resource costs, scan status, and follow-up actions. Actual costs stay separate by subscription, currency, and reported period. Full-month forecasts are separate estimates, and unavailable amounts aren't treated as zero. Failed scans and evidence gaps link to their detailed results. + +For more than five subscriptions, the report shows a compact scope count with an expandable list of names and IDs. Result tables have sticky headers, row numbers, local search, sortable columns, and 25-row pages. Filters include text in collapsed details. Column edges support pointer dragging or keyboard arrow keys, and **Expand** opens a larger table view; **Close** or Escape restores its place and state. Tag inventory and policy recommendations retain expandable details. Each scan's summaries, notes, and guidance are grouped in one panel below its heading. The panel scrolls when its content is long, so notes don't lengthen the page. Wide tables scroll within their own frames on small screens. Printing includes all matching rows, not just the current page, and removes the notes panel's height limit. Collapsed details print only their summary line. These display controls don't change scan results or remove data from CSV exports. The HTML is self-contained and makes no external requests for these controls. + +The story highlights up to five positive resource costs per subscription, currency, and period. **All returned resource costs** opens the complete returned resource table, including credits and any resource IDs and periods the data source provided. Source query limits can omit resources; this view doesn't prove the inventory is complete. A high cost alone isn't evidence of waste. + +By default, reports go under the current user's local application data directory, in `FinOpsToolkit/Multitool/Reports`. On Windows, that's usually `%LOCALAPPDATA%\FinOpsToolkit\Multitool\Reports`. Each run creates a timestamped, uniquely named subfolder. The terminal prints its full path. `-OutputPath` selects a different local parent folder; it doesn't replace reports from an earlier run. + +The run folder allows access only to the current user through filesystem permissions. On Unix, directories use mode `700` and files use mode `600`. The tool rejects Git repositories and worktrees, UNC paths, mapped Windows network drives, symbolic links, and junctions, and adds an ignore-all `.gitignore` as a backup against accidental staging. Unix network mounts aren't detected; choose a path on a local filesystem. If it can't safely save, it reports an error and keeps the scan results in `$FinOpsResults`; it doesn't fall back to the working directory. + +Reports are plaintext and can contain subscription, resource, tag, and billing details. They aren't encrypted or uploaded by the tool. Administrators and processes running as your account can still access them. Keep custom locations outside synced folders, follow your organization's retention policy, and delete reports when they're no longer needed. These safeguards don't stop someone from moving or force-adding the files to a repository later. + +Raw Hub downloads use private, per-run folders under the user's `FinOpsMultitool` application-data directory, not shared temporary storage. The reader removes these folders when a read finishes or returns an error. A process termination or host failure can leave a private `download-*` folder behind; after confirming no scan is using it, delete it according to your retention policy. The Parquet cache stays under `FinOpsMultitool/parquet`. Cached assemblies must match signature-verified package archives before loading. Untrusted ownership, replacement permissions on ancestor directories, write access by other accounts, and linked cache paths are rejected. + +The Parquet reader pins its net8.0 dependency versions and SHA-512 archive hashes in [Get-FinOpsParquetPackageLock](modules/helpers/Read-FinOpsHubData.ps1), using published [NuGet package metadata](https://www.nuget.org/api/v2/). Corporate feeds must return the same archives; repackaged or unexpected dependencies are rejected. The pins include Snappier 1.3.1, which addresses [CVE-2026-44302](https://github.com/advisories/GHSA-pggp-6c3x-2xmx). Dependency updates require reviewing and updating the pins together. + +CSV files use `RecordType` to distinguish datasets when a scan returns several collections, such as reservations and savings plans. Scalar `Summary.*` columns retain scan diagnostics and estimate assumptions. Nested summary collections appear once as separate record types, such as `Summary.UnderutilizedRIs`, instead of repeating in every row. Nested values within a record are JSON. CSV headers include fields from every exported record type, amounts use a decimal point regardless of your system locale, and dates use ISO 8601. Aggregate and detailed records are separate views, not amounts to add together. + +The terminal limits tag inventory to a compact preview. The HTML tag inventory includes every returned tag and value, and wraps long cell text instead of shortening it. CSV exports preserve the underlying value records and their counts. + +The TUI's results renderer escapes control characters before printing, so resource metadata isn't emitted as terminal escape sequences. Progress and warning messages written directly by scan modules aren't covered by this renderer. The underlying scan data and CSV values aren't rewritten by this display protection. + +## Required permissions + +Each scan requires specific permissions. The TUI identifies the required role when a scan fails because of missing permissions. Billing permissions depend on your agreement, such as a Microsoft Customer Agreement (MCA) or Enterprise Agreement (EA). + +| Category | Scans | Required role | Scope | +| ---------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| Optimization | Orphaned Resources, Idle VMs, Storage Tier Advice, AHB, Legacy Resources | Reader | Subscription | +| Governance | Tag Inventory, Tag Recommendations, Policy Inventory/Recs | Reader | Subscription | +| Cost | Cost Data, Resource Costs, Cost by Tag, Cost Trend | Cost Management Reader | Subscription or management group | +| Commitments | Reservation Advice, Savings Realized estimates | Cost Management Reader, and Reader for Azure Hybrid Benefit inventory | Subscription or management group | +| Commitment utilization | Reservation and savings plan usage | Billing access for the agreement, such as EA Enterprise Administrator (read only) or MCA Billing account reader or Billing profile reader | Billing account or profile | +| Monitoring | Budget Status, Anomaly Alerts | Cost Management Reader | Subscription | +| Advisor | Optimization Advice | Reader | Subscription | +| Account | Billing Structure, Contract Info, MACC | Billing access for the agreement | Billing account or profile | +| Hub storage (optional) | Storage-backed cost and tag scans | Storage Blob Data Reader | Hub storage account | +| Hub Kusto (optional) | Kusto-backed cost summaries | Database query access | Hub database | + +Subscription Reader access alone doesn't grant billing access. See [MCA billing roles](https://learn.microsoft.com/azure/cost-management-billing/manage/understand-mca-roles), [EA roles](https://learn.microsoft.com/azure/cost-management-billing/manage/understand-ea-roles), and [Kusto database roles](https://learn.microsoft.com/kusto/management/manage-database-security-roles). Reading hub data also requires network access to the storage or Kusto endpoint. If you receive a 403 response, check the firewall or private endpoint as well as role assignments. + +## Available scans + +The menu contains 26 scans. Four additional modules support direct investigations: VM cost breakdown, shared cost allocation, usage-proportional allocation, and billing account. + +### Optimization + +| Scan | What it finds | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Orphaned Resources | Unattached disks, NICs, public IPs, stopped VMs, empty App Service plans, and old snapshots for review | +| Idle VMs | Running VMs with average CPU below 5% and network traffic below 1 MB per day over 14 days. A second threshold flags underutilized VMs. | +| Storage Tier Advice | Blob storage that could move to cooler tiers | +| AHB Opportunities | Windows/SQL VMs not using Azure Hybrid Benefit | +| Legacy Resources | Legacy/retiring SKUs (v1 VM families, unmanaged disks, Basic IPs/LBs) | + +Storage tier advice uses 30-day transaction and capacity metrics. Missing or invalid samples leave an account unevaluated, not idle. Recommendations are review candidates, not proof that a tier change will save money; validate retrieval needs, eligibility, and retention charges before acting. + +Idle VM checks likewise require CPU and inbound/outbound network samples. Failed or missing measurements leave the VM unevaluated and the utilization KPI unavailable. AHB license estimates use USD retail rates for a 730-hour month; `SavingsCurrency` and `SavingsPeriod` identify the units. + +### 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 | + +**Policy Recommendations** checks definition IDs in direct assignments and in assigned initiatives. Policies found through an initiative appear as **Assigned (Initiative)**, with the matching assignment, scope, and enforcement mode in the report. Reading custom initiative members requires access to the definition's subscription or management group. Each distinct initiative is read once per scan. + +If an initiative can't be read, unmatched policies appear as **Unknown**, not **Missing**. If the effective assignment inventory is incomplete, the launcher keeps the partial inventory but skips recommendations; it doesn't assume unread assignments are missing. Assignment coverage is the percentage of recommended definition IDs found in the supplied inventory, not Azure Policy compliance or proof of enforcement. Review parameters, exclusions, enforcement modes, and equivalent custom policies before treating a recommendation as a governance gap. + +Policy compliance coverage is separate from assignment coverage. When ARG coverage is incomplete, the tool requests REST resource summaries for every selected subscription rather than combining the two counting methods. If those requests don't establish complete coverage, the report retains available evidence but leaves the overall percentage unverified. Assignments are identified by resource ID, so different assignments with the same display name remain distinct. + +Policy definition reads have separate coverage too. Failed or malformed reads produce a visible warning and a **Limited data** result. `DefinitionCoverageIncomplete` and `DefinitionErrors` identify the gap in the scan result and CSV export. Already-read assignments and valid compliance percentages remain available. An unresolved effect in a successfully read definition isn't counted as a failed read. + +Policy locations display available subscription and management-group names while retaining the original scope IDs. Subscription names come from the selected scope. The inventory looks up only distinct management groups referenced by its assignments and accepts a display name only when the response matches the requested group and tenant. If a name can't be verified, the ID remains visible and `ScopeNameErrors` records the reason; assignment and compliance results aren't discarded. HTML details retain the raw IDs, and CSV includes `Scope` and `ScopeDisplayName`. + +### 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) | + +Resource-cost API queries capture one UTC window from the first day of the current month to the query time. Every returned row carries that same window in `ActualPeriod`, `ActualPeriodStart`, and `ActualPeriodEnd`, with `ActualPeriodSource` set to `Query window`. This describes the requested period, not proof that billing data is complete through its end. Resource names and types are derived from the returned ID, including nested resources and reservation charges. The original `ResourcePath` remains in CSV and expandable HTML details. Charges without subscription attribution aren't assigned to an arbitrary subscription; subscription-scoped responses retain their known query scope. + +Cost trend shows a selected-scope aggregate and a subscription view with names and IDs. API queries capture one UTC window covering six full months and the current partial month. HTML labels that partial month from the captured query end, not the date you open the report. `CostPeriodStartUtc`, `CostPeriodEndUtc`, `CostBasis`, and `QueryScope` retain the request context in the result and CSV export. The window doesn't establish billing-data completeness. + +A management-group response can omit selected subscriptions that sit outside that group or have no cost rows. The scan queries each omitted subscription individually and records it in `IndividuallyQueriedIds`. If the management-group response has no rows for any selected subscription, the scan queries every selected subscription individually instead; `IndividuallyQueriedIds` stays empty, and a failed query fails the scan. Coverage distinguishes subscriptions with returned rows, successful empty subscription queries (`NoDataSubscriptionIds`), and omitted subscriptions whose individual results couldn't be added because the query failed or a month's currency differed from the trend total (`UnverifiedSubscriptionIds`, with reasons in `QueryErrors`). An unverified subscription isn't assumed to have zero cost, and a failed query isn't proof of missing access. Unverified coverage sets `CoverageIncomplete`; the aggregate isn't described as a whole-tenant total. Requests and returned grouped rows are filtered to the selected IDs. Missing months aren't filled with zero, and switching views doesn't remove aggregate or per-subscription CSV data. Older results without metadata remain readable but show unverified coverage and an unrecorded query window. + +Cost trend requires explicit cost, date, and currency fields. It rejects monthly totals that would combine different currencies rather than labeling the combined amount with one currency. Failed required responses or continuation pages still fail the scan instead of publishing a partial total. When the individual query for an omitted subscription fails, or a month's currency differs from the trend total, only that subscription stays unverified. + +Management-group cost-scope discovery tries at most 25 distinct candidates: up to 24 non-root groups, then the tenant root. List pagination remains intact, and each candidate keeps the existing low retry budget. The limit applies to candidates, not total HTTP requests. A warning identifies when groups are left unprobed; this doesn't establish that those groups lack cost access. If none of the bounded candidates works, cost scans query the selected subscriptions individually. Discovery results and failure state are cached only for the requested tenant during the scan. + +On the API path, **Cost by Tag** keeps results from subscriptions whose full cost query succeeded when another subscription fails. `CoverageIncomplete`, `ScannedSubs`, `TotalSubs`, `SuccessfulSubscriptionIds`, and `FailedSubscriptions` identify the coverage and failures. Reports show **Limited data**, and whole-scope allocation KPIs remain unavailable. If every subscription fails, the scan reports an error. Failed continuation pages don't contribute partial costs, and missing resource or resource-group tag maps stop the scan rather than classifying unknown tags as untagged. Amounts from different currencies aren't combined. + +**Unit Economics** retains measured VM, vCPU, memory, and storage capacity when currency evidence is missing or mixed. Combined costs, cost shares, and monetary unit rates remain empty, with `CostAvailable = false` and an explanatory `CostIssue`. A measured zero with known currency remains zero. + +Advisor and reservation recommendations retain each recommendation's savings currency. Combined estimates remain unavailable when currency is missing or mixed; a dollar symbol is not substituted for an unknown currency. Tag inventory, alerts, and billing inventories expose `CoverageIncomplete`, read errors, and explanatory notes when a required read fails. Incomplete tag inventory doesn't become missing-tag recommendations. + +### 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 | + +AI usage counts remain available when monetary rates can't be calculated. Missing or mixed billing currencies suppress combined and per-account monetary outputs; `CostIssue` explains why. Token and request rates use costs from accounts with the corresponding measured usage, not all Cognitive Services spend. Failed metric reads suppress aggregate rates and expose a `RateIssue`; reports retain the separate spend and available usage evidence. Missing measurements aren't treated as measured zero. + +Live AI queries share one captured UTC month-to-date window for Azure Monitor usage and amortized account cost. Token totals use the measured `TokenTransaction` value per account and deployment, including zero; the fallback uses prompt plus generated tokens only when both were measured. Account, deployment, and overall totals use that same basis. `TokenBasis`, `ResourceId`, and `SubscriptionId` retain the identity and calculation, so same-named deployments in different accounts don't merge. Hub model-meter rows also retain account identity. Missing requests or tokens remain unavailable, and incomplete measurements suppress account and aggregate rates. These are effective account rates, not model prices or a billing reconciliation. See the [Azure OpenAI metric definitions](https://learn.microsoft.com/azure/foundry/openai/monitor-openai-reference#metrics). + +### Commitments + +| Scan | What it finds | +| ---------------------- | --------------------------------------------------------------------------------------- | +| Reservation Advice | RI purchase recommendations from Advisor | +| Commitment Utilization | RI and Savings Plan usage rates | +| Savings Realized | Estimates of commitment and Azure Hybrid Benefit savings, not measured realized savings | + +Commitment utilization shows all returned reservations and savings plans, with their IDs and latest returned usage periods. Missing SKU or resource-kind metadata triggers a targeted [reservation details](https://learn.microsoft.com/rest/api/reserved-vm-instances/reservation/get?view=rest-reserved-vm-instances-2022-11-01) lookup; a returned name is accepted only when its resource ID matches. Denied or invalid metadata retains the ID and known utilization. Provisioning state and billing scope aren't substituted for SKU or kind. Missing percentages and absent commitment types have unavailable averages, not 0%; a measured 0% remains zero. Averages are unweighted and aren't reported for incomplete or unscoped coverage. Billing-scope results and the explicitly labeled reservation-order fallback can include commitments outside the selected subscriptions. + +The scan keeps the **Savings Realized** name for compatibility. Reservation and savings plan estimates use assumed effective discounts of 40% and 25%. Azure Hybrid Benefit estimates use a Windows license premium when available, with a fallback estimate otherwise. Results include `IsEstimate` and `EstimateBasis`. Compare the estimates with matching pay-as-you-go rates and benefit usage before reporting realized savings. + +Commitment estimates cover usage charges in the captured UTC month-to-date period and retain the billing currency. Purchases, refunds, and unused commitment charges are excluded before aggregation. Unknown, nonmonetary, or mixed billing currencies stop the scan instead of producing a combined amount. Negative usage adjustments also stop the estimate because the assumed discount can't produce a comparable savings amount. Use `RISavingsMonthToDate`, `SPSavingsMonthToDate`, and `CommitmentSavingsMonthToDate`, together with `Currency` and `Period`. + +The AHB estimate is separate: `AHBSavingsMonthly` represents 730 hours for the current VM inventory in USD, using a retail Windows license premium or a USD 50 per-VM fallback. `AHBCurrency` and `AHBPeriod` identify those units. The scan doesn't combine these amounts or annualize them. The legacy `RISavingsMonthly`, `SPSavingsMonthly`, `TotalMonthly`, and `TotalAnnual` fields remain present but are empty. + +### Monitoring + +For more than 50 selected subscriptions, Budget Status samples ten first. If all ten queries succeed without budgets, the remaining subscriptions aren't queried and coverage stays unverified. The report labels this as sampling, not an access denial. Smaller selections query every selected subscription. The scan covers subscription budgets, not all resource-group, management-group, or billing-scope budgets. + +| Scan | What it finds | +| -------------- | -------------------------------------------------------------- | +| Budget Status | Budget consumption vs. thresholds | +| Budget History | Completed-month costs compared with the current monthly budget | +| Anomaly Alerts | Recent cost anomaly detections | + +**Budget History** supports monthly cost budgets with no filter, a tag or dimension `In` filter, or an `and` combination of those filters. An empty filter object (`{}`) means no filter. Filtered budgets use a Cost Management query with the matching filter, even when the primary cost source is a hub. Only unfiltered budgets can reuse the subscription cost trend. Results are cached separately for each subscription and exact filter, including case-sensitive tag values. + +Months before a budget was active for the full month remain **Unavailable**. Unsupported filters, nonmonthly periods, missing budget details, and currency mismatches also remain unavailable; the tool doesn't substitute whole-subscription spend for a filtered budget. Failed or incomplete cost queries remain errors rather than zero spend. Comparisons use the current budget amount and filter, not historical budget revisions. See the [budget filter schema](https://learn.microsoft.com/azure/templates/microsoft.consumption/2023-11-01/budgets#budgetfilter) and [Cost Management query API](https://learn.microsoft.com/rest/api/cost-management/query/usage?view=rest-cost-management-2023-11-01). + +Current forecasts in **Budget Status** come from Azure's budget response, independently of historical actual costs. If the response omits a forecast amount or a compatible currency, the forecast remains **Unavailable**. + +### Sustainability + +Carbon reports retain available detail when another report section fails, but leave missing headline measurements unavailable. Percentage change requires a positive previous-month measurement. A failed permission check is reported separately from a window with no published measurements. + +| Scan | What it finds | +| ---------------- | ------------------------------------------------------------------------------------------- | +| Carbon Emissions | 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 | +| MACC Commitment | Microsoft Azure Consumption Commitment balance and drawdown | + +## FinOps KPI coverage + +The scan modules provide measurements, estimates, or proxies related to [FinOps Foundation KPIs](https://www.finops.org/finops-kpis/). Some KPI definitions need additional inputs and aren't calculated by this tool. The `finops-multitool` agent skill routes a natural-language question to the matching investigation. + +The HTML report's **KPI reference** tab lists every entry from the shared [KPI catalog](kpi/kpi-catalog.json), including entries not measured in the run. Search or filter by status, expand **Calculation and interpretation**, and follow a source-scan link when that scan was included. Each entry identifies the formula, required inputs, interpretation, and limitations. + +| Status | Meaning | +| ------------- | ------------------------------------------------------------------------------------------------ | +| Computed | A value was derived from the scan. It can be an estimate or proxy; this isn't a health rating. | +| Unavailable | The selected scan failed or lacks comparable measurements. Missing values aren't measured zeros. | +| Not run | The scan for a calculable KPI wasn't selected. | +| Informational | The catalog explains the KPI, but this tool doesn't calculate it. | + +**Calculation and thresholds** disclosures beside Unit Economics, Idle VMs, Storage Tier Advice, and Budget Status explain the values in place. Unit Economics names the VM-compute-plus-storage denominator, subtotal, captured UTC window, and amortized basis. The percentage isn't a share of the entire Azure bill or an efficiency score. Unit rates divide period cost by current capacity, including stopped VMs, rather than time-weighted running-resource capacity. + +Idle VM screening uses 14-day average CPU below 5% and combined network below 1 MiB/day; otherwise, CPU below 10% and network below 10 MiB/day flags underutilization. Storage screening uses 30-day blob transactions: fewer than 100 with positive rounded capacity suggests an Archive candidate; otherwise, fewer than 1,000 with capacity above 1 GiB suggests Cool. These are scanner rules, not Azure Advisor criteria or per-blob last-access analysis. Evaluated counts exclude unreadable metrics. + +Budget coverage counts selected subscriptions with at least one budget, not spend or forecast coverage. Forecast availability is shown separately. A zero at-risk count doesn't establish that budgets with missing forecasts are on track. + +There is no universal healthy compute/storage split or unit-cost target. Compare the same scope, period, currency, capacity basis, and service requirements against a workload-specific baseline, as described in the [FinOps unit-economics guidance](https://www.finops.org/framework/capabilities/unit-economics/). Storage-tier decisions also need [retrieval, retention, and eligibility checks](https://learn.microsoft.com/azure/storage/blobs/access-tiers-overview). + +A few examples of question and output: + +### Percentage of Legacy Resource → legacy resources + +> "Which of my resources are running on legacy or retiring SKUs?" + +```text +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) +``` + +The scan returns candidate counts. A legacy percentage also needs a complete, comparable resource denominator; the catalog entry remains informational. + +### Cost per Gigabyte Stored / Hourly Cost per CPU Core → unit economics + +> "What's my cost per vCPU and per GB of storage this month?" + +Abridged example: + +```text +Unit Economics — Month to Date (USD) + +Compute USD 128,400 (75.7% of VM compute + storage spend) +Storage USD 41,200 (24.3% of VM compute + storage spend) +Subtotal USD 169,600; other Azure services excluded +Capacity 312 VMs / 1,840 vCPU / 7,360 GB RAM / 126,400 GB storage + + Cost per vCPU USD 69.78 (month-to-date) + Cost per GB RAM USD 17.45 (month-to-date) + Cost per VM USD 411.54 (month-to-date) + Cost per GB stored USD 0.326 (month-to-date) +``` + +vCPU and RAM come from Compute SKU capabilities. Storage capacity combines provisioned managed disk capacity with storage account usage from the Azure Monitor `UsedCapacity` metric. The tool reports the combined capacity in GB. Cost queries cover the selected subscriptions. If the tool can't access the management group scope, it queries each subscription separately and reports failed queries as errors. **Hourly Cost per CPU Core** divides cost per vCPU by the elapsed hours in the recorded UTC cost period, with a one-hour minimum. It doesn't use a fixed 730-hour month. + +### Token Consumption / Cost per 1K Tokens / Cost per API Call → ai workloads + +> "What are my AI/LLM workloads costing per token and per request this month?" + +This scan first queries Resource Graph for AI workloads (Azure OpenAI, Foundry Tools, Azure Machine Learning, Azure AI Search, and GPU VMs) in the selected subscriptions. When AI workloads are present, the API path combines Azure Monitor token metrics with Cost Management spend over the same month-to-date window. + +```text +AI footprint — OpenAI/AIServices: 3 ML workspaces: 1 AI Search: 2 GPU VMs: 0 +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). + +The TUI uses the selected `-DataSource`. When readable hub rows cover the selected subscriptions, AI spend and billed token volume come from those rows and use their observed period. A Kusto-only hub doesn't currently provide this AI scan, so the result is unavailable. Select **Cost Management API** to run a separate live scan. Cost per request is available only on the API path because request counts aren't billed line items. + +### Carbon per Unit of Spend / Carbon Efficiency → carbon + +> "Show my cloud carbon footprint and how it changed month over month." + +```text +Carbon Emissions — latest available month: 2026-04 (data lags ~2 mo) + +Total emissions 18,420 kgCO2e +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?" + +```text +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?" + +```text +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%. The resource-based path measures cost with no allocation tag. Server-aggregated results use the allocation tag with the lowest cost coverage and name that tag in the result. The tool reports the percentage as unavailable when net totals are zero or negative, or when credits make the percentage unsuitable for comparison. It doesn't score those results. + +## FinOps hub integration + +When you select **FinOps Hub**, supported scans reuse its available cost data: + +- **Tag data reuse**: stored cost records can supply tag inventory and cost by tag. Kusto returns aggregated tag costs. Azure Resource Graph supplies resource inventory where needed. +- **Fewer cost queries**: hub summaries reduce Cost Management API calls. Other scans and forecast enrichment can still call Azure APIs and encounter throttling. +- **Observed cost periods**: actual costs use the dates present in the selected subscriptions' hub data, not an assumed current-month window. +- **Forecast enrichment**: for current-month storage data, the TUI can show a separate full-month API forecast with matching currency. It never adds that forecast to hub actuals. The forecast is unavailable for older data and Kusto summaries, or when the API can't supply it. +- **Resource coverage**: a hub contains only resources represented in its cost data. The storage path queries Azure Resource Graph for total and untagged resource counts when available. + +### FinOps hub data paths + +The **Cost Data**, **Resource Costs**, and **Cost by Tag** scans support three hub paths. The Kusto paths aggregate data in the engine and return summarized results without loading raw cost rows into PowerShell: + +| Path | When | How | +| -------------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Kusto — online** | A deployed hub with an Azure Data Explorer / Fabric cluster | The cluster is discovered via Azure Resource Graph (`microsoft.kusto/clusters` tagged `ftk-tool == 'FinOps hubs'`), queried with a bearer token. Aggregation runs in KQL against the `Costs` function. | +| **Local Kusto (ftklocal)** | A local Kusto emulator with cost data in its `Hub` database | Set `FINOPS_HUB_KUSTO_URI`. Optionally, set `FINOPS_HUB_KUSTO_DB`, which defaults to `Hub`. Loopback queries are anonymous. The public launcher still uses Azure context and resource metadata, so this isn't a fully offline workflow. | +| **Storage export reader** | Small datasets, or when no Kusto cluster is available | Reads the hub's `ingestion` parquet / `msexports` CSV and aggregates in PowerShell. A convenience fallback, **not** the scalable path. | + +An explicit `-DataSource API` or `-DataSource GraphOnly` takes precedence over `FINOPS_HUB_KUSTO_URI` and doesn't preload hub data. Otherwise, a configured Kusto URI selects the hub without requiring storage-account discovery. For a discovered hub, the tool prefers Kusto and uses the storage reader when no Kusto provider is available. An explicit `-DataSource Hub` fails if neither a configured endpoint nor hub storage is available; it doesn't silently switch to API. + +Remote Kusto endpoints and requests carrying access tokens require HTTPS. HTTP is allowed only for a token-free loopback emulator. Endpoint URLs can't contain credentials or fragments. Kusto and export-blob requests don't follow redirects; configure the final endpoint URL. + +Reading Parquet requires a signature verifier: NuGet on Windows or .NET SDK 8 or later on Linux. macOS Parquet setup is unavailable because signed-package verification isn't supported. If the verifier is unavailable, the cache remains unloaded and is preserved for a later attempt. If the reader can't be prepared, the tool warns you with the reason and attempts the hub's `msexports` CSV instead of normalized Parquet data. An export read failure still reports an error rather than zero spend. + +#### 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` | + +The tool loads hub summaries or storage rows once per run and reuses them for supported scans. It reports a failed query against the selected hub as an error and doesn't silently replace the result with API data. + +## Scripting (non-interactive) + +The scan modules can be called directly without the TUI: + +For usage-proportional showback with an explicit `-PoolAmount`, supply `-PoolCurrency` and `-PoolPeriod` to identify its units. Omitted units remain unknown. Resource-backed pools retain the cost source's period and currency. Rounded showback allocations reconcile to the pool amount; they don't write native billing rules. + +```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 +``` + +## Validation + +Run the normal toolkit unit and lint gates from the repository root: + +```powershell +./.build/start.ps1 -Task Test.PowerShell.All +``` + +Run the focused integration checks in a fresh PowerShell 7 session from the repository root. Install Pester 6.0.0 and the Az modules listed in the workflow first. These tests build in a temporary directory, replace Azure access with synthetic responses, and use isolated package caches. They don't scan subscriptions or overwrite the checkout's release output. Package restore and signature verification require network access to the configured NuGet feeds and certificate services. + +```powershell +Import-Module Pester -RequiredVersion 6.0.0 +$paths = @('src/powershell/Tests/Integration/MultitoolPackage.Tests.ps1') +$minimumPassed = 3 +if (-not $IsMacOS) { + $paths += 'src/powershell/Tests/Integration/MultitoolParquet.Tests.ps1' + $minimumPassed += 2 +} +$configuration = New-PesterConfiguration +$configuration.Run.Path = $paths +$configuration.Run.PassThru = $true +$configuration.Output.Verbosity = 'Detailed' +$result = Invoke-Pester -Configuration $configuration +if ($null -eq $result -or $result.Result -ne 'Passed' -or + $result.PassedCount -lt $minimumPassed -or $result.FailedCount -ne 0 -or + $result.SkippedCount -ne 0 -or $result.NotRunCount -ne 0 -or + $result.FailedContainersCount -ne 0 -or $result.FailedBlocksCount -ne 0 -or + $result.Containers.Count -ne $paths.Count) { + throw 'Multitool integration validation failed or was incomplete.' +} +``` + +The workflow records the tested merge commit, host, PowerShell version, and test counts in each job summary. Platform-specific `multitool-tests-*` artifacts retain NUnit results for 14 days. Only the two named test-result XML files are uploaded, not scan reports, package caches, or build output. The macOS summary explicitly records that signed Parquet integration wasn't run. + +These checks don't establish live Azure API behavior or current tenant access. A live smoke test must use an explicitly selected subscription and matching cost periods and currencies. + +## File structure + +```text +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 +│ │ └── MgCostScope.ps1 # Management group scope state +│ ├── Get-CostData.ps1 +│ ├── Get-ResourceCosts.ps1 +│ ├── Get-TagInventory.ps1 +│ ├── Get-CostByTag.ps1 +│ ├── Get-OrphanedResources.ps1 +│ ├── 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..cbb852ad1 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/kpi/kpi-catalog.json @@ -0,0 +1,445 @@ +{ + "_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.1.0", + "learnMoreBase": "https://www.finops.org/finops-kpis/", + "domains": [ + { + "id": "Understand", + "name": "Understand usage and cost", + "summary": "Data acquisition, reporting, analysis, and alerting on top of your cost, usage, and carbon consumption.", + "detail": "This domain is about observability and business intelligence. It brings the data that stakeholders need together (ingestion) into a meaningful breakdown for the organization (allocation). That data can then be reported on (reporting) and monitored to proactively identify and react to issues (anomalies).", + "capabilities": "Data ingestion, Allocation, Reporting and analytics, Anomaly management" + }, + { + "id": "Quantify", + "name": "Quantify business value", + "summary": "Identifying and breaking down cost, usage, and carbon emissions to stay aligned with organizational plans and measure the return on investment from cloud computing efforts.", + "detail": "This domain is about measuring and maximizing the business value each team and workload gets from the cloud to maximize future potential.", + "capabilities": "Planning and estimating, Forecasting, Budgeting, Benchmarking, Unit economics" + }, + { + "id": "Optimize", + "name": "Optimize usage and cost", + "summary": "Designing and optimizing solutions for efficiency to ensure you get the most out of your cloud investments.", + "detail": "Rate optimization lowers the price paid for resources already in use, through commitment discounts and licensing benefits. Usage optimization reduces what is consumed, by removing waste and rightsizing what remains.", + "capabilities": "Architecting for the cloud, Usage optimization, Rate optimization, Licensing and SaaS, Cloud sustainability" + }, + { + "id": "Manage", + "name": "Manage the FinOps practice", + "summary": "Establishing a clear and consistent vision of FinOps and driving cultural adoption across your organization.", + "detail": "Unlike domains that focus on FinOps tasks to drive efficiency and maximize value, this domain emphasizes managing and supporting your FinOps practice.", + "capabilities": "FinOps education and enablement, FinOps practice operations, Onboarding workloads, Governance, policy, and risk, Invoicing and chargeback, FinOps assessment, FinOps tools and services, Intersecting frameworks" + } + ], + "kpis": [ + { + "id": "cost-per-gb-stored", + "name": "Cost per Gigabyte Stored", + "domain": "Quantify", + "definition": "Month-to-date amortized storage cost divided by current reported managed-disk and storage-account capacity.", + "sourceTool": "scan_unit_economics", + "compute": true, + "field": "CostPerGb", + "unit": "per GB (month-to-date)", + "calculation": "StorageCost / TotalStorageGb for the captured cost period.", + "requiredInputs": ["Amortized storage cost and billing currency", "Provisioned managed-disk capacity", "Measured storage-account used capacity", "Cost-period start and end"], + "interpretation": "Compare like-for-like storage tiers, redundancy, access patterns, and periods against your own workload baseline. No universal healthy rate applies.", + "limitations": "A blended cost divided by current capacity, not GB-hours. Provisioned disks and used storage-account capacity are different measures. Partial capacity measurements can distort the rate; this is not a quoted storage price. A returned zero, especially early in the period, can reflect billing-data delay rather than free capacity.", + "plainLanguage": "Month-to-date amortized storage cost per GB of reported current capacity.", + "exploreHint": "Run the Storage Tier Advice scan to see if moving cold data to Cool or Archive lowers this." + }, + { + "id": "hourly-cost-per-cpu-core", + "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", + "unit": "per vCPU / hour", + "calculation": "ComputeCost / TotalVCpu / elapsed hours in the captured UTC cost period, with a minimum one-hour divisor.", + "requiredInputs": ["Amortized VM compute cost and billing currency", "Current vCPU inventory", "Captured UTC cost-period start and end"], + "interpretation": "Compare equivalent VM families, regions, licensing, discounts, and operating schedules. Use workload-specific baselines, not a universal price target.", + "limitations": "Current inventory includes stopped VMs and is not time-weighted running vCPU-hours. Newly added or removed VMs can distort the average. Historical results without captured dates use the current UTC month window. A returned zero can reflect billing-data delay, especially early in the period.", + "plainLanguage": "Period spend spread across current vCPU inventory and elapsed calendar hours, not measured running-core hours.", + "exploreHint": "Run the Optimization Advice or Idle VMs scan to find rightsizing that lowers this." + }, + { + "id": "effective-avg-compute-cost-per-core", + "name": "Effective Average Compute Cost per Core", + "domain": "Quantify", + "definition": "Month-to-date amortized VM compute cost divided by current vCPU inventory.", + "sourceTool": "scan_unit_economics", + "compute": true, + "field": "CostPerVCpu", + "unit": "per vCPU (month-to-date)", + "calculation": "ComputeCost / TotalVCpu for the captured cost period.", + "requiredInputs": ["Amortized VM compute cost and billing currency", "Current vCPU inventory", "Captured cost period"], + "interpretation": "Compare the same elapsed period and a comparable fleet. A lower amount alone does not establish improved efficiency.", + "limitations": "Not a full-month forecast. Current inventory includes stopped VMs; SKU lookup failures can approximate capacity. Only the VM compute meter categories selected by this scan are included. A returned zero can reflect billing-data delay and does not prove that compute is free.", + "plainLanguage": "Month-to-date compute spend per currently provisioned vCPU, including stopped VMs in the inventory.", + "exploreHint": "Pair with utilization, SKU, runtime, and commitment evidence before changing the fleet." + }, + { + "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": "%", + "calculation": "100 x UnallocatedCost / ResourceCostSeen. Legacy results fall back to the highest untagged cost share among recognized allocation tags.", + "requiredInputs": ["Complete cost coverage in one currency", "Resource-associated cost", "Cost with no recognized allocation tag; or comparable per-tag cost rows"], + "interpretation": "Lower is generally better for allocation. Set an organizational target and agreed exceptions; this does not measure all missing tags.", + "limitations": "Recognized allocation-tag presence is a proxy, not validation of required tag values or ownership. Non-resource charges are outside this denominator. Credits and nonpositive totals can prevent calculation.", + "plainLanguage": "How much of your spend lands on resources that are not tagged.", + "exploreHint": "Run the Tag Recommendations scan 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": "%", + "calculation": "100 x UnallocatedCost / ResourceCostSeen, using recognized allocation-tag presence as the allocation proxy.", + "requiredInputs": ["Complete resource-cost coverage in one currency", "ResourceCostSeen", "UnallocatedCost or comparable per-tag cost rows"], + "interpretation": "Aim to reduce unexplained resource spend against your allocation policy. A tagged resource still needs a valid, agreed owner.", + "limitations": "Not verified chargeback completeness. Shared and non-resource charges need separate allocation. Legacy per-tag results use the worst recognized allocation tag rather than a sum across tags.", + "plainLanguage": "Share of your bill you cannot yet assign to an owner.", + "exploreHint": "Backfill allocation tags (Customer/project/CostCenter), then re-run the Cost by Tag scan." + }, + { + "id": "tagging-policy-compliant", + "name": "Percentage of Costs that are Tagging Policy Compliant", + "domain": "Manage", + "definition": "Allocation-tag presence proxy: the share of resource cost carrying a recognized allocation tag, not verified organizational policy compliance.", + "sourceTool": "scan_cost_by_tag", + "compute": true, + "unit": "%", + "calculation": "100 minus the resource-cost share with no recognized allocation tag; legacy results use the worst recognized allocation tag.", + "requiredInputs": ["Complete cost coverage in one currency", "Recognized allocation-tag evidence", "Positive, comparable resource-cost denominator"], + "interpretation": "Higher indicates better allocation-tag presence. It is not proof of compliance with your required tags, allowed values, or Azure Policy.", + "limitations": "This proxy does not validate tag values, all required tags, ownership, or non-resource charges. Missing or incomplete cost coverage remains unavailable.", + "plainLanguage": "Share of resource spend carrying a recognized allocation tag, not verified policy compliance.", + "exploreHint": "Run the Policy Recommendations scan 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": "%", + "calculation": "Mean of the available RIAvgUtilization and SPAvgUtilization values, including only families with a positive commitment count.", + "requiredInputs": ["Reservation or savings-plan counts", "Measured family-average utilization over their reported windows"], + "interpretation": "Values closer to 100% indicate less unused purchased capacity. Review individual commitments before renewal or purchase decisions.", + "limitations": "A simple average of family averages, not a spend-weighted portfolio utilization. It is distinct from commitment coverage of eligible spend. The source windows can differ.", + "plainLanguage": "How much of your reserved/committed capacity you are actually using.", + "exploreHint": "Run the Reservation Advice scan 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": "%", + "calculation": "100 minus the mean of available reservation and savings-plan family utilization averages.", + "requiredInputs": ["Positive commitment counts", "Measured family utilization averages"], + "interpretation": "Lower suggests less unused purchased capacity. Confirm the affected commitments and their cost before estimating a monetary loss.", + "limitations": "An unweighted utilization proxy, not measured wasted currency or proof of business waste. Absent commitment families are excluded rather than treated as zero utilization.", + "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": "%", + "calculation": "100 x CommittedAmortized / (CommittedAmortized + OnDemandAmortized), as returned by the savings scan.", + "requiredInputs": ["Eligible amortized usage charges by pricing model", "One billing currency", "Captured cost period"], + "interpretation": "Set coverage targets around predictable demand and acceptable commitment risk. Higher coverage is not automatically better for variable demand.", + "limitations": "Excludes Spot, purchases, refunds, and unused commitment charges from the eligible basis. This is not commitment utilization or a savings percentage.", + "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": "The count of orphan candidates returned by the scan, rather than a measured percentage of unused resources or spend.", + "sourceTool": "scan_orphaned_resources", + "compute": true, + "unit": "resource count (proxy)", + "calculation": "TotalCount of resources flagged by the orphaned-resource scan; a percentage is not calculated.", + "requiredInputs": ["Complete scoped resource inventory", "Orphan-candidate classifications"], + "interpretation": "Review the candidates for legitimate dependencies, recovery purposes, ownership, and cost. There is no universal acceptable count.", + "limitations": "A candidate count, not confirmed business waste or a share of spend. Counts across resource types have different financial impacts.", + "plainLanguage": "Number of orphan candidates returned, not a measured percentage of unused resources.", + "exploreHint": "Confirm nothing depends on them, then delete them in the Azure portal or with Azure CLI." + }, + { + "id": "computational-waste", + "name": "Computational Waste Percentage", + "domain": "Optimize", + "definition": "Scan proxy: the percentage of currently running VMs flagged idle or underutilized, not a percentage of wasted spend.", + "sourceTool": "scan_idle_vms", + "compute": true, + "unit": "% of running VMs (proxy)", + "calculation": "100 x idle-or-underutilized candidate count / currently running VMs scanned. Missing metrics suppress the percentage.", + "requiredInputs": ["Running-VM inventory", "14-day CPU and inbound/outbound network measurements", "Complete metric-read coverage"], + "interpretation": "Lower means fewer VMs meet this scan's screening rules. Review the Calculation and thresholds section; no candidates is not proof of optimized compute.", + "limitations": "Count-weighted, not cost-weighted. Does not assess memory, disk activity, peaks, availability, or business utility. Averages can hide bursts.", + "plainLanguage": "Compute you are running and paying for but barely using. Shown as the share of running VMs flagged idle or underutilized.", + "exploreHint": "Deallocate confirmed idle VMs in the Azure portal or with Azure CLI. Deallocating is reversible; the disks keep billing." + }, + { + "id": "percent-storage-frequent-tier", + "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": "%", + "calculation": "Reference formula: 100 x Hot-tier blob bytes / all in-scope blob bytes. This scan does not calculate it.", + "requiredInputs": ["Per-blob tier and byte inventory across all relevant tiers", "Common scope and observation time"], + "interpretation": "There is no target Hot-tier percentage. Select tiers according to access patterns, latency, retention, and total cost.", + "limitations": "The scan selects accounts with Hot or unspecified default tier. That default does not establish each blob's tier or the byte-weighted distribution.", + "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": "%", + "calculation": "Reference formula: 100 x storage cost for data not accessed within an agreed window / comparable storage cost. Not calculated here.", + "requiredInputs": ["Per-blob access history and retention window", "Matching storage costs and scope"], + "interpretation": "Unused-looking data may have recovery or compliance value. Assess net savings and retrieval requirements before changing retention or tier.", + "limitations": "Account-level transaction counts cannot identify individual cold blobs or their cost. Low activity is a screening signal, not a measured decay ratio.", + "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": "Average percentage consumed across comparable budgets, not a time-normalized burn rate.", + "sourceTool": "scan_budget_status", + "compute": true, + "unit": "%", + "calculation": "Mean of 100 x ActualSpend / Amount across budgets with verified comparable monthly windows and currencies.", + "requiredInputs": ["Complete budget inventory", "Known amounts and actual spend", "One currency and comparable current-month periods", "Nonoverlapping subscription scopes"], + "interpretation": "Compare with the expected spending profile for the elapsed budget period. This is percent consumed, not a time-normalized spending velocity.", + "limitations": "Unweighted across budgets. Multiple budgets within one subscription, missing fields, or incompatible periods make the aggregate unavailable. Forecast availability is separate.", + "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": "Actual spend as a percentage of comparable budget amounts; the displayed value is percent of plan, not signed variance.", + "sourceTool": "scan_budget_status", + "compute": true, + "unit": "%", + "calculation": "The displayed value is 100 x total actual spend / total budget amount, expressed as percent of plan, not signed variance.", + "requiredInputs": ["Complete, nonoverlapping monthly budgets", "Known actual and budget amounts", "Common currency and verified reporting window"], + "interpretation": "Compare period-to-date consumption with the spending plan and available forecasts. A value below 100% does not by itself establish that spending is on track.", + "limitations": "Overlapping budgets are not combined. The displayed ratio is not forecast accuracy, and a missing forecast is not zero.", + "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": "Observed anomaly-alert and configured-rule counts, not a measured detection rate or avoided cost.", + "sourceTool": "scan_anomaly_alerts", + "compute": true, + "unit": "count", + "calculation": "Observed anomaly-alert count and configured anomaly-rule count. No detection-rate denominator is available.", + "requiredInputs": ["Complete alert and scheduled-rule inventories", "Anomaly alert classifications"], + "interpretation": "Confirm useful alert coverage and response ownership. More alerts are not necessarily better, and no alerts does not prove that no anomalies occurred.", + "limitations": "A count proxy, not detection recall, avoided cost, or a rate over all actual anomalies. Inventory failures leave it unavailable.", + "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": "Estimated commitment savings", + "domain": "Quantify", + "definition": "A month-to-date commitment savings estimate for usage charges in the reported billing currency, using assumed discounts rather than measured savings.", + "sourceTool": "scan_savings_realized", + "compute": true, + "unit": "currency/period", + "calculation": "RISavingsMonthToDate + SPSavingsMonthToDate from the scanner's assumed-discount model. AHB is reported separately.", + "requiredInputs": ["Eligible amortized usage charges", "Known single currency and captured UTC period", "Assumed reservation and savings-plan discounts"], + "interpretation": "Use only as a review estimate. Establish realized savings using matched pay-as-you-go counterfactuals and benefit charges.", + "limitations": "Assumes 40% RI and 25% savings-plan discounts; excludes purchases, refunds, unused commitment charges, and the separate AHB estimate. Not annualized and not a measured effective savings rate.", + "plainLanguage": "This scan estimates commitment savings for the reported UTC period, not a savings percentage or an annual projection. Azure Hybrid Benefit has a separate USD estimate for 730 hours and isn't added to this amount. Actual discounts vary by SKU, term, region, and agreement.", + "exploreHint": "Validate the estimate against matching pay-as-you-go rates and benefit usage before reporting realized savings." + }, + { + "id": "pct-legacy-resource", + "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": "%", + "calculation": "Reference formula: 100 x legacy resources / comparable resource inventory. Only candidate counts are returned by this scan.", + "requiredInputs": ["Verified legacy-resource classifications", "A complete, agreed inventory denominator"], + "interpretation": "Prioritize by support deadlines, workload risk, migration effort, and verified price-performance rather than targeting an arbitrary percentage.", + "limitations": "Different resource types are not financially equivalent. A newer SKU does not by itself prove a cost saving.", + "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", + "calculation": "Reference formula: emissions / spend for the same scope and reporting period. Not calculated here.", + "requiredInputs": ["Carbon measurements and units", "Spend with a known currency", "Matching reporting month and subscription scope"], + "interpretation": "Compare the same workload and methodology over time. Pair with useful output measures; currency changes or prices alone can change the ratio.", + "limitations": "Published carbon data can lag cost data. Current-month spend cannot be substituted for an older carbon-reporting month.", + "plainLanguage": "Carbon per unit of spend requires matching emissions and cost periods.", + "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", + "calculation": "TotalTokens, with available TotalAICost shown separately for the reported period. This entry is not cost per token.", + "requiredInputs": ["Available token measurements", "Reported usage window", "Comparable cost and currency to show the spend figure"], + "interpretation": "Relate token demand to useful output, model choice, and input/output mix. Token volume alone is neither good nor bad.", + "limitations": "Usage can be partial when metric reads fail. Total AI spend may include accounts without measured tokens; it is not an attributed cost of those tokens.", + "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", + "calculation": "CostPerRequest: costs of accounts with measured requests divided by their measured request count for the reported period.", + "requiredInputs": ["Matched account costs and requests", "One billing currency", "Complete metric coverage and no RateIssue"], + "interpretation": "Compare the same model, request mix, service quality, and period with your workload baseline. Cheap failed requests are not a business improvement.", + "limitations": "Blended across measured accounts. Missing or mismatched measurements suppress the rate; it is not a per-model price quote or cost per successful business outcome.", + "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": "%", + "calculation": "Reference formula: 100 x unallocated shared cost / agreed shared-cost pool. Not calculated as a KPI here.", + "requiredInputs": ["Agreed shared-pool boundaries", "Comparable pool and allocation amounts", "Validated ownership and allocation rules"], + "interpretation": "Reduce unexplained shared cost under an agreed allocation policy. Arithmetic reconciliation alone does not establish fair allocation.", + "limitations": "Allocation outputs are read-only showback proposals, not applied billing rules or proof of acceptance by workload owners.", + "plainLanguage": "Shared platform cost not yet split across its consumers.", + "exploreHint": "Split it with a shared cost allocation or usage-proportional allocation scan." + }, + { + "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": "%", + "calculation": "Reference formula: 100 x cost confirmed as correctly allocated / total comparable cost. Not calculated here.", + "requiredInputs": ["Comparable costs and proposed allocations", "Independent ownership validation", "Agreed accuracy criteria"], + "interpretation": "Set targets with finance and workload owners. Validate correctness separately from whether every cent was allocated.", + "limitations": "Tag presence and allocations that sum to the pool cannot prove ownership accuracy. External validation is required.", + "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", + "calculation": "Reference formula: all agreed direct, shared, licensing, and operating costs attributable to a workload over its stated lifecycle or period. Not calculated here.", + "requiredInputs": ["Workload boundary and period", "Resource-cost relationships", "Shared, licensing, and non-cloud operating costs"], + "interpretation": "Compare equivalent workload scope, service quality, and lifecycle assumptions, preferably per business outcome.", + "limitations": "The VM cost breakdown is a partial resource-cost view, not full business TCO. External and shared costs need separate evidence.", + "plainLanguage": "The true all-in cost of one workload, not just its compute line.", + "exploreHint": "The VM cost breakdown scan 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", + "calculation": "Reference formula: elapsed time between successful data refreshes. Not calculated in this report.", + "requiredInputs": ["At least two verified refresh timestamps for each source", "Expected refresh schedule"], + "interpretation": "Compare actual update cadence with the agreed data-freshness objective.", + "limitations": "A last-refresh timestamp alone establishes recency, not update frequency. Discovery results are not attached as KPI measurements by this launcher.", + "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", + "calculation": "Reference formula: time a charge becomes queryable minus its usage or charge occurrence time. Not calculated in this report.", + "requiredInputs": ["Charge occurrence time", "First ingestion or visibility time", "Matching source records"], + "interpretation": "Compare observed latency with the reporting objective for each source. Different services and agreements can have different delays.", + "limitations": "Neither scan time nor a latest-export timestamp establishes end-to-end visibility delay. Live API data is not guaranteed to be real-time.", + "plainLanguage": "The lag between spending money and seeing it in reports.", + "exploreHint": "Compare charge and ingestion timestamps before making a data-latency claim." + }, + { + "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": "%", + "calculation": "Reference formula: 100 x validated covered units / agreed total in-scope units. Not calculated in this report.", + "requiredInputs": ["Agreed coverage basis, such as spend, subscriptions, or sources", "Complete expected inventory", "Reconciled ingested coverage"], + "interpretation": "Declare the coverage basis and gaps. Subscription coverage and percentage of spend covered are not interchangeable.", + "limitations": "A configured connection does not prove complete data delivery. This launcher does not attach source-discovery coverage as a measured KPI.", + "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/Get-AHBOpportunities.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 new file mode 100644 index 000000000..e6f4809b7 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AHBOpportunities.ps1 @@ -0,0 +1,122 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-AHBOPPORTUNITIES.PS1 +# AZURE FINOPS MULTITOOL - Azure Hybrid Benefit Gap Detection +########################################################################### +# 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 id, 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 -All + $windowsVMs = if ($result) { @($result.Data) } else { @() } + } + catch { + throw "Windows VM AHB inventory is incomplete: $($_.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 id, 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 -All + $sqlVMs = if ($result) { @($result.Data) } else { @() } + } + catch { + throw "SQL VM AHB inventory is incomplete: $($_.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 id, 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 -All + $sqlDBs = if ($result) { @($result.Data) } else { @() } + } + catch { + throw "SQL Database AHB inventory is incomplete: $($_.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) + SavingsCurrency = 'USD' + SavingsPeriod = '730-hour estimate for current VM inventory' + 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..e9fbb225b --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AIWorkloadMetrics.ps1 @@ -0,0 +1,472 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-AIWORKLOADMETRICS.PS1 +# AZURE FINOPS MULTITOOL - AI/LLM Workload KPIs (token economics) +########################################################################### +# 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 + $metricFailures = [System.Collections.Generic.List[string]]::new() + + 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 -All + $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 { + throw "AI workload inventory is incomplete: $($_.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 + DetectionFailed = $false + 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() + $now = $now.AddTicks(-($now.Ticks % [TimeSpan]::TicksPerSecond)) + $monthStart = $now.Date.AddDays(1 - $now.Day) + $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 = 'Unknown' + # A tenant can bill subscriptions in different currencies; keep them all. + $currenciesSeen = @{} + $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) { + $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.CostByAcct.Count -gt 0 + 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 + $tokenError = $null + try { $token = Get-PlainAccessToken -ResourceUrl $armBase } catch { $tokenError = $_.Exception.Message } + + 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; SubscriptionId = $acct.SubscriptionId; Tokens = 0.0; Requests = 0.0; TokenBases = @{}; TokensMeasured = $false; RequestsMeasured = $false; MetricsComplete = $false } + } + + # 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 -MaximumRedirection 0 -ErrorAction Stop + $data = $resp.Content | ConvertFrom-Json + if (-not $data.value) { throw 'No AI usage metrics were returned.' } + + foreach ($metric in $data.value) { + $mName = $metric.name.value + if ($mName -notin @('ProcessedPromptTokens', 'GeneratedTokens', 'TokenTransaction', 'AzureOpenAIRequests')) { continue } + if ($metric.errorCode -and $metric.errorCode -ne 'Success') { throw "AI metric $mName is unavailable: $($metric.errorCode)." } + 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 + $hasSamples = $false + foreach ($dp in $ts.data) { + if ($null -ne $dp.total) { + $sample = [double]$dp.total + if ([double]::IsNaN($sample) -or [double]::IsInfinity($sample) -or $sample -lt 0) { throw 'AI usage contains an invalid metric sample.' } + $sum += $sample + $hasSamples = $true + } + } + + $modelKey = "$acctKey|$deployment" + if (-not $modelTokens.ContainsKey($modelKey)) { + $modelTokens[$modelKey] = @{ Deployment = $deployment; Account = $acct.Name; ResourceId = $acctKey; SubscriptionId = $acct.SubscriptionId; Prompt = 0.0; Generated = 0.0; Total = 0.0; PromptMeasured = $false; GeneratedMeasured = $false; TotalMeasured = $false } + } + switch ($mName) { + 'ProcessedPromptTokens' { $modelTokens[$modelKey].Prompt += $sum; $modelTokens[$modelKey].PromptMeasured = $modelTokens[$modelKey].PromptMeasured -or $hasSamples; $totalPrompt += $sum } + 'GeneratedTokens' { $modelTokens[$modelKey].Generated += $sum; $modelTokens[$modelKey].GeneratedMeasured = $modelTokens[$modelKey].GeneratedMeasured -or $hasSamples; $totalGen += $sum } + 'TokenTransaction' { $modelTokens[$modelKey].Total += $sum; $modelTokens[$modelKey].TotalMeasured = $modelTokens[$modelKey].TotalMeasured -or $hasSamples } + 'AzureOpenAIRequests' { $totalReq += $sum; $acctTokens[$acctKey].Requests += $sum; $acctTokens[$acctKey].RequestsMeasured = $acctTokens[$acctKey].RequestsMeasured -or $hasSamples } + } + } + } + $acctTokens[$acctKey].MetricsComplete = $true + } + catch { + # "No usage yet" and "the call failed" both land here, so record it + # rather than letting a throttled account read as zero tokens. + [void]$metricFailures.Add("$acctKey : $($_.Exception.Message)") + } + } + } + elseif ($tokenError) { + Write-Warning "AI workload metrics skipped: could not acquire an access token. $tokenError" + } + } + + if (-not $fromHub) { + foreach ($model in $modelTokens.Values) { + if ($model.TotalMeasured) { $model.TokenBasis = 'TokenTransaction' } + elseif ($model.PromptMeasured -and $model.GeneratedMeasured) { + $model.Total = $model.Prompt + $model.Generated + $model.TokenBasis = 'Prompt + generated' + } + else { $model.Total = $null; $model.TokenBasis = 'Unavailable'; $acctTokens[$model.ResourceId].MetricsComplete = $false } + if ($null -ne $model.Total) { + $totalTokens += $model.Total + $acctTokens[$model.ResourceId].Tokens += $model.Total + $acctTokens[$model.ResourceId].TokensMeasured = $true + $acctTokens[$model.ResourceId].TokenBases[$model.TokenBasis] = $true + } + } + foreach ($accountKey in $acctTokens.Keys) { + $usage = $acctTokens[$accountKey] + if (-not $usage.TokensMeasured) { $usage.Tokens = $null; $usage.MetricsComplete = $false } + if (-not $usage.RequestsMeasured) { $usage.Requests = $null; $usage.MetricsComplete = $false } + if (-not $usage.MetricsComplete -and @($metricFailures | Where-Object { $_.StartsWith("$accountKey :", [StringComparison]::OrdinalIgnoreCase) }).Count -eq 0) { + $metricFailures.Add("$accountKey : AI usage samples are incomplete.") + } + } + $metricsOk = @($acctTokens.Values | Where-Object { $_.TokensMeasured -or $_.RequestsMeasured }).Count -gt 0 + } + + # -- 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 = 'Custom' + timePeriod = @{ from = $fromStr; to = $toStr } + 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 + $cdata = Get-CostQueryResult -FirstResponse $resp -Payload $body -Context 'AI spend' + + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + 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 { '' } + $rowCurrency = if ($iCur -ge 0) { ([string]$row[$iCur]).Trim().ToUpperInvariant() } else { '' } + if ($rowCurrency -notmatch '^[A-Z]{3}$' -or $rowCurrency -in @('XXX', 'XTS')) { $rowCurrency = 'Unknown' } + Add-CurrencySeen -Seen $currenciesSeen -Currency $rowCurrency + $aiCost += $amount + if ($rid) { $costByAcct[$rid.ToLowerInvariant()] += $amount } + } + } + } + } + } + catch { + throw "AI cost query failed: $($_.Exception.Message)" + } + } + + # -- 3: KPIs ---------------------------------------------------------- + if (-not $fromHub) { $currency = Resolve-CurrencyLabel -Seen $currenciesSeen -Fallback 'Unknown' } + $costIssue = if ($currenciesSeen.ContainsKey('UNKNOWN') -or ($currency -notmatch '^[A-Za-z]{3}$' -and $currency -ne 'Mixed') -or $currency -in @('XXX', 'XTS')) { + 'AI cost currency is missing or invalid; combined costs and rates are unavailable.' + } + elseif ($currency -eq 'Mixed') { + 'Multiple billing currencies cannot be combined; AI costs and rates are unavailable.' + } + elseif (-not $costOk) { 'AI cost data is unavailable.' } + else { $null } + $costAvailable = $costOk -and -not $costIssue + $tokenAccounts = @($acctTokens.Keys | Where-Object { $acctTokens[$_].Tokens -gt 0 }) + $requestAccounts = @($acctTokens.Keys | Where-Object { $acctTokens[$_].Requests -gt 0 }) + $rateIssue = if ($costIssue) { $costIssue } + elseif ($metricFailures.Count -gt 0) { 'AI account metrics are incomplete; aggregate token and request rates are unavailable.' } + elseif (-not $metricsOk) { 'No comparable AI usage measurements were returned; aggregate rates are unavailable.' } + elseif (@($acctTokens.Keys | Where-Object { ($acctTokens[$_].Tokens -gt 0 -or $acctTokens[$_].Requests -gt 0) -and -not $costByAcct.ContainsKey($_) }).Count -gt 0) { + 'Costs are missing for accounts with measured AI usage; aggregate rates are unavailable.' + } + elseif ($totalTokens -gt 0 -and ($tokenAccounts.Count -eq 0 -or ($tokenAccounts | ForEach-Object { $acctTokens[$_].Tokens } | Measure-Object -Sum).Sum -ne $totalTokens)) { + 'Token totals cannot be matched to account costs; aggregate rates are unavailable.' + } + else { $null } + $tokenCost = if (-not $rateIssue) { ($tokenAccounts | ForEach-Object { $costByAcct[$_] } | Measure-Object -Sum).Sum } else { $null } + $requestCost = if (-not $rateIssue) { ($requestAccounts | ForEach-Object { $costByAcct[$_] } | Measure-Object -Sum).Sum } else { $null } + $costPer1kTokens = if (-not $rateIssue -and $null -ne $tokenCost -and $totalTokens -gt 0) { [math]::Round(($tokenCost / $totalTokens) * 1000, 4) } else { $null } + $costPerRequest = if (-not $rateIssue -and $null -ne $requestCost -and $totalReq -gt 0) { [math]::Round($requestCost / $totalReq, 4) } else { $null } + + $byModel = @( + $modelTokens.GetEnumerator() | ForEach-Object { + $mt = if ($fromHub -and $_.Value.Total -le 0) { $_.Value.Prompt + $_.Value.Generated } else { $_.Value.Total } + [PSCustomObject]@{ + Deployment = if ($_.Value.Deployment) { $_.Value.Deployment } else { $_.Key } + Account = $_.Value.Account + ResourceId = $_.Value.ResourceId + SubscriptionId = $_.Value.SubscriptionId + TokenBasis = if ($fromHub) { 'Billed token quantity' } else { $_.Value.TokenBasis } + PromptTokens = if ($fromHub -or $_.Value.PromptMeasured) { [long]$_.Value.Prompt } else { $null } + GeneratedTokens = if ($fromHub -or $_.Value.GeneratedMeasured) { [long]$_.Value.Generated } else { $null } + TotalTokens = if ($null -ne $mt) { [long]$mt } else { $null } + PctOfTokens = if ($null -ne $mt -and $totalTokens -gt 0) { [math]::Round(($mt / $totalTokens) * 100, 1) } else { $null } + } + } | Sort-Object TotalTokens -Descending + ) + + $byAccount = @( + $acctTokens.GetEnumerator() | ForEach-Object { + $c = if ($costAvailable -and $costByAcct.ContainsKey($_.Key)) { $costByAcct[$_.Key] } else { $null } + $tk = $_.Value.Tokens + [PSCustomObject]@{ + Name = $_.Value.Name + ResourceId = $_.Key + SubscriptionId = $_.Value.SubscriptionId + TokenBasis = if ($fromHub) { 'Billed token quantity' } elseif ($_.Value.TokenBases.Count -eq 1) { @($_.Value.TokenBases.Keys)[0] } elseif ($_.Value.TokenBases.Count -gt 1) { 'Mixed reported token metrics' } else { 'Unavailable' } + Tokens = if ($null -ne $tk) { [long]$tk } else { $null } + Requests = if (-not $fromHub -and $null -ne $_.Value.Requests) { [long]$_.Value.Requests } else { $null } + MetricsComplete = if ($fromHub) { $null } else { [bool]$_.Value.MetricsComplete } + Currency = $currency + Cost = if ($null -ne $c) { [math]::Round($c, 2) } else { $null } + CostPer1KTokens = if ($null -ne $c -and $tk -gt 0 -and ($fromHub -or $_.Value.MetricsComplete)) { [math]::Round(($c / $tk) * 1000, 4) } else { $null } + } + } | 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.' + } + + if ($costIssue) { $note = "$note $costIssue" } + if ($rateIssue -and $rateIssue -ne $costIssue) { $note = "$note $rateIssue" } + if (-not $rateIssue) { $note = "$note Rates use costs for accounts with corresponding measured token or request usage, not total AI spend." } + if ($metricFailures.Count -gt 0) { + Write-Warning " Metrics unavailable for $($metricFailures.Count) AI account(s); usage totals are partial and aggregate rates are unavailable." + foreach ($f in ($metricFailures | Select-Object -First 3)) { Write-Verbose " $f" } + } + + $tokensMeasured = if ($fromHub) { $agg.HasTokens } else { @($acctTokens.Values | Where-Object TokensMeasured).Count -gt 0 } + $requestsMeasured = -not $fromHub -and @($acctTokens.Values | Where-Object RequestsMeasured).Count -gt 0 + + return [PSCustomObject]@{ + HasData = $hasData + AIFootprint = $footprint + TotalPromptTokens = [long]$totalPrompt + TotalGeneratedTokens = [long]$totalGen + TotalTokens = if ($tokensMeasured) { [long]$totalTokens } else { $null } + TotalRequests = if ($requestsMeasured) { [long]$totalReq } else { $null } + HasTokenData = $tokensMeasured + HasRequestData = $requestsMeasured + TotalAICost = if ($costAvailable) { [math]::Round($aiCost, 2) } else { $null } + Currency = $currency + CostAvailable = $costAvailable + CostIssue = $costIssue + RateIssue = $rateIssue + CostPer1KTokens = $costPer1kTokens + CostPerRequest = $costPerRequest + ByModel = $byModel + ByAccount = $byAccount + Period = if ($fromHub) { $agg.Period } else { 'MonthToDate' } + UsagePeriodStartUtc = if (-not $fromHub) { $monthStart } else { $null } + UsagePeriodEndUtc = if (-not $fromHub) { $now } else { $null } + CostBasis = if (-not $fromHub) { 'AmortizedCost' } else { 'Export cost' } + Source = if ($fromHub) { 'FinOpsHub' } else { 'API' } + ScannedSubs = $Subscriptions.Count + DetectionFailed = $false + # Accounts whose metrics could not be read; token totals exclude them. + MetricFailures = $metricFailures.Count + MetricFailureDetail = @($metricFailures) + Note = $note + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 new file mode 100644 index 000000000..6253e8f52 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AhbVmSavingsRatio.ps1 @@ -0,0 +1,96 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-AHBVMSAVINGSRATIO.PS1 +# AZURE FINOPS MULTITOOL - Per-SKU Azure Hybrid Benefit Savings Ratio +########################################################################### +# 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 { + # OData escapes a single quote by doubling it. + $safeRegion = $Region.Replace("'", "''") + $safeSize = $VmSize.Replace("'", "''") + $filter = "armRegionName eq '$safeRegion' and armSkuName eq '$safeSize' and priceType eq 'Consumption' and serviceName eq 'Virtual Machines'" + $url = "https://prices.azure.com/api/retail/prices?`$filter=$([uri]::EscapeDataString($filter))" + $resp = Invoke-RestMethod -Uri $url -Method GET -TimeoutSec 20 + $items = @($resp.Items) | Where-Object { + $_.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..edd22cec2 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-AnomalyAlerts.ps1 @@ -0,0 +1,170 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-ANOMALYALERTS.PS1 +# AZURE FINOPS MULTITOOL - Cost Management Anomaly & Budget Alerts +########################################################################### +# 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() + $readErrors = [Collections.Generic.List[string]]::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 + $data = Get-FinOpsListResult -FirstResponse $resp -Context "alerts for $($sub.Name)" + + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + 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 ($null -ne $det.amount) { [math]::Round((Get-HubCostValue -Row $det -Column 'amount'), 2) } else { $null } + $currentSpend = if ($null -ne $det.currentSpend) { [math]::Round((Get-HubCostValue -Row $det -Column 'currentSpend'), 2) } else { $null } + $unit = if ($det.unit) { $det.unit } else { $null } + + # 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 { + $readErrors.Add($_.Exception.Message) + 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 + $data = Get-FinOpsListResult -FirstResponse $resp -Context "scheduled actions for $($sub.Name)" + + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + 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 { + $readErrors.Add($_.Exception.Message) + 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) + CoverageIncomplete = ($readErrors.Count -gt 0) + ReadErrors = @($readErrors) + Note = if ($readErrors.Count -gt 0) { 'Alert and rule coverage is incomplete. ' + ($readErrors -join ' ') } else { $null } + ConfiguredRules = @($configuredRules) + TotalAlerts = $triggeredAlerts.Count + AnomalyAlertCount = $anomalyCount + ActiveAlertCount = $activeCount + BudgetAlertCount = $budgetCount + # Disabled or expired rules don't detect anything, so they don't count as coverage. + ConfiguredRuleCount = @($configuredRules | Where-Object { $_.Status -eq 'Enabled' }).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..62e9f8781 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingAccount.ps1 @@ -0,0 +1,138 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + +########################################################################### +# GET-BILLINGACCOUNT.PS1 +# DISCOVER BILLING ACCOUNTS + COST ALLOCATION ELIGIBILITY +########################################################################### +# 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 { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + } + return [PSCustomObject]@{ + HasData = $false + Accounts = @() + CoverageIncomplete = $true + Note = "$msg Ensure you are signed in (Connect-AzAccount) and have at least Billing Reader." + } + } + + $payload = $null + try { $payload = Get-FinOpsListResult -FirstResponse $resp -Context 'billing accounts' } catch { + return [pscustomobject]@{ HasData = $false; Accounts = @(); CoverageIncomplete = $true; Note = $_.Exception.Message } + } + $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 = @() + CoverageIncomplete = $false + 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 + } + } + + $note = 'Only Eligible=true accounts (EA/MCA) support cost allocation rules. RulesAccess reports read access, not permission to change rules.' + + return [PSCustomObject]@{ + HasData = $true + Accounts = @($accounts | Sort-Object -Property @{ Expression = 'Eligible'; Descending = $true }, 'DisplayName') + CoverageIncomplete = $false + 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..d85aa30b6 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BillingStructure.ps1 @@ -0,0 +1,208 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + +########################################################################### +# GET-BILLINGSTRUCTURE.PS1 +# AZURE FINOPS MULTITOOL - Billing Profiles, Invoice Sections & Cost Allocation +########################################################################### +# 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 = @() + $readErrors = [Collections.Generic.List[string]]::new() + + # -- Step 1: Get Billing Accounts ----------------------------------- + # Correlation happens after the list call: billingInfo/default is not a valid + # resource type and 404s on every api-version, which previously left the + # linked-account set empty and skipped every account. + try { + $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" + $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET + if ($baResp.StatusCode -eq 200) { + $baResult = Get-FinOpsListResult -FirstResponse $baResp -Context 'billing accounts' + if ($baResult.value) { + $scope = Get-FinOpsBillingScope -BillingAccounts @($baResult.value) -Subscriptions $Subscriptions + if ($scope.CoverageIncomplete) { + foreach ($issue in $scope.ReadErrors) { $readErrors.Add([string]$issue) } + } + if (-not $scope.Resolved) { + $readErrors.Add([string]$scope.Reason) + Write-Warning " $($scope.Reason)" + } + else { + Write-Host " Resolved $(@($scope.Accounts).Count) billing account(s) linked to scanned subscriptions." -ForegroundColor Cyan + } + foreach ($ba in @($scope.Accounts)) { + $props = $ba.properties + $billingAccounts += [PSCustomObject]@{ + AccountId = $ba.name + DisplayName = $props.displayName + AgreementType = $props.agreementType + AccountType = $props.accountType + AccountStatus = $props.accountStatus + FullId = $ba.id + } + } + } + } + else { + throw "Billing accounts returned HTTP $($baResp.StatusCode); results are incomplete." + } + } + catch { + $readErrors.Add($_.Exception.Message) + 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 = Get-FinOpsListResult -FirstResponse $bpResp -Context "billing profiles for $($ba.DisplayName)" + 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 = Get-FinOpsListResult -FirstResponse $isResp -Context "invoice sections for $($bpProps.displayName)" + 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 + } + } + } + } + else { throw "Invoice sections returned HTTP $($isResp.StatusCode); results are incomplete." } + } + catch { + $readErrors.Add($_.Exception.Message) + Write-Warning " Invoice sections query failed for profile $($bpProps.displayName): $($_.Exception.Message)" + } + } + } + } + else { throw "Billing profiles returned HTTP $($bpResp.StatusCode); results are incomplete." } + } + catch { + $readErrors.Add($_.Exception.Message) + 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 = Get-FinOpsListResult -FirstResponse $deptResp -Context "departments for $($ba.DisplayName)" + 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 + } + } + } + } + else { throw "EA departments returned HTTP $($deptResp.StatusCode); results are incomplete." } + } + catch { + $readErrors.Add($_.Exception.Message) + 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 = Get-FinOpsListResult -FirstResponse $carResp -Context "cost allocation rules for $($ba.DisplayName)" + 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) { + throw "Cost allocation rules for $($ba.DisplayName) returned HTTP $($carResp.StatusCode); results are incomplete." + } + } + catch { + $readErrors.Add($_.Exception.Message) + Write-Warning " Cost allocation rules query failed: $($_.Exception.Message)" + } + } + + return [PSCustomObject]@{ + BillingAccounts = $billingAccounts + CoverageIncomplete = ($readErrors.Count -gt 0) + ReadErrors = @($readErrors) + Note = if ($readErrors.Count -gt 0) { 'Billing inventory is incomplete. ' + ($readErrors -join ' ') } else { $null } + 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..b215eb46b --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-BudgetStatus.ps1 @@ -0,0 +1,487 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +param() + +########################################################################### +# GET-BUDGETSTATUS.PS1 +# AZURE FINOPS MULTITOOL - Budget vs. Actual Comparison +########################################################################### +# Purpose: Query Azure Budgets (Consumption API) for each subscription to +# show configured budget amount vs current spend. Highlights +# subscriptions at risk of overrun. +########################################################################### + +function Format-BudgetAmount { + param($Value, [string]$Currency) + + if ($null -eq $Value -or [string]::IsNullOrWhiteSpace($Currency)) { return 'Unavailable' } + try { + $amount = Get-HubCostValue -Row ([pscustomobject]@{ Value = $Value }) -Column 'Value' + return '{0} {1:N2}' -f $Currency, $amount + } + catch { return 'Unavailable' } +} + +function Get-BudgetStatus { + [CmdletBinding()] + param( + [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 + $scannedSubs = $subCount + $coverageIncomplete = $false + # Subscriptions whose budget query never answered. Counting these as "no + # budget" would turn missing access into a confident coverage percentage. + $unreadableSubs = 0 + + # -- For large tenants, sample first to see if budgets exist -------- + $subsToQuery = $Subscriptions + if ($subCount -gt 50) { + $sampleSize = [math]::Min(10, $subCount) + Write-Host " Large tenant: sampling $sampleSize of $subCount subs for budgets..." -ForegroundColor Yellow + # Random rather than the first N: subscription order is not arbitrary, so + # the head of the list is not a representative sample. + $sampleSubs = @($Subscriptions | Get-Random -Count $sampleSize) + $sampleHits = 0 + $sampleErrors = 0 + foreach ($sub in $sampleSubs) { + try { + $budgetPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/budgets?api-version=2023-05-01" + $resp = Invoke-AzRestMethodWithRetry -Path $budgetPath -Method GET + if ($resp.StatusCode -eq 200) { + $sampleBudgets = @(foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -RootNextLink -Context "budget sample for $($sub.Name)")) { + ($page.Content | ConvertFrom-Json -ErrorAction Stop).value + }) + if ($sampleBudgets -and $sampleBudgets.Count -gt 0) { $sampleHits++ } + } + else { $sampleErrors++ } + } + catch { + $sampleErrors++ + Write-Verbose "Budget sample failed for $($sub.Name): $($_.Exception.Message)" + } + } + + if ($sampleHits -eq 0 -and $sampleErrors -eq 0) { + # Every probe answered and none held a budget. That is evidence about + # the sampled subscriptions only - budgets can still exist in the ones + # never queried - so the result is marked incomplete rather than + # reported as "no budgets" for the whole tenant. + Write-Host " No budgets found in sample of $sampleSize subs - skipping remaining (coverage unverified)" -ForegroundColor Yellow + $sampled = $true + $coverageIncomplete = $true + $scannedSubs = $sampleSize + $subsWithoutBudget = $sampleSize + $subsToQuery = @() # Skip the main loop + } + elseif ($sampleHits -eq 0) { + Write-Warning " Budget sample inconclusive ($sampleErrors of $sampleSize probes failed); querying all $subCount subs instead of assuming none." + } + else { + Write-Host " Budgets found in sample ($sampleHits/$sampleSize), querying all $subCount subs..." -ForegroundColor Cyan + } + } + + $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) { + $budgetRows = @(foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -RootNextLink -Context "budgets for $($sub.Name)")) { + ($page.Content | ConvertFrom-Json -ErrorAction Stop).value + }) + foreach ($budgetRecord in $budgetRows) { + if ($budgetRecord -isnot [pscustomobject] -or $budgetRecord.name -isnot [string] -or + [string]::IsNullOrWhiteSpace($budgetRecord.name) -or $budgetRecord.properties -isnot [pscustomobject] -or + @($budgetRecord.properties.PSObject.Properties).Count -eq 0) { + throw 'The budget list contains an invalid record; budget coverage is unverified.' + } + } + if ($budgetRows.Count -gt 0) { + $subsWithBudget++ + foreach ($budget in $budgetRows) { + $bp = $budget.properties + $issues = [System.Collections.Generic.List[string]]::new() + $amount = $null + try { + $value = Get-HubCostValue -Row $bp -Column 'amount' + if ($value -le 0) { throw 'Budget amount must be positive.' } + $amount = $value + } + catch { [void]$issues.Add('Budget amount is missing or invalid.') } + $timeGrain = $bp.timeGrain + $category = $bp.category + + $actualSpend = $null + $forecast = $null + $spendKnown = $false + $spendSource = 'Unavailable' + $forecastSource = 'Unavailable' + $spendCurrency = $null + $actualUnit = ([string]$bp.currentSpend.unit).Trim().ToUpperInvariant() + $forecastUnit = ([string]$bp.forecastSpend.unit).Trim().ToUpperInvariant() + if ($actualUnit) { $spendCurrency = $actualUnit } + elseif ($forecastUnit) { $spendCurrency = $forecastUnit } + + if ($bp.currentSpend -and $actualUnit) { + try { + $actualSpend = Get-HubCostValue -Row $bp.currentSpend -Column 'amount' + $spendKnown = $true + $spendSource = 'Budget' + } + catch { [void]$issues.Add('Current spend amount is missing or invalid.') } + } + else { [void]$issues.Add('Current spend or its unit is unavailable.') } + if ($bp.forecastSpend -and $forecastUnit -and $forecastUnit -eq $spendCurrency) { + try { + $forecast = Get-HubCostValue -Row $bp.forecastSpend -Column 'amount' + $forecastSource = 'Budget' + } + catch { [void]$issues.Add('Forecast amount is missing or invalid.') } + } + elseif ($bp.forecastSpend) { [void]$issues.Add('Forecast unit is missing or does not match the budget unit.') } + else { [void]$issues.Add('Budget forecast is unavailable.') } + + $pctUsed = if ($spendKnown -and $null -ne $amount) { [math]::Round(($actualSpend / $amount) * 100, 1) } else { $null } + $pctForecast = if ($forecastSource -ne 'Unavailable' -and $null -ne $amount) { [math]::Round(($forecast / $amount) * 100, 1) } else { $null } + + $risk = if ($null -eq $amount) { 'Unknown' } + elseif ($pctUsed -gt 100) { 'Over Budget' } + elseif ($pctForecast -gt 100) { 'Forecast Over' } + elseif (-not $spendKnown) { 'Unknown' } + elseif ($pctForecast -gt 90) { 'At Risk' } + elseif ($pctUsed -gt 90) { 'Near Limit' } + elseif ($forecastSource -eq 'Unavailable') { 'Forecast unavailable' } + elseif ($pctForecast -gt 75) { 'Watch' } + else { 'On Track' } + + # 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 + SpendSource = $spendSource + ForecastSource = $forecastSource + PctUsed = $pctUsed + PctForecast = $pctForecast + Risk = $risk + Thresholds = ($thresholds -join ', ') + ContactEmails = (($contactEmails | Select-Object -Unique) -join ', ') + ContactRoles = (($contactRoles | Select-Object -Unique) -join ', ') + TagFilter = $tagFilterStr + Filter = $bp.filter + TimePeriod = $bp.timePeriod + Scope = "/subscriptions/$($sub.Id)" + Currency = $spendCurrency + Note = ($issues -join ' ') + }) + } + } + else { + $subsWithoutBudget++ + } + } + else { + $unreadableSubs++ + } + } + catch { + Write-Warning " Budget query failed for $($sub.Name): $($_.Exception.Message)" + $unreadableSubs++ + } + } + + # 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', 'Near Limit') }).Count + + # Either an unqueried sample or an unreadable subscription leaves coverage + # unmeasured, so both suppress the percentage rather than rounding down. + if ($unreadableSubs -gt 0) { $coverageIncomplete = $true } + $readSubs = $scannedSubs - $unreadableSubs + + $note = if ($sampled) { + "Sampled $scannedSubs of $subCount subscriptions and none had a budget. Budgets may still exist in the subscriptions that were not queried, so coverage is unverified." + } + elseif ($unreadableSubs -gt 0) { + "$unreadableSubs of $subCount subscriptions could not be queried for budgets, so coverage is unverified. They are not counted as being without a budget." + } + else { $null } + + return [PSCustomObject]@{ + Budgets = @($budgets) + TotalBudgets = $budgets.Count + SubsWithBudget = $subsWithBudget + SubsWithoutBudget = $subsWithoutBudget + UnreadableSubs = $unreadableSubs + OverBudgetCount = $overBudget + AtRiskCount = $atRisk + HasData = ($budgets.Count -gt 0) + Sampled = $sampled + ScannedSubs = $readSubs + TotalSubs = $subCount + CoverageIncomplete = $coverageIncomplete + Note = $note + # Left null when incomplete: a percentage derived from a partial read + # would be taken as a measured figure for the whole tenant. + BudgetCoverage = if ($coverageIncomplete) { $null } + elseif ($Subscriptions.Count -gt 0) { + [math]::Round(($subsWithBudget / $Subscriptions.Count) * 100, 1) + } + else { 0 } + } +} + +function ConvertTo-BudgetHistoryFilter { + [CmdletBinding()] + param( + [AllowNull()][object]$Filter, + [int]$Depth = 0 + ) + + if ($Depth -gt 4) { throw 'Budget filter nesting is unsupported.' } + if ($null -eq $Filter -and $Depth -eq 0) { return $null } + if ($Filter -isnot [System.Collections.IDictionary] -and $Filter -isnot [pscustomobject]) { + throw 'Budget filter must be a structured expression.' + } + $keys = @(if ($Filter -is [System.Collections.IDictionary]) { $Filter.Keys } + else { $Filter.PSObject.Properties | ForEach-Object Name }) + if ($keys.Count -eq 0 -and $Depth -eq 0) { return $null } + if ($keys.Count -ne 1 -or $keys[0] -notin @('and', 'dimensions', 'tags')) { + throw 'Budget filter contains an unsupported or ambiguous expression.' + } + $kind = ([string]$keys[0]).ToLowerInvariant() + if ($kind -eq 'and') { + if ($Filter.and -isnot [array] -or $Filter.and.Count -lt 2) { throw 'Budget filter AND must contain at least two expressions.' } + $children = @(foreach ($child in $Filter.and) { ConvertTo-BudgetHistoryFilter -Filter $child -Depth ($Depth + 1) }) + return [ordered]@{ and = $children } + } + + $comparison = $Filter.$kind + if ($comparison -isnot [System.Collections.IDictionary] -and $comparison -isnot [pscustomobject]) { + throw 'Budget filter comparison is invalid.' + } + $comparisonKeys = @(if ($comparison -is [System.Collections.IDictionary]) { $comparison.Keys } + else { $comparison.PSObject.Properties | ForEach-Object Name }) + if ($comparisonKeys.Count -ne 3 -or @($comparisonKeys | Where-Object { $_ -notin @('name', 'operator', 'values') }).Count -gt 0 -or + $comparison.name -isnot [string] -or [string]::IsNullOrWhiteSpace($comparison.name) -or + $comparison.operator -ne 'In' -or $comparison.values -isnot [array] -or $comparison.values.Count -eq 0 -or + @($comparison.values | Where-Object { $_ -isnot [string] }).Count -gt 0) { + throw 'Budget filter requires a name, the In operator, and string values.' + } + return [ordered]@{ $kind = [ordered]@{ name = $comparison.name; operator = 'In'; values = @($comparison.values) } } +} + +function Get-BudgetHistory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Budgets, + + [Parameter()] + [ValidateRange(1, 36)] + [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() + $now = (Get-Date).ToUniversalTime() + $monthStart = $now.Date.AddDays(1 - $now.Day) + $monthDates = @(for ($monthsAgo = $MonthsBack; $monthsAgo -ge 1; $monthsAgo--) { $monthStart.AddMonths(-$monthsAgo) }) + $costCache = [System.Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + + foreach ($budget in $Budgets) { + $reason = $null + $budgetAmount = $null + $periodStart = $null + $periodEnd = [datetime]::MaxValue + $subId = [string]$budget.SubscriptionId + $queryFilter = $null + try { + $queryFilter = ConvertTo-BudgetHistoryFilter -Filter $budget.Filter + if ($null -eq $queryFilter -and $budget.TagFilter) { throw 'The structured budget filter is unavailable.' } + } + catch { $reason = "Budget history cannot apply this filter: $($_.Exception.Message)" } + $filterKey = if ($null -eq $queryFilter) { '' } else { ConvertTo-Json -InputObject $queryFilter -Depth 20 -Compress } + $cacheKey = "$subId|$filterKey" + if (-not $reason -and ($budget.Category -ne 'Cost' -or $budget.TimeGrain -ne 'Monthly')) { $reason = 'Subscription monthly costs cannot reconstruct this budget category or period.' } + elseif (-not $budget.Currency) { $reason = 'Budget currency is unavailable.' } + elseif ($budget.Scope -and $budget.Scope -ne "/subscriptions/$subId") { $reason = 'Budget scope differs from the subscription cost scope.' } + try { + $budgetAmount = Get-HubCostValue -Row $budget -Column 'Amount' + if ($budgetAmount -le 0) { throw 'Budget amount must be positive.' } + } + catch { $budgetAmount = $null; $reason = 'Budget amount is missing or invalid.' } + try { + if (-not $budget.TimePeriod.startDate) { throw 'Missing start date.' } + $periodStart = ([datetime]$budget.TimePeriod.startDate).ToUniversalTime() + if ($budget.TimePeriod.endDate) { $periodEnd = ([datetime]$budget.TimePeriod.endDate).ToUniversalTime().Date.AddDays(1) } + } + catch { $reason = 'Budget validity period is unavailable.' } + $activeMonths = if (-not $reason) { @($monthDates | Where-Object { $_ -ge $periodStart -and $_.AddMonths(1) -le $periodEnd }) } else { @() } + + if (-not $reason -and $activeMonths.Count -gt 0 -and -not $costCache.ContainsKey($cacheKey)) { + $monthlyCosts = @{} + if ($null -eq $queryFilter -and $CostTrend -and $CostTrend.BySubscription -and $CostTrend.BySubscription[$subId]) { + try { + foreach ($entry in $CostTrend.BySubscription[$subId]) { + if ($entry.MonthDate -isnot [datetime] -or -not $entry.Currency) { throw 'Cached cost date or currency is missing.' } + $key = $entry.MonthDate.ToString('yyyy-MM') + $amount = Get-HubCostValue -Row $entry -Column 'Cost' + if (-not $monthlyCosts.ContainsKey($key)) { $monthlyCosts[$key] = @{ Cost = 0.0; Currency = $entry.Currency } } + if ($monthlyCosts[$key].Currency -ne $entry.Currency) { throw 'Cached monthly costs have mixed currencies.' } + $monthlyCosts[$key].Cost += $amount + } + } + catch { $monthlyCosts.Clear() } + } + $covered = $true + foreach ($month in $monthDates) { if (-not $monthlyCosts.ContainsKey($month.ToString('yyyy-MM'))) { $covered = $false } } + if (-not $covered) { + $dataset = @{ granularity = 'Monthly'; aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } } + if ($null -ne $queryFilter) { $dataset.filter = $queryFilter } + $body = @{ + type = 'ActualCost'; timeframe = 'Custom' + timePeriod = @{ from = $monthDates[0].ToString('yyyy-MM-dd'); to = $monthStart.AddDays(-1).ToString('yyyy-MM-dd') } + dataset = $dataset + } | ConvertTo-Json -Depth 20 + $costPath = "/subscriptions/$subId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $response = Invoke-AzRestMethodWithRetry -Path $costPath -Method POST -Payload $body + $result = Get-CostQueryResult -FirstResponse $response -Payload $body -Context "budget history for $($budget.Subscription)" + $costIndex = Get-CostColumnIndex -Columns $result.properties.columns -Names @('cost', 'totalcost', 'pretaxcost') + $dateIndex = Get-CostColumnIndex -Columns $result.properties.columns -Names @('billingmonth', 'usagedate') + $currencyIndex = Get-CostColumnIndex -Columns $result.properties.columns -Names @('currency', 'billingcurrency') + if ($result.properties.rows.Count -gt 0 -and ($costIndex -lt 0 -or $dateIndex -lt 0 -or $currencyIndex -lt 0)) { + throw 'Budget history is missing required cost columns; results are incomplete.' + } + $monthlyCosts.Clear() + foreach ($month in $monthDates) { $monthlyCosts[$month.ToString('yyyy-MM')] = @{ Cost = 0.0; Currency = $null } } + foreach ($row in $result.properties.rows) { + $rawDate = $row[$dateIndex] + try { + $date = if ($rawDate -is [datetime]) { $rawDate } + elseif ([string]$rawDate -match '^\d{8}$') { [datetime]::ParseExact([string]$rawDate, 'yyyyMMdd', [cultureinfo]::InvariantCulture) } + else { [datetime]::Parse([string]$rawDate, [cultureinfo]::InvariantCulture) } + } + catch { throw 'Budget history contains an invalid date; results are incomplete.' } + $key = $date.ToString('yyyy-MM') + $currency = [string]$row[$currencyIndex] + if (-not $monthlyCosts.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($currency) -or + ($monthlyCosts[$key].Currency -and $monthlyCosts[$key].Currency -ne $currency)) { + throw 'Budget history has an invalid period or mixed currencies; results are incomplete.' + } + $monthlyCosts[$key].Currency = $currency + $monthlyCosts[$key].Cost += [double]$row[$costIndex] + } + } + $costCache[$cacheKey] = $monthlyCosts + } + + foreach ($month in $monthDates) { + $key = $month.ToString('yyyy-MM') + $rowReason = $reason + $actual = $null + $pctUsed = $null + $status = 'Unavailable' + if (-not $rowReason -and ($month -lt $periodStart -or $month.AddMonths(1) -gt $periodEnd)) { + $rowReason = 'The budget was not active for this full month.' + } + if (-not $rowReason) { + $cost = $costCache[$cacheKey][$key] + if ($cost.Currency -and $cost.Currency -ne $budget.Currency) { $rowReason = 'Cost currency does not match the budget currency.' } + else { + $actual = [math]::Round($cost.Cost, 2) + $pctUsed = [math]::Round(100 * $actual / $budgetAmount, 1) + $status = if ($pctUsed -gt 100) { 'Over' } elseif ($pctUsed -gt 90) { 'Near Limit' } else { 'Under' } + } + } + [void]$history.Add([PSCustomObject]@{ + Subscription = $budget.Subscription; BudgetName = $budget.BudgetName; Month = $month.ToString('MMM yyyy'); MonthSort = $key + BudgetAmount = if ($budget.TimeGrain -eq 'Monthly') { $budgetAmount } else { $null } + ActualSpend = $actual; PctUsed = $pctUsed; Status = $status; Currency = $budget.Currency + Note = if ($rowReason) { $rowReason } + elseif ($null -ne $queryFilter) { 'Costs use the current budget filter and amount; prior budget revisions are unavailable.' } + else { 'Compared with the current budget amount; prior budget revisions are unavailable.' } + }) + } + } + + 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..03cabbcbd --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CarbonMetrics.ps1 @@ -0,0 +1,269 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-CARBONMETRICS.PS1 +# AZURE FINOPS MULTITOOL - Carbon Emissions (Sustainability) +########################################################################### +# 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 + $probeErrors = [Collections.Generic.List[string]]::new() + 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 { + $probeErrors.Add($_.Exception.Message) + $probe = $null + } + + if ($probe -and $probe.StatusCode -eq 200) { + # A 200 with an empty value array means the window published no data. + # Accepting it would lock onto an empty month and never try older ones. + $probeRows = 0 + try { + $values = ($probe.Content | ConvertFrom-Json -ErrorAction Stop).value + if ($values -isnot [array]) { throw 'Carbon probe returned an invalid result collection.' } + $probeRows = $values.Count + } + catch { $probeRows = 0; $probeErrors.Add($_.Exception.Message) } + if ($probeRows -gt 0) { + $window = @{ Start = $startMonth; End = $endMonth } + break + } + } + elseif ($probe -and $probe.StatusCode -notin @(400, 404)) { + $probeErrors.Add("Carbon window probe returned HTTP $($probe.StatusCode).") + if ($probe.StatusCode -in @(401, 403)) { 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 = $null + PreviousMonthKg = $null + ChangeRatio = $null + ChangeValueKg = $null + CoverageIncomplete = ($probeErrors.Count -gt 0) + ReadErrors = @($probeErrors) + LatestMonth = $null + MonthlyTrend = @() + BySubscription = @() + Unit = 'kgCO2e' + ScannedSubs = $subCount + Note = if ($probeErrors.Count -gt 0) { 'Carbon discovery is incomplete. ' + ($probeErrors -join ' ') } else { 'No measurements were returned for the queried windows; this is not a measured zero. Carbon data publishes with a delay.' } + } + } + + $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() + $readErrors = [Collections.Generic.List[string]]::new() + foreach ($probeError in $probeErrors) { $readErrors.Add($probeError) } + $headlineComplete = $true + $headlineRows = 0 + + $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 (-not $resp -or $resp.StatusCode -ne 200) { throw "Overall carbon report returned HTTP $($resp.StatusCode)." } + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + if ($data.value -isnot [array] -or $data.value.Count -eq 0) { throw 'Overall carbon report has no measured values.' } + foreach ($row in @($data.value)) { + $latest = Get-HubCostValue -Row $row -Column 'latestMonthEmissions' + $previous = Get-HubCostValue -Row $row -Column 'previousMonthEmissions' + $totalLatest += $latest + $totalPrevious += $previous + $headlineRows++ + } + } + else { throw 'Overall carbon report returned no content.' } + } + catch { + $headlineComplete = $false + $readErrors.Add("Overall batch $batchNo : $($_.Exception.Message)") + 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 (-not $resp -or $resp.StatusCode -ne 200) { throw "Per-subscription carbon report returned HTTP $($resp.StatusCode)." } + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + if ($data.value -isnot [array]) { throw 'Per-subscription carbon report returned an invalid collection.' } + 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 = Get-HubCostValue -Row $row -Column 'latestMonthEmissions' + [void]$bySub.Add([PSCustomObject]@{ + Subscription = if ($subObj) { $subObj.Name } else { $subId } + SubscriptionId = $subId + EmissionsKg = [math]::Round($latest, 3) + }) + } + } + else { throw 'Per-subscription carbon report returned no content.' } + } + catch { + $readErrors.Add("Subscription batch $batchNo : $($_.Exception.Message)") + 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 (-not $resp -or $resp.StatusCode -ne 200) { throw "Monthly carbon report returned HTTP $($resp.StatusCode)." } + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $data = $resp.Content | ConvertFrom-Json + if ($data.value -isnot [array]) { throw 'Monthly carbon report returned an invalid collection.' } + 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 } + $column = if ($null -ne $row.totalCarbonEmission) { 'totalCarbonEmission' } + elseif ($null -ne $row.carbonEmission) { 'carbonEmission' } else { 'latestMonthEmissions' } + $val = Get-HubCostValue -Row $row -Column $column + if (-not $monthlyTotals.ContainsKey($month)) { $monthlyTotals[$month] = 0.0 } + $monthlyTotals[$month] += $val + } + } + else { throw 'Monthly carbon report returned no content.' } + } + catch { + $readErrors.Add("Monthly batch $batchNo : $($_.Exception.Message)") + Write-Warning " Carbon monthly query failed (batch $batchNo): $($_.Exception.Message)" + } + } + + $trend = @( + $monthlyTotals.Keys | Sort-Object | ForEach-Object { + [PSCustomObject]@{ Month = $_; EmissionsKg = [math]::Round($monthlyTotals[$_], 3) } + } + ) + + $headlineAvailable = $headlineComplete -and $headlineRows -gt 0 + $changeValue = if ($headlineAvailable) { $totalLatest - $totalPrevious } else { $null } + $changeRatio = if ($headlineAvailable -and $totalPrevious -gt 0) { [math]::Round(($changeValue / $totalPrevious) * 100, 1) } else { $null } + + $hasData = $headlineAvailable -or ($trend.Count -gt 0) -or ($bySub.Count -gt 0) + + return [PSCustomObject]@{ + HasData = $hasData + TotalEmissionsKg = if ($headlineAvailable) { [math]::Round($totalLatest, 3) } else { $null } + PreviousMonthKg = if ($headlineAvailable) { [math]::Round($totalPrevious, 3) } else { $null } + ChangeValueKg = if ($headlineAvailable) { [math]::Round($changeValue, 3) } else { $null } + ChangeRatio = $changeRatio + CoverageIncomplete = ($readErrors.Count -gt 0) + ReadErrors = @($readErrors) + LatestMonth = $window.End.ToString('yyyy-MM') + MonthlyTrend = $trend + BySubscription = @($bySub | Sort-Object EmissionsKg -Descending) + Unit = 'kgCO2e' + ScannedSubs = $subCount + Note = if ($readErrors.Count -gt 0) { 'Carbon report coverage is incomplete. ' + ($readErrors -join ' ') } + elseif ($headlineAvailable -and $totalPrevious -le 0) { 'A month-over-month percentage requires a positive previous-month measurement.' } + elseif ($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..58f770c47 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CommitmentUtilization.ps1 @@ -0,0 +1,412 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + +########################################################################### +# GET-COMMITMENTUTILIZATION.PS1 +# AZURE FINOPS MULTITOOL - RI & Savings Plan Utilization +########################################################################### +# 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?" +########################################################################### + +# Compares two usageDate values so only the newest period per commitment is +# kept. The API returns an ISO string; an unparsable value falls back to an +# ordinal compare, and a missing existing value always loses. +function Test-UsageDateIsNewer { + param($Candidate, $Existing) + + if ($null -eq $Existing) { return $true } + if ($null -eq $Candidate) { return $false } + + $c = [datetime]::MinValue + $e = [datetime]::MinValue + if ([datetime]::TryParse([string]$Candidate, [ref]$c) -and [datetime]::TryParse([string]$Existing, [ref]$e)) { + return ($c -gt $e) + } + return ([string]$Candidate -gt [string]$Existing) +} + +function Get-CommitmentUtilization { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions, + + [Parameter()] + [string]$AgreementType + ) + + Write-Host " Querying commitment utilization..." -ForegroundColor Cyan + + $reservations = @() + $utilFailures = [System.Collections.Generic.List[string]]::new() + $metadataErrors = [System.Collections.Generic.List[string]]::new() + $savingsPlans = @() + + function ConvertTo-CommitmentPercentage { + param($Value) + $percentage = 0.0 + if ($null -eq $Value -or -not [double]::TryParse([Convert]::ToString($Value, [cultureinfo]::InvariantCulture), [Globalization.NumberStyles]::Float, [cultureinfo]::InvariantCulture, [ref]$percentage) -or + [double]::IsNaN($percentage) -or [double]::IsInfinity($percentage) -or $percentage -lt 0 -or $percentage -gt 100) { return $null } + [math]::Round($percentage, 1) + } + + # Set to $true if a reservation/savings-plan query is forbidden (401/403) + # rather than simply returning no commitments. + $accessDenied = $false + + # Why billing-scope correlation produced nothing, when it produced nothing. + $scopeResolutionReason = $null + $scopeCoverageIncomplete = $false + $scopeResolutionErrors = @() + + # -- Step 0: Resolve the billing scopes that own the scanned subscriptions -- + # Both commitment APIs are billing-scoped. A subscription-scoped path answers + # 404 "Unknown. Please check the request path", which is not an access denial, + # so the previous per-subscription queries could only ever report zero. + # EA reads at billing account scope, MCA/MPA at billing profile scope. + $commitmentScopes = @() + try { + $baPath = "/providers/Microsoft.Billing/billingAccounts?api-version=2024-04-01" + $baResp = Invoke-AzRestMethodWithRetry -Path $baPath -Method GET + if ($baResp.StatusCode -eq 200) { + $allAccounts = @(foreach ($page in (Get-CostQueryResponsePage -FirstResponse $baResp -Context 'commitment billing accounts' -RootNextLink)) { ($page.Content | ConvertFrom-Json).value }) + $scope = Get-FinOpsBillingScope -BillingAccounts $allAccounts -Subscriptions $Subscriptions + $scopeCoverageIncomplete = [bool]$scope.CoverageIncomplete + $scopeResolutionErrors = @($scope.ReadErrors | Where-Object { $_ }) + if (-not $scope.Resolved -or $scopeCoverageIncomplete) { + $scopeResolutionReason = (@($scope.Reason) + $scopeResolutionErrors | Where-Object { $_ } | Select-Object -Unique) -join ' ' + Write-Warning " $scopeResolutionReason" + } + + foreach ($ba in @($scope.Accounts)) { + # Fall back to the caller's agreement type when the account + # listing does not carry one. + $agreement = if ($ba.properties.agreementType) { $ba.properties.agreementType } else { $AgreementType } + if ($agreement -in @('MicrosoftCustomerAgreement', 'MicrosoftPartnerAgreement')) { + try { + $bpResp = Invoke-AzRestMethodWithRetry -Path "$($ba.id)/billingProfiles?api-version=2024-04-01" -Method GET + if ($bpResp.StatusCode -eq 200) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $bpResp -Context 'commitment billing profiles' -RootNextLink)) { + foreach ($bp in @(($page.Content | ConvertFrom-Json).value)) { $commitmentScopes += $bp.id } + } + } + elseif ($bpResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + else { $utilFailures.Add("Billing profiles for $($ba.name): HTTP $($bpResp.StatusCode)") } + } + catch { + $utilFailures.Add("Billing profiles for $($ba.name): $($_.Exception.Message)") + Write-Warning " Billing profile lookup failed for $($ba.name): $($_.Exception.Message)" + } + } + else { + $commitmentScopes += $ba.id + } + } + } + elseif ($baResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + else { $utilFailures.Add("Billing accounts: HTTP $($baResp.StatusCode)") } + } + catch { + $utilFailures.Add("Billing scope resolution: $($_.Exception.Message)") + Write-Warning " Billing scope resolution failed: $($_.Exception.Message)" + } + Write-Host " Found $($commitmentScopes.Count) billing scope(s) for commitment queries." -ForegroundColor Cyan + + # -- Step 1: Get all reservations and their utilization -------------- + # One row per reservation, not per usage period. The API returns a record + # per month, so counting records would inflate RICount and weight the + # average towards whichever reservation happens to span more months. + $latestReservation = @{} + $usageFrom = ((Get-Date).AddDays(-30)).ToString('yyyy-MM-dd') + $usageTo = (Get-Date).ToString('yyyy-MM-dd') + $scopeTotal = @($commitmentScopes).Count + $scopeIdx = 0 + foreach ($scopeId in $commitmentScopes) { + $scopeIdx++ + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying reservations ($scopeIdx/$scopeTotal scopes)..." + } + try { + # UsageDate needs both bounds and must not be quoted: it is an + # Edm.DateTimeOffset, so a quoted value fails the type comparison. + $summaryPath = "$scopeId/providers/Microsoft.Consumption/reservationSummaries?grain=monthly&api-version=2023-05-01&`$filter=properties/UsageDate ge $usageFrom and properties/UsageDate le $usageTo" + $resp = Invoke-AzRestMethodWithRetry -Path $summaryPath -Method GET + + if ($resp.StatusCode -eq 200) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Context 'reservation utilization' -RootNextLink)) { + foreach ($item in @(($page.Content | ConvertFrom-Json).value)) { + $p = $item.properties + $key = "$($p.reservationOrderId)/$($p.reservationId)" + if ([string]::IsNullOrWhiteSpace(($key -replace '/', ''))) { continue } + $existing = $latestReservation[$key] + if ($existing -and -not (Test-UsageDateIsNewer -Candidate $p.usageDate -Existing $existing.UsageDate)) { continue } + $latestReservation[$key] = [PSCustomObject]@{ + ReservationOrderId = $p.reservationOrderId + ReservationId = $p.reservationId + SkuName = $p.skuName + Kind = $p.kind + AvgUtilization = ConvertTo-CommitmentPercentage $p.avgUtilizationPercentage + MinUtilization = ConvertTo-CommitmentPercentage $p.minUtilizationPercentage + MaxUtilization = ConvertTo-CommitmentPercentage $p.maxUtilizationPercentage + ReservedHours = $p.reservedHours + UsedHours = $p.usedHours + UsageDate = $p.usageDate + } + } + } + } + elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } + else { + # A non-200 that is not a denial still means this scope produced + # nothing, which must not be presented as "no reservations". + [void]$utilFailures.Add("$scopeId : HTTP $($resp.StatusCode)") + } + } + catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + throw "Reservation summaries query failed for $scopeId : $($_.Exception.Message)" + } + } + $reservations += @($latestReservation.Values) + + # -- Step 2: Try the Reservation Orders API at billing scope -- + # This endpoint is tenant-wide and cannot be filtered to the requested + # subscriptions, so anything it returns is flagged as unscoped rather than + # presented as belonging to the scan scope. + $unscopedFallback = $false + if ($reservations.Count -eq 0) { + try { + $roPath = "/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $roPath -Method GET + if ($resp.StatusCode -eq 200) { + $orders = @(foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Context 'reservation orders' -RootNextLink)) { ($page.Content | ConvertFrom-Json).value }) + $seenReservations = @{} + if ($orders) { + foreach ($order in $orders) { + $op = $order.properties + if ($op.reservations) { + foreach ($ri in $op.reservations) { + if ($ri.id -notmatch '^/providers/Microsoft\.Capacity/reservationOrders/[a-zA-Z0-9-]+/reservations/[a-zA-Z0-9-]+$') { + $utilFailures.Add('A reservation order returned an invalid reservation resource ID.') + continue + } + if ($seenReservations.ContainsKey($ri.id)) { continue } + $seenReservations[$ri.id] = $true + # 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 $usageFrom and properties/UsageDate le $usageTo" + $utilResp = Invoke-AzRestMethodWithRetry -Path $utilPath -Method GET + if ($utilResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + $usageRows = @(foreach ($page in (Get-CostQueryResponsePage -FirstResponse $utilResp -Context 'reservation fallback utilization' -RootNextLink)) { ($page.Content | ConvertFrom-Json).value }) + if ($utilResp.StatusCode -eq 200) { + if ($usageRows.Count -gt 0) { + $latest = $null + foreach ($usageRow in $usageRows) { + if (-not $latest -or (Test-UsageDateIsNewer -Candidate $usageRow.properties.usageDate -Existing $latest.properties.usageDate)) { $latest = $usageRow } + } + $up = $latest.properties + $reservations += [PSCustomObject]@{ + ReservationOrderId = $order.name + ReservationId = $ri.id.Split('/')[-1] + SkuName = $up.skuName + Kind = $up.kind + AvgUtilization = ConvertTo-CommitmentPercentage $up.avgUtilizationPercentage + MinUtilization = ConvertTo-CommitmentPercentage $up.minUtilizationPercentage + MaxUtilization = ConvertTo-CommitmentPercentage $up.maxUtilizationPercentage + ReservedHours = $up.reservedHours + UsedHours = $up.usedHours + UsageDate = $up.usageDate + } + } + } + } + catch { + # Dropping this reservation silently would understate + # the count and read as better coverage than reality. + [void]$utilFailures.Add("$($ri.id.Split('/')[-1]): $($_.Exception.Message)") + } + } + } + } + } + } + elseif ($resp.StatusCode -in @(401, 403)) { $accessDenied = $true } + else { $utilFailures.Add("Reservation orders: HTTP $($resp.StatusCode)") } + } + catch { + $utilFailures.Add("Reservation orders: $($_.Exception.Message)") + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Warning " Reservation orders query failed: $($_.Exception.Message)" + } + # This block only runs when the scoped queries returned nothing, so + # anything present now came from the tenant-wide endpoint. + if ($reservations.Count -gt 0) { + $unscopedFallback = $true + Write-Warning " Commitments were read from every reservation order this account can see; they are not limited to the scanned subscriptions." + } + } + + # -- Step 3: Savings Plans utilization via Benefit Utilization Summaries -- + # Keyed on benefitId, not benefitOrderId: one order can hold several savings + # plans, so ordering alone would collapse distinct plans into one and drop + # real commitments. Only the newest period per plan is kept, so SPCount + # counts plans rather than monthly records. + $latestSavingsPlan = @{} + $spIdx = 0 + foreach ($scopeId in $commitmentScopes) { + $spIdx++ + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying savings plans ($spIdx/$scopeTotal scopes)..." + } + try { + $spPath = "$scopeId/providers/Microsoft.CostManagement/benefitUtilizationSummaries?api-version=2023-11-01&grainParameter=Monthly" + $spResp = Invoke-AzRestMethodWithRetry -Path $spPath -Method GET + + if ($spResp.StatusCode -eq 200) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $spResp -Context 'savings plan utilization' -RootNextLink)) { + foreach ($item in @(($page.Content | ConvertFrom-Json).value)) { + $p = $item.properties + if ($p.benefitType -ne 'SavingsPlan') { continue } + $key = if ($p.benefitId) { [string]$p.benefitId } else { [string]$p.benefitOrderId } + if ([string]::IsNullOrWhiteSpace($key)) { continue } + $existing = $latestSavingsPlan[$key] + if ($existing -and -not (Test-UsageDateIsNewer -Candidate $p.usageDate -Existing $existing.UsageDate)) { continue } + $latestSavingsPlan[$key] = [PSCustomObject]@{ + BenefitId = $p.benefitId + BenefitOrderId = $p.benefitOrderId + BenefitType = $p.benefitType + AvgUtilization = ConvertTo-CommitmentPercentage $p.avgUtilizationPercentage + UsageDate = $p.usageDate + } + } + } + } + elseif ($spResp.StatusCode -in @(401, 403)) { $accessDenied = $true } + else { + [void]$utilFailures.Add("$scopeId : HTTP $($spResp.StatusCode)") + } + } + catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + throw "Savings plan utilization query failed for $scopeId : $($_.Exception.Message)" + } + } + $savingsPlans += @($latestSavingsPlan.Values) + + $metadataById = @{} + foreach ($reservation in $reservations) { + $orderId = ([string]$reservation.ReservationOrderId).TrimEnd('/').Split('/')[-1] + $reservationId = ([string]$reservation.ReservationId).TrimEnd('/').Split('/')[-1] + $resourceId = "/providers/Microsoft.Capacity/reservationOrders/$orderId/reservations/$reservationId" + $reservation | Add-Member -NotePropertyName ResourceId -NotePropertyValue $resourceId + $reservation | Add-Member -NotePropertyName Name -NotePropertyValue $reservationId + if ($orderId -notmatch '^[a-zA-Z0-9-]+$' -or $reservationId -notmatch '^[a-zA-Z0-9-]+$') { + $metadataErrors.Add('Reservation metadata was not requested because an identifier was invalid.') + continue + } + if (-not $reservation.SkuName -or -not $reservation.Kind) { + if (-not $metadataById.ContainsKey($resourceId)) { + $metadataById[$resourceId] = $null + try { + $metadataResponse = Invoke-AzRestMethodWithRetry -Path "$resourceId`?api-version=2022-11-01" -Method GET + if ($metadataResponse.StatusCode -ne 200) { throw "HTTP $($metadataResponse.StatusCode)" } + $metadata = $metadataResponse.Content | ConvertFrom-Json -ErrorAction Stop + if ($metadata.id -ine $resourceId -or -not $metadata.properties) { throw 'Reservation metadata did not match the requested resource.' } + $metadataById[$resourceId] = $metadata + } + catch { $metadataErrors.Add("$resourceId : $($_.Exception.Message)") } + } + $metadata = $metadataById[$resourceId] + if ($metadata) { + if ($metadata.properties.displayName) { $reservation.Name = [string]$metadata.properties.displayName } + if (-not $reservation.SkuName -and $metadata.sku.name) { $reservation.SkuName = [string]$metadata.sku.name } + if (-not $reservation.Kind -and $metadata.properties.reservedResourceType) { $reservation.Kind = [string]$metadata.properties.reservedResourceType } + } + } + } + foreach ($commitment in @($reservations) + @($savingsPlans)) { + if ($null -eq $commitment.AvgUtilization) { + $identity = if ($commitment.ReservationId) { $commitment.ReservationId } else { $commitment.BenefitId } + $utilFailures.Add("$identity : Average utilization is missing or invalid.") + } + } + $coverageIncomplete = $scopeCoverageIncomplete -or $accessDenied -or $utilFailures.Count -gt 0 -or @($commitmentScopes).Count -eq 0 -or $unscopedFallback + + # -- Step 4: Calculate summary stats -- + $riAvgUtil = $null + $riCount = $reservations.Count + if ($riCount -gt 0 -and -not $coverageIncomplete) { + $riAvgUtil = [math]::Round(($reservations | Measure-Object -Property AvgUtilization -Average).Average, 1) + } + + $spAvgUtil = $null + $spCount = $savingsPlans.Count + if ($spCount -gt 0 -and -not $coverageIncomplete) { + $spAvgUtil = [math]::Round(($savingsPlans | Measure-Object -Property AvgUtilization -Average).Average, 1) + } + + $underutilized = @($reservations | Where-Object { $null -ne $_.AvgUtilization -and $_.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 (@($commitmentScopes).Count -eq 0) { + # Distinct from an access denial: the account list was readable, it just + # does not contain an account that owns a scanned subscription. + $detail = if ($scopeResolutionReason) { " $scopeResolutionReason" } else { '' } + "Reservations and savings plans are billing-scoped, and no billing scope was resolved for the scanned subscriptions, so utilization could not be read.$detail" + } + elseif ($denied) { + 'Access denied reading reservation/savings-plan utilization (needs Cost Management Reader / billing-scope access). The zero counts below reflect missing access, NOT confirmed absence of commitments.' + } + elseif ($coverageIncomplete) { + "Commitment coverage is incomplete. Missing results do not establish the absence of reservations or savings plans. $scopeResolutionReason".TrimEnd() + } + elseif ($riCount -eq 0 -and $spCount -eq 0) { + 'No reservations or savings plans found in scope.' + } + else { + $riLabel = if ($null -ne $riAvgUtil) { "$riAvgUtil%" } else { 'unavailable' } + $spLabel = if ($null -ne $spAvgUtil) { "$spAvgUtil%" } else { 'unavailable' } + "$riCount reservation(s), average utilization $riLabel; $spCount savings plan(s), average utilization $spLabel. Averages are unweighted and use the latest returned period per commitment." + } + if ($coverageIncomplete) { $note = "$note Coverage is incomplete; overall utilization averages are unavailable." } + + if ($utilFailures.Count -gt 0) { + Write-Warning " Utilization unavailable for $($utilFailures.Count) reservation(s); counts below exclude them." + foreach ($f in ($utilFailures | Select-Object -First 3)) { Write-Verbose " $f" } + } + + return [PSCustomObject]@{ + Reservations = $reservations + SavingsPlans = $savingsPlans + RICount = $riCount + SPCount = $spCount + RIAvgUtilization = $riAvgUtil + SPAvgUtilization = $spAvgUtil + CoverageIncomplete = $coverageIncomplete + ScopeResolutionErrors = $scopeResolutionErrors + MetadataErrors = $metadataErrors.ToArray() + AverageBasis = 'Unweighted mean of the latest returned period per commitment' + UnderutilizedRIs = $underutilized + HasData = ($riCount -gt 0 -or $spCount -gt 0) + AccessDenied = $denied + ScopesQueried = @($commitmentScopes).Count + # True when the figures came from the tenant-wide reservation order + # endpoint, which cannot be filtered to the scanned subscriptions. + UnscopedFallback = $unscopedFallback + Note = if ($unscopedFallback) { + 'No commitments were readable at the resolved billing scopes, so these figures come from every reservation order this account can see and are not limited to the scanned subscriptions.' + } + else { $note } + # Reservations excluded because their utilization could not be read. + UtilizationFailures = $utilFailures.Count + UtilizationFailureDetail = @($utilFailures) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 new file mode 100644 index 000000000..d21fdca86 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ContractInfo.ps1 @@ -0,0 +1,181 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +param() + +########################################################################### +# GET-CONTRACTINFO.PS1 +# AZURE FINOPS MULTITOOL - Billing Account & Contract Type Detection +########################################################################### +# 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 + $probeErrors = [Collections.Generic.List[string]]::new() + + # -- 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 Stop | Select-Object -First 3) } catch { + $probeErrors.Add("Subscription discovery: $($_.Exception.Message)") + Write-Warning "Contract subscription discovery failed: $($_.Exception.Message)" + } + } + + foreach ($sub in $subsToCheck) { + try { + $subPath = "/subscriptions/$($sub.Id)?api-version=2022-12-01" + $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method GET + if (-not $subResp -or $subResp.StatusCode -ne 200) { throw "Subscription contract probe returned HTTP $($subResp.StatusCode)." } + if ($subResp.StatusCode -eq 200) { + $subDetail = ($subResp.Content | ConvertFrom-Json) + $quotaId = $subDetail.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 { + $probeErrors.Add("$($sub.Name): $($_.Exception.Message)") + Write-Warning "Contract probe failed for $($sub.Name): $($_.Exception.Message)" + } + } + + # -- 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 + $result = Get-FinOpsListResult -FirstResponse $response -Context 'contract billing accounts' + + if ($result.value -and $result.value.Count -gt 0) { + $matchedAccount = $null + + # Billing accounts are visible across every tenant the signed-in + # identity can reach, and even a single visible account can belong to + # another tenant. Picking by agreement type alone can surface an + # unrelated account, so confirm the account owns one of the scanned + # subscriptions before trusting it. Prefer 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 + $bsData = Get-FinOpsListResult -FirstResponse $bsResp -Context "billing membership for $($cand.name)" + if ($bsResp -and $bsResp.StatusCode -eq 200 -and $bsResp.Content) { + $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 { + $probeErrors.Add("$($cand.name): $($_.Exception.Message)") + Write-Warning "Contract billing membership probe failed for $($cand.name): $($_.Exception.Message)" + } + } + # 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 + CoverageIncomplete = ($probeErrors.Count -gt 0) + ReadErrors = @($probeErrors) + Note = if ($probeErrors.Count -gt 0) { 'Some contract probes failed. ' + ($probeErrors -join ' ') } else { $null } + # soldTo is a billing mailing address, so this is a country, not a currency. + SoldToCountry = if ($props.soldTo) { $props.soldTo.country } else { 'Unknown' } + }) + } + } + catch { + $probeErrors.Add($_.Exception.Message) + 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' + CoverageIncomplete = ($probeErrors.Count -gt 0) + ReadErrors = @($probeErrors) + Note = ('Agreement inferred from subscription metadata, not confirmed billing-account details. ' + ($probeErrors -join ' ')).TrimEnd() + }) + } + + return @([PSCustomObject]@{ + AccountName = 'Unknown' + AgreementType = 'Unknown' + FriendlyType = 'Could not confirm the agreement type' + CoverageIncomplete = ($probeErrors.Count -gt 0) + ReadErrors = @($probeErrors) + Note = if ($probeErrors.Count -gt 0) { 'Contract discovery is incomplete. ' + ($probeErrors -join ' ') } else { 'No agreement was identified from the readable metadata.' } + }) +} 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..0d7c7a999 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostByTag.ps1 @@ -0,0 +1,572 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; the dispatcher passes -TenantId to every scan module.')] +param() + +########################################################################### +# GET-COSTBYTAG.PS1 +# AZURE FINOPS MULTITOOL - Cost Breakdown by Tag +########################################################################### +# 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" + $resourceTags = Search-AzGraphSafe -Query $resTagQuery -Subscription $subIds -First 1000 -All + if ($null -eq $resourceTags) { throw 'Resource tags could not be read.' } + foreach ($row in $resourceTags.Data) { + if ($row.id) { $resTagMap[([string]$row.id).ToLower()] = ConvertTo-TagHash $row.tags } + } + + $rgTagQuery = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions/resourcegroups' | where isnotempty(tags) | project id, tags" + $groupTags = Search-AzGraphSafe -Query $rgTagQuery -Subscription $subIds -First 1000 -All + if ($null -eq $groupTags) { throw 'Resource group tags could not be read.' } + foreach ($row in $groupTags.Data) { + if ($row.id) { $rgTagMap[([string]$row.id).ToLower()] = ConvertTo-TagHash $row.tags } + } + + Write-Host " Mapped tags for $($resTagMap.Count) resources, $($rgTagMap.Count) resource groups" -ForegroundColor Gray + } + catch { + throw "Resource tag map is incomplete: $($_.Exception.Message)" + } + + # 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 ConvertFrom-ResourceIdRow { + 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 -lt 0 -or $ridIdx -lt 0 -or $currIdx -lt 0) { throw 'Cost-by-tag requires explicit cost, resource ID, and currency columns.' } + + 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 = ([string]$row[$currIdx]).Trim().ToUpperInvariant() + if ($currency -notmatch '^[A-Z]{3}$' -or $currency -in @('XXX', 'XTS')) { throw 'Cost-by-tag billing currency is missing or invalid.' } + [void]$parsed.Add([PSCustomObject]@{ ResourceId = $rid; Cost = $cost; Currency = $currency }) + } + return $parsed + } + + # Helper: parse Cost Management query response using column headers + function ConvertFrom-TagCostRow { + 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 ConvertFrom-BatchedCostRow { + 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 { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + $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. All selected subscriptions must be readable. + $usedTimeframe = 'MonthToDate' + $timeframes = @('MonthToDate', 'Custom') + + # Per-tag aggregation: tagName -> (tagValue -> accumulated cost). Tag values + # are case-sensitive in Azure, so 'Prod' and 'prod' stay separate. + $tagAgg = @{} + foreach ($t in $tagsToQuery) { $tagAgg[$t] = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::Ordinal) } + $currencySeen = $null + $subsQueried = 0 + $subsFailed = 0 + $grandTotal = 0.0 + $returnedRows = 0 + $successfulSubs = [Collections.Generic.List[string]]::new() + $failedSubscriptions = [Collections.Generic.List[object]]::new() + + # True allocation coverage, counted once per resource. The per-tag totals + # cannot answer this: a resource appears as untagged under every tag it + # lacks, so summing across tags double-counts the same spend. + $allocTagNames = if (Get-Command Get-CafAllocationTag -ErrorAction SilentlyContinue) { Get-CafAllocationTag } else { @('CostCenter', 'Customer', 'Project', 'Environment', 'Application', 'ApplicationName', 'Owner', 'BusinessUnit', 'Department', 'Team', 'OpsTeam', 'Service', 'WorkloadName') } + $allocatedCost = 0.0 # resource carries at least one allocation tag + $unallocatedCost = 0.0 # resource carries none + $resourceCostSeen = 0.0 # allocated + unallocated, excludes non-resource charges + + 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 ($returnedRows -gt 0 -or $subsFailed -gt 0)) { break } + if ($tf -eq 'Custom') { + Write-Host ' MonthToDate returned no cost rows - querying last month...' -ForegroundColor Yellow + foreach ($t in $tagsToQuery) { $tagAgg[$t] = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::Ordinal) } + $subsQueried = 0; $subsFailed = 0; $grandTotal = 0.0 + $allocatedCost = 0.0; $unallocatedCost = 0.0; $resourceCostSeen = 0.0 + $currencySeen = $null; $returnedRows = 0 + $successfulSubs.Clear() + $failedSubscriptions.Clear() + } + + $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 + try { + if (-not $subResp -or $subResp.StatusCode -ne 200) { + if ($subResp -and $subResp.StatusCode -eq 403) { $script:costAccessIssue = 'MCA' } + if ($subResp -and $subResp.StatusCode -eq 400) { + $errorMessage = try { ($subResp.Content | ConvertFrom-Json).error.message } catch { '' } + if ($errorMessage -match 'AO View Charges') { $script:costAccessIssue = 'EA' } + } + throw "Cost query returned HTTP $($subResp.StatusCode)." + } + # Follow nextLink: one page only would understate a large subscription. + $rows = @() + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $subResp -Payload $body -Context "cost-by-tag for $($pj.Call.SubName)")) { + $rows += ConvertFrom-ResourceIdRow -ResponseContent $page.Content + } + } + catch { + $subsFailed++ + $failedSubscriptions.Add([pscustomobject]@{ + SubscriptionId = $pj.Call.SubId + Subscription = $pj.Call.SubName + StatusCode = if ($subResp -and $subResp.StatusCode -ne 200) { $subResp.StatusCode } else { $null } + Error = $_.Exception.Message + }) + continue + } + $subsQueried++ + $successfulSubs.Add([string]$pj.Call.SubId) + $returnedRows += $rows.Count + foreach ($row in $rows) { + if ($currencySeen -and $currencySeen -ne $row.Currency) { throw 'Cost-by-tag cannot combine different billing currencies; results are incomplete.' } + $currencySeen = $row.Currency + } + if ($rows.Count -gt 0) { + foreach ($row in $rows) { + $cost = $row.Cost + $grandTotal += $cost + + if ([string]::IsNullOrWhiteSpace($row.ResourceId)) { + foreach ($t in $tagsToQuery) { + if (-not $tagAgg[$t].ContainsKey('(non-resource charges)')) { $tagAgg[$t]['(non-resource charges)'] = 0.0 } + $tagAgg[$t]['(non-resource charges)'] += $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)' } + if (-not $tagAgg[$t].ContainsKey($val)) { $tagAgg[$t][$val] = 0.0 } + $tagAgg[$t][$val] += $cost + } + + # Count this resource once toward allocation coverage. + $hasAlloc = $false + if ($tags -and $tags.Count -gt 0) { + foreach ($k in $tags.Keys) { + if ($allocTagNames -contains $k -and -not [string]::IsNullOrWhiteSpace($tags[$k])) { $hasAlloc = $true; break } + } + } + $resourceCostSeen += $cost + if ($hasAlloc) { $allocatedCost += $cost } else { $unallocatedCost += $cost } + } + } + } + } + + $usedTimeframe = $tf + } + + if ($subsFailed -gt 0) { + if ($subsQueried -eq 0) { throw "Cost-by-tag failed for every selected subscription: $(($failedSubscriptions | ForEach-Object { $_.Error }) -join '; '); results are incomplete." } + 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)" 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 + Currency = $currencySeen + CoverageIncomplete = ($subsFailed -gt 0) + ScannedSubs = $subsQueried + TotalSubs = $subIds.Count + SuccessfulSubscriptionIds = @($successfulSubs) + FailedSubscriptions = @($failedSubscriptions) + Note = if ($subsFailed -gt 0) { "Cost coverage is incomplete: $subsQueried of $($subIds.Count) subscriptions were read. Amounts cover successful subscriptions only; whole-scope allocation KPIs are unavailable." } else { $null } + # Allocation coverage counted once per resource. Null when this run did + # not walk resources (hub paths aggregate server-side), so consumers can + # tell "no allocated spend" apart from "not measured". + AllocatedCost = if ($resourceCostSeen -gt 0) { [math]::Round($allocatedCost, 2) } else { $null } + UnallocatedCost = if ($resourceCostSeen -gt 0) { [math]::Round($unallocatedCost, 2) } else { $null } + ResourceCostSeen = if ($resourceCostSeen -gt 0) { [math]::Round($resourceCostSeen, 2) } else { $null } + AllocationTags = $allocTagNames + } +} 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..fadfd7e21 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostData.ps1 @@ -0,0 +1,471 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +param() + +########################################################################### +# GET-COSTDATA.PS1 +# AZURE FINOPS MULTITOOL - Current & Forecasted Cost Data +########################################################################### +# 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 +########################################################################### + +# Matches column names exactly. A substring match lands on 'CostStatus', whose +# value is the text 'Actual' or 'Forecast', and casting that to a number throws. +function Get-CostColumnIndex { + param($Columns, [string[]]$Names) + + if (-not $Columns) { return -1 } + for ($i = 0; $i -lt $Columns.Count; $i++) { + if (([string]$Columns[$i].name).ToLower() -in $Names) { return $i } + } + return -1 +} + +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." + } + + # The query API returns one page at a time. A truncated read looks like + # lower cost rather than an error, so follow nextLink before summing. + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $response -Payload $actualBody -Context 'actual cost')) { + $result = ($page.Content | ConvertFrom-Json) + + # Resolve column indices by name. The MG-scope response is not contractually + # ordered, and a reorder would silently attribute cost to the wrong sub. + $aCols = $result.properties.columns + $aSubIdx = Get-CostColumnIndex -Columns $aCols -Names @('subscriptionid') + $aCurIdx = Get-CostColumnIndex -Columns $aCols -Names @('currency') + $aCostIdx = Get-CostColumnIndex -Columns $aCols -Names @('cost', 'pretaxcost', 'costusd') + + # Guessing at positions here would attribute real money to the wrong + # subscription, so fail into the per-subscription path instead. + if ($aCostIdx -lt 0 -or $aSubIdx -lt 0 -or $aCurIdx -lt 0) { + throw "Actual cost response did not expose the expected Cost, SubscriptionId, and Currency columns." + } + + if ($result.properties.rows) { + foreach ($row in $result.properties.rows) { + $subId = [string]$row[$aSubIdx] + $amount = [double]$row[$aCostIdx] + $currency = ([string]$row[$aCurIdx]).Trim().ToUpperInvariant() + + if ($selectedSubs -and -not $selectedSubs.Contains($subId)) { continue } + if (-not $currency) { throw "Actual cost currency is unavailable for $subId." } + + if (-not $costMap.ContainsKey($subId)) { + $costMap[$subId] = @{ Actual = 0; Forecast = $null; Currency = $currency; ForecastSource = 'Unavailable'; ActualPeriod = 'Month to date (UTC query window)' } + } + if ($costMap[$subId].Currency -ne $currency) { throw "Actual cost contains mixed currency values for $subId." } + $costMap[$subId].Actual += $amount + $costMap[$subId].Currency = $currency + } + } + } + + # Round once after every page is in, not per page. + foreach ($subId in @($costMap.Keys)) { + $costMap[$subId].Actual = [math]::Round($costMap[$subId].Actual, 2) + } + } + catch { + Write-Warning "Actual cost query failed: $($_.Exception.Message)" + if (-not $Subscriptions) { throw } + Write-Warning "Falling back to per-subscription queries." + $costMap = Get-CostDataPerSubscription -Subscriptions $Subscriptions + return $costMap + } + + # -- 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).ToUniversalTime() + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + + $forecastBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.AddDays(1 - $now.Day).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)" + } + + $forecastSums = @{} + $forecastCurrencies = @{} + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $fResponse -Payload $forecastBody -Context 'forecast')) { + $fResult = $page.Content | ConvertFrom-Json + if ($fResult.properties.rows.Count -eq 0) { continue } + $fCols = $fResult.properties.columns + $fSubIdx = Get-CostColumnIndex -Columns $fCols -Names @('subscriptionid') + $fCostIdx = Get-CostColumnIndex -Columns $fCols -Names @('cost', 'pretaxcost', 'costusd') + $fCurIdx = Get-CostColumnIndex -Columns $fCols -Names @('currency') + if ($fCostIdx -lt 0 -or $fSubIdx -lt 0 -or $fCurIdx -lt 0) { + throw "Forecast response did not expose the expected Cost, SubscriptionId, and Currency columns." + } + + foreach ($row in @($fResult.properties.rows)) { + $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] + $currency = ([string]$row[$fCurIdx]).Trim().ToUpperInvariant() + if (-not $currency) { throw "Forecast currency is unavailable for $subId." } + if ($forecastCurrencies.ContainsKey($subId) -and $forecastCurrencies[$subId] -ne $currency) { throw "Forecast contains mixed currency values for $subId." } + $forecastCurrencies[$subId] = $currency + if (-not $forecastSums.ContainsKey($subId)) { $forecastSums[$subId] = 0 } + $forecastSums[$subId] += $amount + } + } + if ($forecastSums.Count -gt 0) { + foreach ($subId in $forecastSums.Keys) { + if (-not $costMap.ContainsKey($subId)) { + $costMap[$subId] = @{ Actual = $null; Forecast = $null; Currency = $forecastCurrencies[$subId]; ActualPeriod = 'Month to date (UTC query window)' } + } + if ($costMap[$subId].Currency -ne $forecastCurrencies[$subId]) { throw "Actual cost and forecast currency differ for $subId." } + $costMap[$subId].Forecast = [math]::Round($forecastSums[$subId], 2) + $costMap[$subId].ForecastSource = 'Forecast' + } + $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)" + if (-not $Subscriptions) { throw } + Write-Host " Falling back to per-subscription forecast queries..." -ForegroundColor Yellow + } + + # Per-subscription forecast fallback + if (-not $forecastSuccess -and $Subscriptions) { + $now = (Get-Date).ToUniversalTime() + $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.AddDays(1 - $now.Day).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 (-not $fResp -or $fResp.StatusCode -ne 200) { + throw "Forecast retry returned HTTP $($fResp.StatusCode); results are incomplete." + } + if ($fResp.StatusCode -eq 200) { + $total = 0.0 + $rowCount = 0 + $forecastCurrency = $null + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $fResp -Payload $fBody -Context "forecast for $($sub.Id)")) { + $fRes = $page.Content | ConvertFrom-Json + if ($fRes.properties.rows.Count -eq 0) { continue } + $costIndex = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('cost', 'pretaxcost', 'costusd') + $currencyIndex = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('currency') + if ($costIndex -lt 0 -or $currencyIndex -lt 0) { throw 'Forecast response did not expose the expected Cost and Currency columns.' } + foreach ($row in $fRes.properties.rows) { + $rowCurrency = ([string]$row[$currencyIndex]).Trim().ToUpperInvariant() + if (-not $rowCurrency -or ($forecastCurrency -and $forecastCurrency -ne $rowCurrency)) { throw 'Forecast currency is unavailable or mixed.' } + $forecastCurrency = $rowCurrency + $total += [double]$row[$costIndex] + $rowCount++ + } + } + if ($rowCount -gt 0) { + if (-not $costMap.ContainsKey($sub.Id)) { + $costMap[$sub.Id] = @{ Actual = $null; Forecast = $null; Currency = $forecastCurrency; ActualPeriod = 'Month to date (UTC query window)' } + } + if ($costMap[$sub.Id].Currency -ne $forecastCurrency) { throw 'Actual cost and forecast currency differ.' } + $costMap[$sub.Id].Forecast = [math]::Round($total, 2) + $costMap[$sub.Id].ForecastSource = 'Forecast' + $hitCount++ + } + else { + throw 'Forecast retry returned no rows; results are incomplete.' + } + } + } + catch { + throw "Forecast query failed for $($sub.Name): $($_.Exception.Message)" + } + } + Write-Host " Per-sub forecast: got data for $hitCount of $subCount subscriptions" -ForegroundColor $(if ($hitCount -gt 0) { 'Green' } else { 'Yellow' }) + } + + # Subs without forecast data fall back to actual, which understates a + # full-month projection. Flag it so callers can label the number rather + # than present month-to-date spend as a forecast. + foreach ($subId in @($costMap.Keys)) { + if ($costMap[$subId].ForecastSource -ne 'Forecast') { + $costMap[$subId].Forecast = $costMap[$subId].Actual + $costMap[$subId].ForecastSource = 'Actual' + } + } + + foreach ($sub in $Subscriptions) { + if (-not $costMap.ContainsKey($sub.Id)) { + $costMap[$sub.Id] = @{ Actual = $null; Forecast = $null; Currency = $null; ForecastSource = 'Unavailable'; ActualPeriod = 'Month to date (UTC query window)' } + } + } + + return $costMap +} + +# -- 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 = $null; $currency = $null + if ($resp.StatusCode -eq 200) { + $sum = 0.0 + $actualRowCount = 0 + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context "actual cost for $($sub.Id)")) { + $res = $page.Content | ConvertFrom-Json + if ($res.properties.rows.Count -eq 0) { continue } + $cIdx = Get-CostColumnIndex -Columns $res.properties.columns -Names @('cost', 'pretaxcost', 'costusd') + $curIdx = Get-CostColumnIndex -Columns $res.properties.columns -Names @('currency') + if ($cIdx -lt 0 -or $curIdx -lt 0) { throw 'Actual cost response did not expose the expected Cost and Currency columns.' } + foreach ($row in $res.properties.rows) { + $rowCurrency = ([string]$row[$curIdx]).Trim().ToUpperInvariant() + if (-not $rowCurrency -or ($currency -and $currency -ne $rowCurrency)) { throw 'Actual cost currency is unavailable or mixed.' } + $sum += [double]$row[$cIdx] + $currency = $rowCurrency + $actualRowCount++ + } + } + if ($actualRowCount -gt 0) { $actual = [math]::Round($sum, 2) } + } + elseif ($resp.StatusCode -in @(400, 403) -and $resp.Content) { + $errMsg = try { ($resp.Content | ConvertFrom-Json).error.message } catch { '' } + 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." + } + } + if (-not $resp -or $resp.StatusCode -ne 200) { + throw "Actual cost query returned HTTP $($resp.StatusCode); results are incomplete." + } + + # Forecast starts as actual so a sub with no forecast still reports a + # number; ForecastSource records that it is month-to-date, not a projection. + $costMap[$sub.Id] = @{ Actual = $actual; Forecast = $actual; Currency = $currency; ForecastSource = 'Actual'; ActualPeriod = 'Month to date (UTC query window)' } + + # Per-sub forecast (skipped for large tenants) + if (-not $skipForecast) { + try { + $now = (Get-Date).ToUniversalTime() + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + $fBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.AddDays(1 - $now.Day).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 (-not $fResp -or $fResp.StatusCode -ne 200) { + throw "Forecast query returned HTTP $($fResp.StatusCode); results are incomplete." + } + if ($fResp.StatusCode -eq 200) { + $total = 0.0 + $rowCount = 0 + $forecastCurrency = $null + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $fResp -Payload $fBody -Context "forecast for $($sub.Id)")) { + $fRes = $page.Content | ConvertFrom-Json + if ($fRes.properties.rows.Count -eq 0) { continue } + $fcIdx = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('cost', 'pretaxcost', 'costusd') + $fcCurIdx = Get-CostColumnIndex -Columns $fRes.properties.columns -Names @('currency') + if ($fcIdx -lt 0 -or $fcCurIdx -lt 0) { throw 'Forecast response did not expose the expected Cost and Currency columns.' } + foreach ($fRow in $fRes.properties.rows) { + $rowCurrency = ([string]$fRow[$fcCurIdx]).Trim().ToUpperInvariant() + if (-not $rowCurrency -or ($forecastCurrency -and $forecastCurrency -ne $rowCurrency)) { throw 'Forecast currency is unavailable or mixed.' } + $forecastCurrency = $rowCurrency + $total += [double]$fRow[$fcIdx] + $rowCount++ + } + } + if ($rowCount -gt 0) { + if ($currency -and $currency -ne $forecastCurrency) { throw 'Actual cost and forecast currency differ.' } + $costMap[$sub.Id].Currency = $forecastCurrency + $costMap[$sub.Id].Forecast = [math]::Round($total, 2) + $costMap[$sub.Id].ForecastSource = 'Forecast' + } + else { + throw 'Forecast query returned no rows; results are incomplete.' + } + } + } + catch { + throw + } + } + } + catch { + throw "Cost query failed for $($sub.Name): $($_.Exception.Message)" + } + } + return $costMap +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 new file mode 100644 index 000000000..2804c8fda --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-CostTrend.ps1 @@ -0,0 +1,402 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Read-only: builds in-memory objects and changes no state.')] +param() + +########################################################################### +# GET-COSTTREND.PS1 +# AZURE FINOPS MULTITOOL - 6-Month Cost Trend Data +########################################################################### +# 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 six full months and current month-to-date cost trend..." -ForegroundColor Cyan + + $now = (Get-Date).ToUniversalTime() + $periodEnd = $now.AddTicks( - ($now.Ticks % [TimeSpan]::TicksPerSecond)) + $periodStart = $periodEnd.Date.AddDays(1 - $periodEnd.Day).AddMonths(-6) + $fromStr = $periodStart.ToString('yyyy-MM-ddTHH:mm:ssZ') + $toStr = $periodEnd.ToString('yyyy-MM-ddTHH:mm:ssZ') + $subscriptionNames = @{} + foreach ($subscription in $Subscriptions) { + if ($subscription.Id) { + $subscriptionNames[[string]$subscription.Id] = if ($subscription.Name) { [string]$subscription.Name } else { [string]$subscription.Id } + } + } + $noDataSubscriptionIds = [System.Collections.Generic.List[string]]::new() + $individuallyQueriedIds = [System.Collections.Generic.List[string]]::new() + $queryErrors = [System.Collections.Generic.List[string]]::new() + $queryScope = $null + + $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 -or $subscriptionNames.Count -gt 0) { 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 ConvertFrom-TrendCostRow { + param($Rows, $Columns) + $entries = [System.Collections.Generic.List[PSCustomObject]]::new() + if (-not $Rows) { return $entries } + + $costIdx = Get-CostColumnIndex -Columns $Columns -Names @('cost', 'pretaxcost', 'costusd', 'totalcost') + $dateIdx = Get-CostColumnIndex -Columns $Columns -Names @('billingmonth', 'usagedate') + $currIdx = Get-CostColumnIndex -Columns $Columns -Names @('currency', 'billingcurrency') + if ($costIdx -lt 0 -or $dateIdx -lt 0 -or $currIdx -lt 0) { + throw 'Cost trend requires explicit cost, date, and currency columns; results are incomplete.' + } + + 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 = ([string]$row[$currIdx]).Trim().ToUpperInvariant() + if ($currency -notmatch '^[A-Z]{3}$' -or $currency -in @('XXX', 'XTS')) { throw 'Cost trend currency is unavailable or invalid.' } + [void]$entries.Add([PSCustomObject]@{ + Month = $parsed.ToString('MMM yyyy') + MonthDate = $parsed + Cost = $cost + Currency = $currency + }) + } + return $entries + } + + # Cost Management answers one page at a time. Reading only the first page + # under-reports a large scope as lower spend rather than as an error, so + # every page is collected before the rows are parsed. + function Get-AllCostRow { + param($FirstResponse, [string]$Payload, [string]$Context) + $rows = [System.Collections.Generic.List[object]]::new() + $columns = $null + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $FirstResponse -Payload $Payload -Context $Context)) { + $parsed = ($page.Content | ConvertFrom-Json) + if (-not $columns) { $columns = $parsed.properties.columns } + foreach ($row in @($parsed.properties.rows)) { [void]$rows.Add($row) } + } + return [PSCustomObject]@{ Rows = @($rows); Columns = $columns } + } + + try { + # Parse a SubscriptionId-grouped Monthly response into per-sub entries. + function ConvertFrom-GroupedCostRow { + param($Rows, $Columns) + $out = [System.Collections.Generic.List[PSCustomObject]]::new() + if (-not $Rows) { return $out } + $costIdx = Get-CostColumnIndex -Columns $Columns -Names @('cost', 'pretaxcost', 'costusd', 'totalcost') + $dateIdx = Get-CostColumnIndex -Columns $Columns -Names @('billingmonth', 'usagedate') + $currIdx = Get-CostColumnIndex -Columns $Columns -Names @('currency', 'billingcurrency') + $subIdx = Get-CostColumnIndex -Columns $Columns -Names @('subscriptionid') + if ($costIdx -lt 0 -or $dateIdx -lt 0 -or $currIdx -lt 0 -or $subIdx -lt 0) { + throw 'Grouped cost trend requires explicit cost, date, currency, and subscription columns; results are incomplete.' + } + foreach ($row in $Rows) { + $subId = [string]$row[$subIdx] + if ([string]::IsNullOrWhiteSpace($subId)) { throw 'Cost trend subscription is missing; results are incomplete.' } + if ($subscriptionNames.Count -gt 0 -and -not $subscriptionNames.ContainsKey($subId)) { continue } + $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 = ([string]$row[$currIdx]).Trim().ToUpperInvariant() + if ($currency -notmatch '^[A-Z]{3}$' -or $currency -in @('XXX', 'XTS')) { throw 'Cost trend currency is unavailable or invalid.' } + [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)) { + # Track every currency in the month, not just the first seen. + $agg[$key] = @{ Cost = 0; Date = $e.MonthDate; Currencies = @{} } + } + Add-CurrencySeen -Seen $agg[$key].Currencies -Currency $e.Currency + if ($agg[$key].Currencies.Count -ne 1) { throw 'Cost trend cannot combine multiple billing currency values in one monthly total.' } + $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 = Resolve-CurrencyLabel -Seen $entry.Value.Currencies + }) + } + } + + $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] + $queryScope = "/subscriptions/$($only.Id)" + $subPath = "/subscriptions/$($only.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $subResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $body + $paged = Get-AllCostRow -FirstResponse $subResp -Payload $body -Context "cost trend for $($only.Name)" + if ($paged.Rows.Count -gt 0) { + $months = ConvertFrom-TrendCostRow -Rows $paged.Rows -Columns $paged.Columns + $bySubscription[$only.Id] = @($months | Sort-Object MonthDate) + } + else { $noDataSubscriptionIds.Add([string]$only.Id) } + } + 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. + # Keep the grouped query filtered to the selected subscriptions. + $mgScopeId = Resolve-CostMgId -TenantId $TenantId + $useMgScope = [bool]$mgScopeId + $groupedOk = $false + + if ($useMgScope) { + $queryScope = "/providers/Microsoft.Management/managementGroups/$mgScopeId" + $mgPath = "/providers/Microsoft.Management/managementGroups/$mgScopeId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $response = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $groupedBody + if ($response.StatusCode -eq 200) { + $paged = Get-AllCostRow -FirstResponse $response -Payload $groupedBody -Context 'management-group cost trend' + if ($paged.Rows.Count -gt 0) { + $entries = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($entry in @(ConvertFrom-GroupedCostRow -Rows $paged.Rows -Columns $paged.Columns)) { $entries.Add($entry) } + if ($entries.Count -gt 0) { + # The group omits selected subscriptions outside it and those without cost rows. + $returnedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $monthCurrency = @{} + foreach ($entry in $entries) { + [void]$returnedIds.Add([string]$entry.SubId) + $monthCurrency[$entry.MonthDate.ToString('yyyy-MM')] = $entry.Currency + } + $omittedIds = @($subscriptionNames.Keys | Where-Object { -not $returnedIds.Contains([string]$_) } | Sort-Object) + if ($omittedIds.Count -gt 0) { + Write-Host " The management-group response omitted $($omittedIds.Count) selected subscription(s). Querying them individually..." -ForegroundColor Yellow + } + $i = 0 + foreach ($subId in $omittedIds) { + $i++ + if ($i -eq 1 -or $i -eq $omittedIds.Count -or ($omittedIds.Count -gt 5 -and $i % [math]::Max(1, [int]($omittedIds.Count / 10)) -eq 0)) { + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus "Querying omitted subscriptions for cost trend ($i/$($omittedIds.Count))..." + } + } + $individuallyQueriedIds.Add($subId) + try { + $subResp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$subId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" -Method POST -Payload $body + $subPaged = Get-AllCostRow -FirstResponse $subResp -Payload $body -Context "cost trend for $($subscriptionNames[$subId])" + $subMonths = @(ConvertFrom-TrendCostRow -Rows $subPaged.Rows -Columns $subPaged.Columns) + # A month billed in another currency can't join that month's total, so the subscription stays unverified. + $subCurrency = @{} + foreach ($subMonth in $subMonths) { + $monthKey = $subMonth.MonthDate.ToString('yyyy-MM') + $expectedCurrency = if ($subCurrency.ContainsKey($monthKey)) { $subCurrency[$monthKey] } else { $monthCurrency[$monthKey] } + if ($expectedCurrency -and $expectedCurrency -ne $subMonth.Currency) { + throw "$($subMonth.Month) is billed in $($subMonth.Currency), but the trend total for that month is in $expectedCurrency." + } + $subCurrency[$monthKey] = $subMonth.Currency + } + } + catch { + $queryErrors.Add("$($subscriptionNames[$subId]) [$subId]: $($_.Exception.Message)") + continue + } + if ($subMonths.Count -eq 0) { $noDataSubscriptionIds.Add($subId); continue } + foreach ($monthKey in $subCurrency.Keys) { $monthCurrency[$monthKey] = $subCurrency[$monthKey] } + foreach ($subMonth in $subMonths) { + $entries.Add([PSCustomObject]@{ SubId = $subId; Month = $subMonth.Month; MonthDate = $subMonth.MonthDate; Cost = $subMonth.Cost; Currency = $subMonth.Currency }) + } + } + if ($queryErrors.Count -gt 0) { + Write-Warning "$($queryErrors.Count) omitted subscription(s) couldn't be added to the cost trend. They stay unverified and aren't counted as zero cost." + } + } + 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) { + $queryScope = 'Individual selected-subscription queries' + $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 + + $paged = Get-AllCostRow -FirstResponse $subResp -Payload $body -Context "cost trend for $($sub.Name)" + if ($paged.Rows.Count -gt 0) { + $subMonths = ConvertFrom-TrendCostRow -Rows $paged.Rows -Columns $paged.Columns + $bySubscription[$sub.Id] = @($subMonths | Sort-Object MonthDate) + + foreach ($sm in $subMonths) { + $key = $sm.MonthDate.ToString('yyyy-MM') + if (-not $aggTotals.ContainsKey($key)) { + $aggTotals[$key] = @{ Cost = 0; Date = $sm.MonthDate; Currency = $sm.Currency } + } + if ($aggTotals[$key].Currency -ne $sm.Currency) { throw 'Cost trend cannot combine multiple billing currency values in one monthly total.' } + $aggTotals[$key].Cost += $sm.Cost + } + } + else { $noDataSubscriptionIds.Add([string]$sub.Id) } + } + + 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 { + throw "Cost trend query failed: $($_.Exception.Message)" + } + + # Sort by date + $sorted = @($months | Sort-Object MonthDate) + $unverifiedSubscriptionIds = @($subscriptionNames.Keys | Where-Object { + -not $bySubscription.ContainsKey($_) -and $_ -notin $noDataSubscriptionIds + } | Sort-Object) + $coverageIncomplete = $subscriptionNames.Count -eq 0 -or $unverifiedSubscriptionIds.Count -gt 0 + $note = if ($subscriptionNames.Count -eq 0) { + 'The selected subscription set was not recorded. These results do not establish whole-tenant coverage.' + } + elseif ($unverifiedSubscriptionIds.Count -gt 0) { + "Trend coverage is not verified for $($unverifiedSubscriptionIds.Count) selected subscription(s). The management-group response omitted them, and their individual results couldn't be added: the query failed, or a month's currency differed from the trend total. Missing subscriptions are not treated as zero cost." + } + elseif ($noDataSubscriptionIds.Count -gt 0) { + "$($noDataSubscriptionIds.Count) selected subscription(s) returned no cost rows. No zero-valued months were added for them." + } + else { $null } + + return [PSCustomObject]@{ + Months = $sorted + BySubscription = $bySubscription + HasData = ($sorted.Count -gt 0) + ScopeKind = if ($subscriptionNames.Count -gt 0) { 'Selected subscriptions' } else { 'Management group' } + TenantId = $TenantId + QueryScope = $queryScope + SubscriptionNames = $subscriptionNames + SelectedSubscriptionCount = $subscriptionNames.Count + SubscriptionsWithData = $bySubscription.Count + NoDataSubscriptionIds = $noDataSubscriptionIds.ToArray() + UnverifiedSubscriptionIds = $unverifiedSubscriptionIds + IndividuallyQueriedIds = $individuallyQueriedIds.ToArray() + QueryErrors = $queryErrors.ToArray() + CoverageIncomplete = $coverageIncomplete + CostBasis = 'ActualCost' + CostPeriodStartUtc = $periodStart + CostPeriodEndUtc = $periodEnd + Note = $note + } +} 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..725eb2833 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-IdleVMs.ps1 @@ -0,0 +1,201 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-IDLEVMS.PS1 +# AZURE FINOPS MULTITOOL - Idle & Underutilized VM Detection +########################################################################### +# 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() + $metricFailures = [System.Collections.Generic.List[string]]::new() + + # -- 1: Find all running VMs ------------------------------------------ + # Pull every VM with its power state so we can distinguish "no VMs at all" + # 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 id, name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + osType = properties.storageProfile.osDisk.osType, + powerState +"@ + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -All + $allVMs = if ($result) { @($result.Data) } else { @() } + $totalVMs = $allVMs.Count + $runningVMs = @($allVMs | Where-Object { $_.powerState -eq 'PowerState/running' }) + $deallocatedCount = $totalVMs - $runningVMs.Count + Write-Host " VMs found: $totalVMs ($($runningVMs.Count) running, $deallocatedCount stopped/deallocated)" -ForegroundColor Gray + } + catch { + throw "Running VM inventory is incomplete: $($_.Exception.Message)" + } + + 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 - run the orphaned resources scan." + } + else { + 'No virtual machines found in scope.' + } + return [PSCustomObject]@{ + IdleVMs = @() + Count = 0 + HasData = $false + ScannedVMs = 0 + TotalVMs = $totalVMs + DeallocatedVMs = $deallocatedCount + # Same shape as the main return so callers can read these on either path. + EvaluatedVMs = 0 + MetricFailures = 0 + MetricFailureDetail = @() + Note = $note + } + } + + # -- 2: Query 14-day avg CPU + Network for each VM ------------------- + $armBase = Get-FinOpsArmEndpoint + $token = Get-PlainAccessToken -ResourceUrl $armBase + $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } + $now = (Get-Date).ToUniversalTime() + $fourteenDaysAgo = $now.AddDays(-14).ToString('yyyy-MM-ddTHH:mm:ssZ') + $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 + # FULL is the only way to get one datapoint for the whole span: + # P14D is not a published timegrain and the API rejects it. + $metricUri = "$armBase$scope/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Percentage CPU,Network In Total,Network Out Total×pan=$fourteenDaysAgo/$nowStr&aggregation=Average,Total&interval=FULL" + $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -MaximumRedirection 0 -ErrorAction Stop + $metricData = ($resp.Content | ConvertFrom-Json) + + $avgCpu = $null + $totalNetIn = 0 + $totalNetOut = 0 + $networkInSamples = 0 + $networkOutSamples = 0 + + foreach ($metric in $metricData.value) { + $metricName = $metric.name.value + foreach ($ts in $metric.timeseries) { + foreach ($dp in $ts.data) { + switch ($metricName) { + 'Percentage CPU' { + $avgCpu = Get-HubCostValue -Row $dp -Column 'average' + if ($avgCpu -lt 0 -or $avgCpu -gt 100) { throw 'CPU measurement is outside the valid percentage range.' } + } + 'Network In Total' { + $networkValue = Get-HubCostValue -Row $dp -Column 'total' + if ($networkValue -lt 0) { throw 'Network measurement cannot be negative.' } + $totalNetIn += $networkValue + $networkInSamples++ + } + 'Network Out Total' { + $networkValue = Get-HubCostValue -Row $dp -Column 'total' + if ($networkValue -lt 0) { throw 'Network measurement cannot be negative.' } + $totalNetOut += $networkValue + $networkOutSamples++ + } + } + } + } + } + + if ($null -eq $avgCpu -or $networkInSamples -eq 0 -or $networkOutSamples -eq 0) { throw 'CPU or network measurements are missing; the VM was not evaluated.' } + $totalNetwork = $totalNetIn + $totalNetOut + + # Classify: idle if CPU < threshold AND network < threshold + $isIdle = $false + $classification = $null + + if ($null -ne $avgCpu -and $avgCpu -lt $cpuThreshold -and $totalNetwork -lt $networkThreshold14d) { + $isIdle = $true + $classification = 'Idle' + } + elseif ($null -ne $avgCpu -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 { + # A throttled or unauthorized metrics call is not the same as a busy VM, + # so count it rather than letting it read as "nothing to report". + [void]$metricFailures.Add("$($vm.name): $($_.Exception.Message)") + } + } + + if ($metricFailures.Count -gt 0) { + Write-Warning " Metrics unavailable for $($metricFailures.Count) of $($runningVMs.Count) VM(s); those VMs were not evaluated." + foreach ($f in ($metricFailures | Select-Object -First 3)) { Write-Verbose " $f" } + } + + Write-Host " Idle/underutilized VMs: $($results.Count)" -ForegroundColor Gray + + [PSCustomObject]@{ + IdleVMs = @($results) + Count = $results.Count + HasData = ($results.Count -gt 0) + ScannedVMs = $runningVMs.Count + TotalVMs = $totalVMs + DeallocatedVMs = $deallocatedCount + # Evaluated excludes VMs whose metrics could not be read, so a caller can + # tell a clean result from a partial one. + EvaluatedVMs = ($runningVMs.Count - $metricFailures.Count) + MetricFailures = $metricFailures.Count + MetricFailureDetail = @($metricFailures) + Note = if ($metricFailures.Count -gt 0) { 'VM utilization coverage is incomplete; VMs with unreadable metrics are not assumed active.' } else { $null } + } +} 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..b6ec878b6 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-LegacyResources.ps1 @@ -0,0 +1,190 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-LEGACYRESOURCES.PS1 +# AZURE FINOPS MULTITOOL - Legacy & Retiring Resource Detection +########################################################################### +# 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 id, name, resourceGroup, subscriptionId, location, vmSize +"@ + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 -All + $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 { + throw "Legacy VM inventory is incomplete: $($_.Exception.Message)" + } + + # -- 2: Unmanaged (VHD) disks ----------------------------------------- + try { + $vhdQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where isnotempty(properties.storageProfile.osDisk.vhd.uri) +| project id, name, resourceGroup, subscriptionId, location, + vhd = tostring(properties.storageProfile.osDisk.vhd.uri) +"@ + $result = Search-AzGraphSafe -Query $vhdQuery -Subscription $subIds -First 1000 -All + $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 { + throw "Unmanaged disk inventory is incomplete: $($_.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 id, name, resourceGroup, subscriptionId, location, + diskSizeGb = properties.diskSizeGB, sku = sku.name +"@ + $result = Search-AzGraphSafe -Query $hddQuery -Subscription $subIds -First 1000 -All + $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 { + throw "HDD disk inventory is incomplete: $($_.Exception.Message)" + } + + # -- 4: Basic-SKU public IPs (retiring Sept 2025) --------------------- + try { + $pipQuery = @" +resources +| where type =~ 'microsoft.network/publicipaddresses' +| where tostring(sku.name) =~ 'Basic' +| project id, name, resourceGroup, subscriptionId, location, sku = sku.name +"@ + $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 -All + $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 { + throw "Basic public IP inventory is incomplete: $($_.Exception.Message)" + } + + # -- 5: Basic-SKU load balancers (retiring Sept 2025) ----------------- + try { + $lbQuery = @" +resources +| where type =~ 'microsoft.network/loadbalancers' +| where tostring(sku.name) =~ 'Basic' +| project id, name, resourceGroup, subscriptionId, location, sku = sku.name +"@ + $result = Search-AzGraphSafe -Query $lbQuery -Subscription $subIds -First 1000 -All + $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 { + throw "Basic load balancer inventory is incomplete: $($_.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 + # Rank explicitly: a descending string sort puts 'Low' ahead of 'High'. + LegacyResources = @($allLegacy | Sort-Object @{ Expression = { switch ([string]$_.Impact) { 'High' { 0 } 'Medium' { 1 } 'Low' { 2 } default { 3 } } } }, Category) + ByCategory = $byCategory + ScannedSubs = $Subscriptions.Count + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 new file mode 100644 index 000000000..13464c07a --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-MaccCommitment.ps1 @@ -0,0 +1,199 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + +########################################################################### +# GET-MACCCOMMITMENT.PS1 +# AZURE FINOPS MULTITOOL - MACC Consumption Commitment Tracking +########################################################################### +# 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 = @() + CoverageIncomplete = $false + ReadErrors = @() + } + + # -- 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 + $parsed = Get-FinOpsListResult -FirstResponse $resp -Context 'MACC billing accounts' + if ($resp.StatusCode -eq 200) { + 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)" + $result.CoverageIncomplete = $true + $result.ReadErrors = @($_.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 + } + + # Keep only accounts that own a scanned subscription. Listing billingAccounts + # returns every account the caller can read, which would otherwise mix in + # commitments from unrelated enrollments. + $scope = Get-FinOpsBillingScope -BillingAccounts $billingAccounts -Subscriptions $Subscriptions + if (-not $scope.Resolved) { + $result.Reason = $scope.Reason + $result.CoverageIncomplete = $true + Write-Host " MACC: $($scope.Reason)" -ForegroundColor DarkGray + return $result + } + if (@($scope.Accounts).Count -lt $billingAccounts.Count) { + Write-Host " MACC: scoped to $(@($scope.Accounts).Count) of $($billingAccounts.Count) reachable billing account(s)." -ForegroundColor DarkGray + } + $billingAccounts = @($scope.Accounts) + + # -- Step 2: List MACC lots per billing account --------------------- + $lots = [System.Collections.Generic.List[PSCustomObject]]::new() + $readErrors = [Collections.Generic.List[string]]::new() + if ($scope.CoverageIncomplete) { + foreach ($issue in $scope.ReadErrors) { $readErrors.Add([string]$issue) } + } + $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 } + if ($lotResp.StatusCode -eq 404) { continue } + throw "MACC lots returned HTTP $($lotResp.StatusCode); results are incomplete." + } + $lotData = Get-FinOpsListResult -FirstResponse $lotResp -Context "MACC lots for $($ba.DisplayName)" + foreach ($lot in $lotData.value) { + $p = $lot.properties + if ("$($p.source)" -ne 'ConsumptionCommitment') { continue } + + $currency = if ($p.billingCurrency) { ([string]$p.billingCurrency).Trim().ToUpperInvariant() } + elseif ($p.originalAmount.currency) { ([string]$p.originalAmount.currency).Trim().ToUpperInvariant() } else { $null } + $original = if ($null -ne $p.originalAmount.value) { Get-HubCostValue -Row $p.originalAmount -Column 'value' } else { $null } + + # 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) { Get-HubCostValue -Row $p.closedBalance -Column 'value' } else { $null } + $comparable = $currency -match '^[A-Z]{3}$' -and $currency -notin @('XXX', 'XTS') -and $null -ne $original -and $null -ne $remaining + foreach ($balanceCurrency in @($p.originalAmount.currency, $p.closedBalance.currency)) { + if ($balanceCurrency -and ([string]$balanceCurrency).Trim() -ne $currency) { $comparable = $false } + } + if (-not $comparable) { $readErrors.Add("$($ba.DisplayName): a commitment lot has missing or incompatible balance amounts or currency.") } + $used = if ($comparable) { [math]::Round($original - $remaining, 2) } else { $null } + $pctUsed = if ($comparable -and $original -gt 0) { [math]::Round(($used / $original) * 100, 1) } else { $null } + + $lots.Add([PSCustomObject]@{ + BillingAccount = $ba.DisplayName + Agreement = $ba.Agreement + Currency = $currency + Commitment = if ($comparable) { [math]::Round($original, 2) } else { $null } + Consumed = $used + Remaining = if ($comparable) { [math]::Round($remaining, 2) } else { $null } + 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 { + $readErrors.Add("$($ba.DisplayName): $($_.Exception.Message)") + 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 + } + elseif ($readErrors.Count -eq 0) { + $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 + } + + if ($readErrors.Count -gt 0) { + $result.CoverageIncomplete = $true + $result.ReadErrors = @($readErrors) + $result.Reason = ('MACC coverage is incomplete. ' + $result.Reason + ' ' + ($readErrors -join ' ')).Trim() + } + 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..c450d9ac5 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OptimizationAdvice.ps1 @@ -0,0 +1,184 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + +########################################################################### +# GET-OPTIMIZATIONADVICE.PS1 +# AZURE FINOPS MULTITOOL - Azure Advisor Cost Optimization +########################################################################### +# 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() + $readErrors = [System.Collections.Generic.List[string]]::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 id, 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 + # -All follows continuation tokens and fails on unreadable or truncated pages. + $result = Search-AzGraphSafe -Query $query -Subscription $subIds -First 1000 -All + if (-not $result) { throw 'Advisor recommendations could not be read from Resource Graph; results are incomplete.' } + $allRows = @($result.Data) + + 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 { + # The fallback rereads every subscription, so rows read before the failure would count twice. + $allRecs.Clear() + 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 + $advResult = Get-FinOpsListResult -FirstResponse $advResp -Context "Advisor recommendations for $($sub.Name)" + + 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 { + [void]$readErrors.Add("$($sub.Name): $($_.Exception.Message)") + Write-Warning " Advisor query failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + # -- Summarize by category ------------------------------------------ + $byCat = $allRecs | Group-Object Category | ForEach-Object { + $categorySavings = Measure-FinOpsSavingsEstimate -Recommendations $_.Group + [PSCustomObject]@{ + Category = $_.Name + Count = $_.Count + TotalSavings = $categorySavings.Total + Currency = $categorySavings.Currency + CostIssue = $categorySavings.CostIssue + } + } + + $savingsSummary = Measure-FinOpsSavingsEstimate -Recommendations @($allRecs) + + # -- 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 = $savingsSummary.Total + Currency = $savingsSummary.Currency + CostIssue = $savingsSummary.CostIssue + CoverageIncomplete = ($readErrors.Count -gt 0) + ReadErrors = @($readErrors) + Note = if ($readErrors.Count -gt 0) { "Advisor recommendations are incomplete. $($readErrors -join ' ')" } else { $null } + Summary = "$($allRecs.Count) optimization recommendations; estimated annual savings: $(Format-BudgetAmount -Value $savingsSummary.Total -Currency $savingsSummary.Currency)" + } +} 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..f41d224e2 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-OrphanedResources.ps1 @@ -0,0 +1,447 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-ORPHANEDRESOURCES.PS1 +# AZURE FINOPS MULTITOOL - Orphaned & Idle Resource Detection +########################################################################### +# 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, + + [Parameter()] + [switch]$SkipCost + ) + + 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 id, 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 -All + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Orphaned Disk' + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.diskSizeGb) GB ($($r.sku))" + Impact = 'Medium' + }) + } + Write-Host " Orphaned disks: $($rows.Count)" -ForegroundColor Gray + } + catch { + throw "Orphaned disk inventory is incomplete: $($_.Exception.Message)" + } + + # -- 2: Idle Public IPs ------------------------------------------------ + # Two distinct cases with different remediation: an IP attached to nothing, + # and an IP still reserved by a stopped VM. Both bill; only the first is + # safe to simply delete. + try { + $pipQuery = @" +resources +| where type =~ 'microsoft.network/publicipaddresses' +| extend ipConfigId = tolower(tostring(properties.ipConfiguration.id)) +| extend natGw = tostring(properties.natGateway.id) +| project id, name, resourceGroup, subscriptionId, location, + sku = tostring(sku.name), + allocationMethod = tostring(properties.publicIPAllocationMethod), + ipAddress = tostring(properties.ipAddress), + ipConfigId, natGw +| join kind=leftouter ( + resources + | where type =~ 'microsoft.network/networkinterfaces' + | mv-expand ipc = properties.ipConfigurations + | project nicName = name, + nicVmId = tolower(tostring(properties.virtualMachine.id)), + ipConfigId = tolower(tostring(ipc.id)) + ) on ipConfigId +| join kind=leftouter ( + resources + | where type =~ 'microsoft.compute/virtualmachines' + | project nicVmId = tolower(tostring(id)), vmName = name, + vmPower = tostring(properties.extended.instanceView.powerState.displayStatus) + ) on nicVmId +| project id, name, resourceGroup, subscriptionId, location, sku, allocationMethod, + ipAddress, ipConfigId, natGw, nicName, vmName, vmPower +"@ + $result = Search-AzGraphSafe -Query $pipQuery -Subscription $subIds -First 1000 -All + $rows = if ($result) { @($result.Data) } else { @() } + $pipUnattached = 0 + $pipStoppedVm = 0 + foreach ($r in $rows) { + $addr = if ($r.ipAddress) { $r.ipAddress } else { 'no address' } + $held = if ($r.allocationMethod -eq 'Static') { 'address is reserved' } else { 'address is dynamic' } + $isStoppedVm = ($r.vmName -and $r.vmPower -and $r.vmPower -notmatch '(?i)running') + $isUnattached = ([string]::IsNullOrWhiteSpace([string]$r.ipConfigId) -and [string]::IsNullOrWhiteSpace([string]$r.natGw)) + + if ($isStoppedVm) { + $category = 'Public IP on stopped VM' + $detail = "$($r.sku)/$($r.allocationMethod) $addr - held by stopped VM $($r.vmName), $held" + $pipStoppedVm++ + } + elseif ($isUnattached) { + $category = 'Unattached Public IP' + $detail = "$($r.sku)/$($r.allocationMethod) $addr - attached to nothing, $held" + $pipUnattached++ + } + else { + # In use by a load balancer, gateway, Bastion, firewall or running VM. + continue + } + + [void]$allOrphans.Add([PSCustomObject]@{ + Category = $category + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + IpAddress = $r.ipAddress + AttachedTo = if ($isStoppedVm) { $r.vmName } else { $null } + Detail = $detail + Impact = if ($r.sku -eq 'Standard') { 'Medium' } else { 'Low' } + }) + } + Write-Host " Unattached public IPs: $pipUnattached" -ForegroundColor Gray + Write-Host " Public IPs on stopped VMs: $pipStoppedVm" -ForegroundColor Gray + } + catch { + throw "Public IP inventory is incomplete: $($_.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 id, name, resourceGroup, subscriptionId, location, + enableAcceleratedNetworking = properties.enableAcceleratedNetworking, + type = 'Unattached NIC' +"@ + $result = Search-AzGraphSafe -Query $nicQuery -Subscription $subIds -First 1000 -All + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Unattached NIC' + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "Accelerated: $($r.enableAcceleratedNetworking)" + Impact = 'Low' + }) + } + Write-Host " Unattached NICs: $($rows.Count)" -ForegroundColor Gray + } + catch { + throw "NIC inventory is incomplete: $($_.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 id, name, resourceGroup, subscriptionId, location, + vmSize = properties.hardwareProfile.vmSize, + powerState = properties.extended.instanceView.powerState.displayStatus, + osDiskId = tostring(properties.storageProfile.osDisk.managedDisk.id), + dataDisks = properties.storageProfile.dataDisks, + type = 'Deallocated VM' +"@ + $result = Search-AzGraphSafe -Query $vmQuery -Subscription $subIds -First 1000 -All + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + # A stopped VM bills nothing itself; the spend sits on its managed disks. + $vmDiskIds = [System.Collections.Generic.List[string]]::new() + if ($r.osDiskId) { [void]$vmDiskIds.Add([string]$r.osDiskId) } + foreach ($dd in @($r.dataDisks)) { + if ($dd -and $dd.managedDisk -and $dd.managedDisk.id) { [void]$vmDiskIds.Add([string]$dd.managedDisk.id) } + } + $diskLabel = if ($vmDiskIds.Count -eq 1) { '1 disk' } else { "$($vmDiskIds.Count) disks" } + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Deallocated VM' + ResourceId = $r.id + ChildResourceIds = @($vmDiskIds) + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.vmSize) - $diskLabel still billing" + Impact = 'Medium' + }) + } + Write-Host " Deallocated VMs: $($rows.Count)" -ForegroundColor Gray + } + catch { + throw "Deallocated VM inventory is incomplete: $($_.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 id, 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 -All + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Empty App Service Plan' + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.sku), $($r.workers) worker(s), 0 apps" + Impact = 'High' + }) + } + Write-Host " Empty App Service Plans: $($rows.Count)" -ForegroundColor Gray + } + catch { + throw "App Service plan inventory is incomplete: $($_.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 id, name, resourceGroup, subscriptionId, location, + diskSizeGb = properties.diskSizeGB, + timeCreated = properties.timeCreated, + type = 'Old Snapshot' +"@ + $result = Search-AzGraphSafe -Query $snapQuery -Subscription $subIds -First 1000 -All + $rows = if ($result) { @($result.Data) } else { @() } + foreach ($r in $rows) { + [void]$allOrphans.Add([PSCustomObject]@{ + Category = 'Old Snapshot (30d+)' + ResourceId = $r.id + ResourceName = $r.name + ResourceGroup = $r.resourceGroup + SubscriptionId = $r.subscriptionId + Location = $r.location + Detail = "$($r.diskSizeGb) GB, created $($r.timeCreated)" + Impact = 'Low' + }) + } + Write-Host " Old snapshots (30d+): $($rows.Count)" -ForegroundColor Gray + } + catch { + throw "Snapshot inventory is incomplete: $($_.Exception.Message)" + } + + # -- Observed cost per orphan (best effort) --------------------------- + # Cost Management is a separate grant from Reader, so a denial here leaves + # MonthlyCost null instead of failing the scan. The API rejects the + # TheLastMonth timeframe, so a full previous month needs an explicit Custom + # range, with month-to-date as the fallback. + $costMap = @{} + $costFailures = [System.Collections.Generic.List[string]]::new() + if ($SkipCost) { [void]$costFailures.Add('Cost lookup was not requested for the selected data source.') } + $costQueried = 0 + $costPeriodLabel = $null + $firstOfThisMonth = (Get-Date -Day 1).Date + $lastMonthStart = $firstOfThisMonth.AddMonths(-1) + $lastMonthEnd = $firstOfThisMonth.AddDays(-1) + $costAttempts = @( + @{ + Label = $lastMonthStart.ToString('MMM yyyy') + Body = @{ + type = 'ActualCost' + timeframe = 'Custom' + timePeriod = @{ + from = $lastMonthStart.ToString('yyyy-MM-ddT00:00:00Z') + to = $lastMonthEnd.ToString('yyyy-MM-ddT23:59:59Z') + } + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'ResourceId' }) + } + } + } + @{ + Label = 'Month to date' + Body = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @(@{ type = 'Dimension'; name = 'ResourceId' }) + } + } + } + ) + if ($allOrphans.Count -gt 0 -and -not $SkipCost) { + foreach ($sub in $Subscriptions) { + $costResp = $null + $usedLabel = $null + $lastCode = 'no response' + foreach ($attempt in $costAttempts) { + # A later subscription reuses whatever period already worked. + if ($costPeriodLabel -and $attempt.Label -ne $costPeriodLabel) { continue } + try { + $body = $attempt.Body | ConvertTo-Json -Depth 10 + $r = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" -Method POST -Payload $body + if ($r -and $r.StatusCode -eq 200) { $costResp = $r; $usedLabel = $attempt.Label; break } + $lastCode = if ($r) { [string]$r.StatusCode } else { 'no response' } + } + catch { + $lastCode = $_.Exception.Message + } + } + + if (-not $costResp) { + $reason = switch ($lastCode) { + '429' { 'rate limited by Cost Management' } + '401' { 'not authorized for Cost Management' } + '403' { 'not authorized for Cost Management' } + default { "Cost Management returned $lastCode" } + } + [void]$costFailures.Add("$($sub.Name): $reason") + continue + } + + try { + # Follow nextLink: one page only would leave later orphans uncosted. + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $costResp -Payload $body -Context "orphan cost for $($sub.Name)")) { + $costResult = ($page.Content | ConvertFrom-Json) + $costCols = @{} + for ($cIdx = 0; $cIdx -lt $costResult.properties.columns.Count; $cIdx++) { + $costCols[$costResult.properties.columns[$cIdx].name] = $cIdx + } + if (-not $costCols.ContainsKey('Currency')) { throw 'Cost response did not expose the expected Currency column.' } + foreach ($costRow in $costResult.properties.rows) { + $rid = [string]$costRow[$costCols['ResourceId']] + # Resource Graph and Cost Management disagree on ID casing. + if ($rid) { + $costMap[$rid.ToLowerInvariant()] = [pscustomobject]@{ + Amount = [math]::Round([double]$costRow[$costCols['Cost']], 2) + Currency = ([string]$costRow[$costCols['Currency']]).Trim().ToUpperInvariant() + } + } + } + } + if (-not $costPeriodLabel) { $costPeriodLabel = $usedLabel } + $costQueried++ + } + catch { + [void]$costFailures.Add("$($sub.Name): $($_.Exception.Message)") + Write-Verbose "Orphan cost lookup failed for $($sub.Name): $($_.Exception.Message)" + } + } + } + + foreach ($orphan in $allOrphans) { + $ridKey = if ($orphan.ResourceId) { ([string]$orphan.ResourceId).ToLowerInvariant() } else { $null } + $ownParts = @(if ($ridKey -and $costMap.ContainsKey($ridKey)) { $costMap[$ridKey] }) + + # A deallocated VM bills nothing on its own object, so fold in its disks. + $attachedParts = @() + if ($orphan.PSObject.Properties['ChildResourceIds']) { + foreach ($childId in @($orphan.ChildResourceIds)) { + $childKey = ([string]$childId).ToLowerInvariant() + if ($costMap.ContainsKey($childKey)) { $attachedParts += $costMap[$childKey] } + } + } + + $parts = @($ownParts) + @($attachedParts) + $partCurrencies = @($parts | ForEach-Object { $_.Currency } | Select-Object -Unique) + $combined = $null + $attachedCost = $null + $orphanCurrency = $null + if ($partCurrencies.Count -eq 1) { + $orphanCurrency = [string]$partCurrencies[0] + $combined = [math]::Round(($parts | Measure-Object -Property Amount -Sum).Sum, 2) + if ($attachedParts.Count -gt 0) { $attachedCost = [math]::Round(($attachedParts | Measure-Object -Property Amount -Sum).Sum, 2) } + } + elseif ($partCurrencies.Count -gt 1) { + [void]$costFailures.Add("$($orphan.ResourceName): costs use multiple billing currencies") + } + $orphan | Add-Member -NotePropertyName MonthlyCost -NotePropertyValue $combined -Force + $orphan | Add-Member -NotePropertyName AttachedCost -NotePropertyValue $attachedCost -Force + $orphan | Add-Member -NotePropertyName Currency -NotePropertyValue $orphanCurrency -Force + } + + $costed = @($allOrphans | Where-Object { $null -ne $_.MonthlyCost }) + $costCurrencies = @($costed | ForEach-Object { $_.Currency } | Select-Object -Unique) + $totalCurrency = if ($costCurrencies.Count -eq 1) { [string]$costCurrencies[0] } else { $null } + $totalMonthlyCost = if ($costed.Count -gt 0 -and $totalCurrency) { [math]::Round((($costed | Measure-Object -Property MonthlyCost -Sum).Sum), 2) } else { $null } + if ($costCurrencies.Count -gt 1) { [void]$costFailures.Add('Observed costs use multiple billing currencies, so no combined total is shown') } + $costAvailable = ($costQueried -gt 0) + $costIssue = if ($costFailures.Count -gt 0) { ($costFailures | Select-Object -Unique) -join '; ' } else { $null } + if ($costed.Count -gt 0) { + Write-Host " Observed cost ($costPeriodLabel) on $($costed.Count) of $($allOrphans.Count) orphans." -ForegroundColor Gray + } + if ($costIssue) { + Write-Host " Cost lookup incomplete - $costIssue" -ForegroundColor Yellow + } + + # -- Summary by category -- + $summary = $allOrphans | Group-Object Category | ForEach-Object { + [PSCustomObject]@{ + Category = $_.Name + Count = $_.Count + } + } + + return [PSCustomObject]@{ + Orphans = @($allOrphans) + Summary = @($summary) + TotalCount = $allOrphans.Count + HasData = ($allOrphans.Count -gt 0) + MonthlyCost = $totalMonthlyCost + Currency = $totalCurrency + CostedCount = $costed.Count + CostAvailable = $costAvailable + CostPeriod = $costPeriodLabel + CostIssue = $costIssue + } +} 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..436664a03 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyInventory.ps1 @@ -0,0 +1,500 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; the dispatcher passes -TenantId to every scan module.')] +param() + +########################################################################### +# GET-POLICYINVENTORY.PS1 +# AZURE FINOPS MULTITOOL - Policy Inventory Across the Tenant +########################################################################### +# 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 Format-PolicyEffectName { + # Azure stores effect names inconsistently across definitions: some authored as + # "modify", others as "AuditIfNotExists". Capitalizing the first character keeps + # one column from mixing both conventions. + param([string]$Effect) + if ([string]::IsNullOrWhiteSpace($Effect)) { return $Effect } + $trimmed = $Effect.Trim() + return $trimmed.Substring(0, 1).ToUpperInvariant() + $trimmed.Substring(1) +} + +function Resolve-PolicyEffect { + # An assignment only carries an effect when it overrides the parameter, which + # is the exception rather than the rule. Everything else has to come from the + # definition, or the report understates what is actually enforced. + # + # Precedence: assignment override, definition literal, definition parameter default. + [CmdletBinding()] + param( + [Parameter()] + [string]$AssignmentEffect, + + [Parameter()] + [object]$Definition, + + # An initiative bundles policies with differing effects, so there is no + # single value to report. That is not the same as an unknown effect. + [Parameter()] + [switch]$IsInitiative + ) + + if (-not [string]::IsNullOrWhiteSpace($AssignmentEffect) -and $AssignmentEffect -ne '-') { + return (Format-PolicyEffectName $AssignmentEffect) + } + if ($IsInitiative) { return 'varies (Initiative)' } + if (-not $Definition) { return '-' } + + # "[parameters('effect')]" defers to the parameter default; a bare word is the effect. + $literal = [string]$Definition.policyRule.then.effect + if (-not [string]::IsNullOrWhiteSpace($literal) -and $literal -notmatch '^\s*\[') { + return (Format-PolicyEffectName $literal) + } + + $default = [string]$Definition.parameters.effect.defaultValue + if (-not [string]::IsNullOrWhiteSpace($default)) { return (Format-PolicyEffectName $default) } + + return '-' +} + +function Get-PolicyDefinitionMap { + # Fetched by resource ID rather than queried. Resource Graph's policyresources + # only returns definitions scoped to the subscriptions being queried, which + # excludes tenant-level built-ins and management-group definitions - and those + # are exactly where inherited assignments point. A GET against the definition + # ID works for all three scopes. + # + # Callers pass only the IDs they could not resolve, deduplicated, so this stays + # proportional to distinct definitions rather than to assignment count. + [CmdletBinding()] + param( + [Parameter()] + [string[]]$DefinitionIds, + + [Parameter()] + [System.Collections.Generic.List[string]]$ReadErrors + ) + + $map = @{} + foreach ($id in @($DefinitionIds | Where-Object { $_ } | Select-Object -Unique)) { + # The ID is concatenated ahead of a query string, so anything carrying '?', + # '#' or '&' could rewrite the request. Accept only well-formed definition IDs. + # The scope prefix is optional: built-ins start at /providers directly. + try { + if ($id -notmatch '^(/[A-Za-z0-9._\-()/]+)?/providers/Microsoft\.Authorization/policyDefinitions/[A-Za-z0-9._\-()]+$') { + throw 'The policy definition resource ID is invalid. No request was sent.' + } + $resp = Invoke-AzRestMethodWithRetry -Path "$($id)?api-version=2023-04-01" -Method GET + if (-not $resp -or $resp.StatusCode -ne 200) { throw "HTTP $($resp.StatusCode) while reading policy definition." } + $def = $resp.Content | ConvertFrom-Json -ErrorAction Stop + if (-not $def.properties) { throw 'The policy definition response has no properties.' } + $map[[string]$id] = $def.properties + } + catch { + $message = "Policy definition '$id' could not be read: $($_.Exception.Message)" + if ($null -ne $ReadErrors) { [void]$ReadErrors.Add($message) } + Write-Warning $message + continue + } + } + return $map +} + +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 + $subFailures = [System.Collections.Generic.List[string]]::new() + + # -- 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) { + # Scoped per subscription: a transient failure on one must not abandon + # the rest of the tenant and leave a partial result looking complete. + try { + $subName = $sub.Name + $nextLink = "/subscriptions/$($sub.Id)/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01" + while ($nextLink) { + $resp = Invoke-AzRestMethodWithRetry -Path $nextLink -Method GET + if ($resp.StatusCode -ne 200) { + [void]$subFailures.Add("$($sub.Name): HTTP $($resp.StatusCode)") + break + } + $body = $resp.Content | ConvertFrom-Json + if ($null -eq $body -or $body.value -isnot [array]) { + throw 'The policy assignment response has no valid value collection.' + } + foreach ($a in $body.value) { + if ([string]::IsNullOrWhiteSpace([string]$a.id) -or [string]::IsNullOrWhiteSpace([string]$a.properties.policyDefinitionId)) { + throw 'A policy assignment has no resource ID or policy definition ID.' + } + # De-duplicate (same MG assignment appears under each sub) + if ($seenIds.ContainsKey($a.id)) { continue } + $seenIds[$a.id] = $true + + $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 } + } + } + catch { + [void]$subFailures.Add("$($sub.Name): $($_.Exception.Message)") + } + } + + if ($subFailures.Count -gt 0) { + Write-Warning " Policy assignments could not be read for $($subFailures.Count) of $subCount subscription(s); the inventory below is partial." + foreach ($f in ($subFailures | Select-Object -First 3)) { Write-Verbose " $f" } + } + + if ($subFailures.Count -eq 0 -or $allAssignments.Count -gt 0) { + $gotAssignments = $true + Write-Host " ARM REST API: $($allAssignments.Count) unique policy assignments (including inherited)" -ForegroundColor Green + } + } + catch { + [void]$subFailures.Add("ARM REST policy query: $($_.Exception.Message)") + Write-Warning " ARM REST policy query failed: $($_.Exception.Message)" + } + + # 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)" + } + } + + $complianceErrors = [System.Collections.Generic.List[string]]::new() + # -- 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 -All + + 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 (-not $matchSub) { continue } + $subName = $matchSub.Name + foreach ($column in @('Total', 'NonCompliant', 'Compliant')) { + if ((Get-HubCostValue -Row $row -Column $column) -lt 0) { throw 'Policy compliance contains an invalid count.' } + } + + $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 = @($Subscriptions | Where-Object { $complianceMap.ContainsKey([string]$_.Id) }).Count -eq $subCount + Write-Host " Resource Graph compliance: $($complianceMap.Count) subscriptions" -ForegroundColor Green + } + } + catch { + $complianceMap.Clear() + 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 + $complianceMap.Clear() + $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 (-not $compResp -or $compResp.StatusCode -ne 200) { throw "Policy compliance returned HTTP $($compResp.StatusCode)." } + $summary = ($compResp.Content | ConvertFrom-Json -ErrorAction Stop).value + if ($summary -isnot [array] -or $summary.Count -gt 1) { throw 'Policy compliance returned an invalid summary.' } + $total = 0.0 + $compliant = 0.0 + $nonCompliant = 0.0 + $policyCount = $null + if ($summary.Count -eq 1) { + $summaryResult = $summary[0].results + if ($summaryResult.resourceDetails -isnot [array]) { throw 'Policy compliance has no resource counts.' } + foreach ($detail in $summaryResult.resourceDetails) { + $count = Get-HubCostValue -Row $detail -Column 'count' + if ($count -lt 0) { throw 'Policy compliance contains a negative count.' } + $total += $count + if ($detail.complianceState -eq 'compliant') { $compliant += $count } + elseif ($detail.complianceState -eq 'noncompliant') { $nonCompliant += $count } + } + $policyCount = ($summaryResult.policyDetails | ForEach-Object { $_.count } | Measure-Object -Sum).Sum + } + $complianceMap[$sub.Id] = [PSCustomObject]@{ + Subscription = $sub.Name; SubscriptionId = $sub.Id; TotalResources = $total + NonCompliant = $nonCompliant; Compliant = $compliant; PolicyCount = $policyCount + } + } + catch { + [void]$complianceErrors.Add("$($sub.Name): $($_.Exception.Message)") + 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 resource ID ----------------------- + $seen = @{} + $unique = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($a in $allAssignments) { + $key = [string]$a.AssignmentId + if ([string]::IsNullOrWhiteSpace($key)) { + [void]$subFailures.Add('An assignment has no resource ID; assignment coverage is incomplete.') + [void]$unique.Add($a) + continue + } + if (-not $seen.ContainsKey($key)) { + $seen[$key] = $true + [void]$unique.Add($a) + } + } + + $scopeNames = @{} + $scopeNameErrors = [System.Collections.Generic.List[string]]::new() + foreach ($subscription in $Subscriptions) { + if ($subscription.Id -and $subscription.Name) { $scopeNames["/subscriptions/$($subscription.Id)"] = [string]$subscription.Name } + } + foreach ($assignment in $unique) { + $scopeId = ([string]$assignment.Scope).TrimEnd('/') + if (-not $scopeNames.ContainsKey($scopeId)) { + $scopeNames[$scopeId] = [string]$assignment.Scope + if ($scopeId -match '^/subscriptions/([^/]+)/resourceGroups/([^/]+)$' -and $scopeNames.ContainsKey("/subscriptions/$($Matches[1])")) { + $scopeNames[$scopeId] = "$($scopeNames["/subscriptions/$($Matches[1])"]) / $($Matches[2])" + } + elseif ($TenantId -and $scopeId -match '^/providers/Microsoft\.Management/managementGroups/[A-Za-z0-9._()\-]+$') { + try { + $scopeResponse = Invoke-AzRestMethodWithRetry -Path "$($scopeId)?api-version=2020-05-01" -Method GET + if (-not $scopeResponse -or $scopeResponse.StatusCode -ne 200) { throw "HTTP $($scopeResponse.StatusCode)." } + $scopeBody = $scopeResponse.Content | ConvertFrom-Json -ErrorAction Stop + if ($scopeBody.id -ne $scopeId -or $scopeBody.properties.tenantId -ne $TenantId) { throw 'The returned management group does not match the requested scope and tenant.' } + if ([string]::IsNullOrWhiteSpace([string]$scopeBody.properties.displayName)) { throw 'The management group display name is missing.' } + $scopeNames[$scopeId] = [string]$scopeBody.properties.displayName + } + catch { + $message = "Policy scope name unavailable for '$scopeId': $($_.Exception.Message) The scope ID is retained." + [void]$scopeNameErrors.Add($message) + Write-Warning $message + } + } + } + $assignment | Add-Member -NotePropertyName ScopeDisplayName -NotePropertyValue $scopeNames[$scopeId] + } + + # -- Resolve effects the assignment did not override --------------- + $definitionErrors = [System.Collections.Generic.List[string]]::new() + if ($unique.Count -gt 0) { + # Only single policies need a definition lookup; initiatives resolve to 'varies'. + $needsLookup = @($unique | + Where-Object { $_.Origin -ne 'Initiative' -and (-not $_.Effect -or $_.Effect -eq '-') } | + ForEach-Object { $_.PolicyDefId }) + $defMap = if ($needsLookup.Count -gt 0) { Get-PolicyDefinitionMap -DefinitionIds $needsLookup -ReadErrors $definitionErrors } else { @{} } + + foreach ($a in $unique) { + $override = if ($a.Effect -and $a.Effect -ne '-') { $a.Effect } else { '' } + $a.Effect = Resolve-PolicyEffect -AssignmentEffect $override -Definition $defMap[[string]$a.PolicyDefId] -IsInitiative:($a.Origin -eq 'Initiative') + } + $resolved = @($unique | Where-Object { $_.Effect -ne '-' }).Count + Write-Host " Effects resolved for $resolved of $($unique.Count) assignments." -ForegroundColor Green + } + + # -- Compliance totals --------------------------------------------- + $totalCompliant = 0 + $totalNonCompliant = 0 + foreach ($c in $complianceMap.Values) { + $totalCompliant += $c.Compliant + $totalNonCompliant += $c.NonCompliant + } + $totalEvaluated = $totalCompliant + $totalNonCompliant + $complianceIncomplete = $complianceErrors.Count -gt 0 -or @($Subscriptions | Where-Object { -not $complianceMap.ContainsKey([string]$_.Id) }).Count -gt 0 + $compliancePct = if (-not $complianceIncomplete -and $totalEvaluated -gt 0) { [math]::Round(($totalCompliant / $totalEvaluated) * 100, 1) } else { $null } + + return [PSCustomObject]@{ + Assignments = $unique + AssignmentCount = $unique.Count + CoverageIncomplete = ($subFailures.Count -gt 0) + AssignmentErrors = $subFailures.ToArray() + DefinitionCoverageIncomplete = ($definitionErrors.Count -gt 0) + DefinitionErrors = $definitionErrors.ToArray() + ScopeNameErrors = $scopeNameErrors.ToArray() + Note = (@( + if ($subFailures.Count -gt 0) { 'Some effective policy assignments could not be read. Missing assignments cannot be determined from this inventory.' } + if ($complianceIncomplete) { 'Policy compliance coverage is incomplete. Partial results do not establish a percentage for the selected scope.' } + if ($definitionErrors.Count -gt 0) { 'Policy definition coverage is incomplete. Some effects could not be resolved because their definitions could not be read.' } + ) -join ' ') + ComplianceCoverageIncomplete = $complianceIncomplete + ComplianceErrors = $complianceErrors.ToArray() + ComplianceBySubMap = $complianceMap + CompliancePct = $compliancePct + TotalCompliant = $totalCompliant + TotalNonCompliant = $totalNonCompliant + TotalEvaluated = $totalEvaluated + HasComplianceData = (-not $complianceIncomplete -and $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..2a2a5e5d6 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-PolicyRecommendations.ps1 @@ -0,0 +1,296 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-POLICYRECOMMENDATIONS.PS1 +# AZURE FINOPS MULTITOOL - FinOps Policy Recommendations +########################################################################### +# 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)] + [AllowEmptyCollection()] + [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 = 'Storage accounts should be limited by allowed 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/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 = @{} + $initiativeCache = @{} + $initiativeErrors = [System.Collections.Generic.List[object]]::new() + $scopePattern = '(?:(?:/subscriptions/[0-9a-fA-F-]{36})|(?:/providers/Microsoft\.Management/managementGroups/[A-Za-z0-9._()-]+))?' + $initiativePattern = "^$scopePattern/providers/Microsoft\.Authorization/policySetDefinitions/[A-Za-z0-9._()-]+$" + $policyPattern = "^$scopePattern/providers/Microsoft\.Authorization/policyDefinitions/[A-Za-z0-9._()-]+(?:/versions/[0-9.]+)?$" + foreach ($assignment in $ExistingAssignments) { + $definitionId = ([string]$assignment.PolicyDefId).TrimEnd('/') + if (-not $definitionId) { continue } + $isInitiative = $assignment.Origin -eq 'Initiative' -or $definitionId -match '/policySetDefinitions/' + $memberIds = @($definitionId) + if ($isInitiative) { + if (-not $initiativeCache.ContainsKey($definitionId)) { + try { + if ($definitionId -notmatch $initiativePattern) { throw 'The initiative ID is not a valid policy set definition resource ID.' } + $response = Invoke-AzRestMethodWithRetry -Path "$($definitionId)?api-version=2023-04-01" -Method GET + if (-not $response -or $response.StatusCode -ne 200 -or -not $response.Content) { + throw "Initiative membership returned HTTP $($response.StatusCode)." + } + $properties = ($response.Content | ConvertFrom-Json -ErrorAction Stop).properties + if (-not $properties -or $null -eq $properties.policyDefinitions -or $properties.policyDefinitions -isnot [array]) { + throw 'The initiative response has no valid policyDefinitions collection.' + } + $members = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($member in $properties.policyDefinitions) { + $memberId = ([string]$member.policyDefinitionId).TrimEnd('/') + if ($memberId -notmatch $policyPattern) { throw 'An initiative member has an invalid policy definition ID.' } + [void]$members.Add(($memberId -replace '/versions/[0-9.]+$', '')) + } + $initiativeCache[$definitionId] = @($members) + } + catch { + $initiativeCache[$definitionId] = @() + [void]$initiativeErrors.Add([pscustomobject]@{ InitiativeId = $definitionId; Error = $_.Exception.Message }) + } + } + $memberIds = @($initiativeCache[$definitionId]) + } + foreach ($memberId in $memberIds) { + $policyId = $memberId -replace '/versions/[0-9.]+$', '' + if (-not $existingDefIds.ContainsKey($policyId)) { $existingDefIds[$policyId] = [System.Collections.Generic.List[object]]::new() } + [void]$existingDefIds[$policyId].Add([pscustomobject]@{ + AssignmentId = $assignment.AssignmentId + AssignmentName = $assignment.AssignmentName + Scope = $assignment.Scope + ScopeDisplayName = $assignment.ScopeDisplayName + EnforcementMode = $assignment.EnforcementMode + Source = if ($isInitiative) { 'Initiative' } else { 'Direct' } + InitiativeId = if ($isInitiative) { $definitionId } else { $null } + }) + } + } + + $analysis = foreach ($rec in $recommendedPolicies) { + $matchedAssignments = @($existingDefIds[$rec.PolicyDefId] | Where-Object { $null -ne $_ }) + $status = if (@($matchedAssignments | Where-Object Source -EQ 'Direct').Count -gt 0) { 'Assigned' } + elseif ($matchedAssignments.Count -gt 0) { 'Assigned (Initiative)' } + elseif ($initiativeErrors.Count -gt 0) { 'Unknown' } + else { 'Missing' } + + [PSCustomObject]@{ + DisplayName = $rec.DisplayName + 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 { @() } + MatchedAssignments = $matchedAssignments + Note = if ($status -eq 'Unknown') { 'Initiative membership is incomplete. This policy cannot be confirmed missing.' } + elseif ($status -eq 'Missing') { 'No matching definition ID was found in the supplied assignments or readable initiatives. Equivalent custom policies are not assessed.' } + else { 'Assignment presence does not establish enforcement, parameter settings, exclusions, or compliance. Review the matched assignments.' } + } + } + + $missing = @($analysis | Where-Object { $_.Status -eq 'Missing' }) + $assigned = @($analysis | Where-Object { $_.Status -in @('Assigned', 'Assigned (Initiative)') }) + $unknown = @($analysis | Where-Object Status -EQ 'Unknown') + + return [PSCustomObject]@{ + Analysis = $analysis + Missing = $missing + Assigned = $assigned + Unknown = $unknown + InitiativeErrors = $initiativeErrors.ToArray() + CoverageIncomplete = ($initiativeErrors.Count -gt 0) + CompliancePct = if ($initiativeErrors.Count -eq 0) { [math]::Round(($assigned.Count / $analysis.Count) * 100, 0) } else { $null } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 new file mode 100644 index 000000000..95988a82a --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ReservationAdvice.ps1 @@ -0,0 +1,263 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + +########################################################################### +# GET-RESERVATIONADVICE.PS1 +# AZURE FINOPS MULTITOOL - Reservation & Savings Plan Recommendations +########################################################################### +# 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 { + # The fallback rereads every subscription, so rows read before the failure would count twice. + $allRecommendations.Clear() + 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 -------------------- + # Must be subscription scoped: the bare provider path returns 404 + # InvalidResourceType. 'Shared' scope is only valid at billing-account scope + # and returns 422 here. resourceType defaults to VirtualMachines, so every + # other type has to be requested by name or it is silently absent. + $rrResourceTypes = @( + 'VirtualMachines', 'SQLDatabases', 'PostgreSQL', 'ManagedDisk', 'MySQL', + 'RedHat', 'MariaDB', 'RedisCache', 'CosmosDB', 'SqlDataWarehouse', + 'SUSELinux', 'AppService', 'BlockBlob', 'AzureDataExplorer', 'VMwareCloudSimple' + ) + $reservationRecs = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($sub in $Subscriptions) { + foreach ($rrType in $rrResourceTypes) { + try { + $rrFilter = "properties/scope eq 'Single' and properties/resourceType eq '$rrType' and properties/lookBackPeriod eq 'Last30Days'" + $rrPath = "/subscriptions/$($sub.Id)/providers/Microsoft.Consumption/reservationRecommendations?api-version=2023-05-01&`$filter=$rrFilter" + $rrResp = Invoke-AzRestMethodWithRetry -Path $rrPath -Method GET + if ($rrResp -and $rrResp.StatusCode -in @(401, 403)) { $accessDenied = $true; break } + if (-not $rrResp -or $rrResp.StatusCode -ne 200 -or -not $rrResp.Content) { continue } + $rrResult = ($rrResp.Content | ConvertFrom-Json) + if (-not $rrResult.value) { continue } + + foreach ($item in $rrResult.value) { + $props = $item.properties + # Legacy records carry normalizedSize and no resourceType; modern carry skuName. + $rrSku = if ($props.skuName) { $props.skuName } elseif ($props.normalizedSize) { $props.normalizedSize } else { '-' } + [void]$reservationRecs.Add([PSCustomObject]@{ + Subscription = $sub.Name + ResourceType = if ($props.resourceType) { $props.resourceType } else { $rrType } + SKU = $rrSku + Region = $item.location + RecommendedQty = $props.recommendedQuantity + Term = $props.term + CostWithoutRI = if ($null -ne $props.costWithNoReservedInstances) { [math]::Round($props.costWithNoReservedInstances, 2) } else { $null } + CostWithRI = if ($null -ne $props.totalCostWithReservedInstances) { [math]::Round($props.totalCostWithReservedInstances, 2) } else { $null } + NetSavings = if ($null -ne $props.netSavings) { [math]::Round($props.netSavings, 2) } else { $null } + Scope = $props.scope + LookBackPeriod = $props.lookBackPeriod + FlexGroup = $props.instanceFlexibilityGroup + }) + } + } + catch { + if ("$($_.Exception.Message)" -match '403|Forbidden|Authorization|AuthorizationFailed|access') { $accessDenied = $true } + Write-Verbose "Reservation recommendation query failed for $($sub.Name)/$rrType : $($_.Exception.Message)" + } + } + } + if ($reservationRecs.Count -gt 0) { + Write-Host " Retrieved $($reservationRecs.Count) reservation purchase recommendations." -ForegroundColor Cyan + } + + # -- 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}|{7}' -f ` + $rec.SubscriptionId, $rec.ResourceType, $rec.Term, $rec.SKU, $rec.Region, $rec.Qty, $rec.AnnualSavings, $rec.Currency + 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 ---------------------------------------------- + $savingsSummary = Measure-FinOpsSavingsEstimate -Recommendations @($allRecommendations) + + $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 = $savingsSummary.Total + Currency = $savingsSummary.Currency + CostIssue = $savingsSummary.CostIssue + 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..f79884c84 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-ResourceCosts.ps1 @@ -0,0 +1,457 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-RESOURCECOSTS.PS1 +# AZURE FINOPS MULTITOOL - Per-Resource Cost Breakdown +########################################################################### +# 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. + # ForecastSource records whether a row uses this projection or a Cost Management forecast. + $now = (Get-Date).ToUniversalTime() + $costPeriodEnd = $now.AddTicks(-($now.Ticks % [TimeSpan]::TicksPerSecond)) + $costPeriodStart = $costPeriodEnd.Date.AddDays(1 - $costPeriodEnd.Day) + $queryPeriod = @{ + from = $costPeriodStart.ToString('yyyy-MM-ddTHH:mm:ssZ') + to = $costPeriodEnd.ToString('yyyy-MM-ddTHH:mm:ssZ') + } + $actualPeriod = '{0:yyyy-MM-dd HH:mm} to {1:yyyy-MM-dd HH:mm} UTC (query window)' -f $costPeriodStart, $costPeriodEnd + $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' + } + + $subNameMap = @{} + foreach ($subscription in $Subscriptions) { $subNameMap[[string]$subscription.Id] = [string]$subscription.Name } + + function Get-ResourceCostIdentity { + param([string]$ResourceId, [object]$QuerySubscription) + + $subscriptionId = if ($QuerySubscription) { [string]$QuerySubscription.Id } else { '' } + if ($ResourceId -match '^/subscriptions/([^/]+)(?:/|$)') { $subscriptionId = $Matches[1] } + $subscriptionName = if ($subscriptionId -and $subNameMap.ContainsKey($subscriptionId) -and $subNameMap[$subscriptionId]) { $subNameMap[$subscriptionId] } + elseif ($subscriptionId) { $subscriptionId } + else { 'Not attributed' } + $resourceName = 'No resource ID recorded' + $resourceType = 'Unattributed charge' + if (-not [string]::IsNullOrWhiteSpace($ResourceId)) { + $resourceType = 'Unknown' + $segments = @($ResourceId.TrimEnd('/').Split('/', [StringSplitOptions]::RemoveEmptyEntries)) + $resourceName = $segments[-1] + $providerIndex = -1 + for ($segmentIndex = 0; $segmentIndex -lt $segments.Count; $segmentIndex++) { + if ($segments[$segmentIndex] -eq 'providers') { $providerIndex = $segmentIndex } + } + if ($providerIndex -ge 0 -and $segments.Count -gt ($providerIndex + 2)) { + $typeSegments = @(for ($segmentIndex = $providerIndex + 2; $segmentIndex -lt $segments.Count; $segmentIndex += 2) { $segments[$segmentIndex] }) + $providerType = ($segments[$providerIndex + 1] + '/' + ($typeSegments -join '/')).ToLowerInvariant() + $resourceType = if ($typeMap.ContainsKey($providerType)) { $typeMap[$providerType] } else { $providerType -replace '^microsoft\.', '' } + } + if ($ResourceId -match '(?i)^/providers/Microsoft\.Capacity/reservationOrders/([^/]+)/reservations(?:/([^/]+))?/?$') { + $resourceType = 'Reservation charge' + $resourceName = if ($Matches[2]) { $Matches[2] } else { "Reservation charge (order $($Matches[1]))" } + } + } + [pscustomobject]@{ Subscription = $subscriptionName; SubscriptionId = $subscriptionId; ResourceName = $resourceName; ResourceType = $resourceType } + } + + $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 } + if ($mgScopeId -and -not (Test-CostMgCoverage -ManagementGroupId $mgScopeId -TenantId $TenantId -Subscriptions $Subscriptions)) { $mgScopeId = $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 = 'Custom' + timePeriod = $queryPeriod + 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 + } + + $pageNum = 0 + foreach ($responsePage in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context 'management-group resource costs')) { + $page = $responsePage.Content | ConvertFrom-Json + $pageNum++ + if ($page.properties.rows) { + if ($pageNum -eq 1 -or $pageNum % 3 -eq 0) { + 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 = [string]$row[$cols['ResourceId']] + $rg = $row[$cols['ResourceGroupName']] + + $identity = Get-ResourceCostIdentity -ResourceId $resourceId + + [void]$allRows.Add([PSCustomObject]@{ + Subscription = $identity.Subscription + SubscriptionId = $identity.SubscriptionId + ResourceGroup = $rg + ResourceType = $identity.ResourceType + ResourceName = $identity.ResourceName + ResourcePath = $resourceId + Actual = $cost + ActualPeriod = $actualPeriod + ActualPeriodStart = $costPeriodStart + ActualPeriodEnd = $costPeriodEnd + ActualPeriodSource = 'Query window' + Forecast = [math]::Round($cost * $forecastMult, 2) + ForecastSource = 'Linear projection' + Currency = $currency + }) + } + } + } + + if ($allRows.Count -gt 0) { + $gotMgData = $true + Write-Host " MG scope: $($allRows.Count) resources across $pageNum page(s)" -ForegroundColor Green + + # 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 + $isForecast = if ($null -ne $entry.Value.ForecastSource) { $entry.Value.ForecastSource -eq 'Forecast' } else { $f -gt $a } + if ($a -gt 0 -and $null -ne $f -and $isForecast) { $ratios[$entry.Key.ToLower()] = @{ Ratio = $f / $a; Currency = ([string]$entry.Value.Currency).Trim().ToUpperInvariant() } } + # Forecast spend with no actual cost to apportion by can't be split across resources. + elseif ($null -ne $f -and $isForecast -and $f -ne 0) { $ratios[$entry.Key.ToLower()] = @{ Ratio = $null; Currency = '' } } + } + foreach ($r in $allRows) { + if ($r.ResourcePath -match '/subscriptions/([^/]+)/') { + $sid = $Matches[1].ToLower() + if ($ratios.ContainsKey($sid)) { + $splittable = $null -ne $ratios[$sid].Ratio -and $ratios[$sid].Currency -and $ratios[$sid].Currency -eq ([string]$r.Currency).Trim().ToUpperInvariant() + $r.Forecast = if ($splittable) { [math]::Round($r.Actual * $ratios[$sid].Ratio, 2) } else { $null } + $r.ForecastSource = if ($splittable) { 'Forecast' } else { 'Unavailable' } + } + } + } + } + } + } + else { + if ($resp.StatusCode -in @(401, 403)) { Set-MgCostScopeFailed } + Write-Warning " MG-scope resource cost query returned HTTP $($resp.StatusCode)" + } + } + catch { + $allRows.Clear() + $gotMgData = $false + 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 ---------------------------- + # A list, not a map: unattributed rows share an empty ID, and IDs can differ only by case. + $actualRows = [System.Collections.Generic.List[PSCustomObject]]::new() + try { + Write-Host " Querying resource costs for $($sub.Name)..." -ForegroundColor Cyan + $body = @{ + type = 'ActualCost' + timeframe = 'Custom' + timePeriod = $queryPeriod + 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) + foreach ($responsePage in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context "resource costs for $($sub.Name)")) { + $page = $responsePage.Content | ConvertFrom-Json + if ($page.properties.rows) { + foreach ($row in $page.properties.rows) { + $cost = [math]::Round($row[$cols['Cost']], 2) + $currency = $row[$cols['Currency']] + $resourceId = [string]$row[$cols['ResourceId']] + $rg = $row[$cols['ResourceGroupName']] + + $identity = Get-ResourceCostIdentity -ResourceId $resourceId -QuerySubscription $sub + + [void]$actualRows.Add([PSCustomObject]@{ + Subscription = $identity.Subscription + SubscriptionId = $identity.SubscriptionId + ResourceGroup = $rg + ResourceType = $identity.ResourceType + ResourceName = $identity.ResourceName + ResourcePath = $resourceId + Actual = $cost + ActualPeriod = $actualPeriod + ActualPeriodStart = $costPeriodStart + ActualPeriodEnd = $costPeriodEnd + ActualPeriodSource = 'Query window' + Forecast = [math]::Round($cost * $forecastMult, 2) + ForecastSource = 'Linear projection' + Currency = $currency + }) + } + } + } + } + else { + throw "Resource cost query returned HTTP $($resp.StatusCode); results are incomplete." + } + } + catch { + throw "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 $actualRows) { $subTotalActual += $entry.Actual } + + $subForecast = $subTotalActual # default: same as actual + $hasForecast = $false + $forecastIssue = $null + $forecastCurrencyMismatch = $false + $forecastUnapportionable = $false + $actualCurrencies = @($actualRows | ForEach-Object { ([string]$_.Currency).Trim().ToUpperInvariant() } | Select-Object -Unique) + + # Use a verified Cost Data forecast when there is one (avoids an extra API call). + # Entries without one fall through to the forecast API. + $cd = if ($CostData -and $CostData.ContainsKey($sub.Id)) { $CostData[$sub.Id] } else { $null } + $cdIsForecast = $null -ne $cd -and $null -ne $cd.Forecast -and $(if ($null -ne $cd.ForecastSource) { $cd.ForecastSource -eq 'Forecast' } else { $cd.Forecast -gt $cd.Actual }) + if ($cdIsForecast -and $cd.Actual -gt 0) { + $cdCurrency = ([string]$cd.Currency).Trim().ToUpperInvariant() + if ($cdCurrency -and $actualCurrencies.Count -eq 1 -and $cdCurrency -eq [string]$actualCurrencies[0]) { + $subForecast = $subTotalActual * ($cd.Forecast / $cd.Actual) + $hasForecast = $true + } + else { $forecastCurrencyMismatch = $true } + } + elseif ($cdIsForecast -and $cd.Forecast -ne 0 -and $subTotalActual -le 0) { + # Forecast spend with no actual cost to apportion by can't be split across resources. + $forecastUnapportionable = $true + } + elseif (-not $skipForecast) { + # Only call the forecast API for small tenants without a usable Cost Data forecast + try { + $now = (Get-Date).ToUniversalTime() + $monthEnd = (Get-Date -Year $now.Year -Month $now.Month -Day 1).AddMonths(1).AddDays(-1) + + $fBody = @{ + type = 'Usage' + timeframe = 'Custom' + timePeriod = @{ + from = $now.AddDays(1 - $now.Day).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 (-not $fResp -or $fResp.StatusCode -ne 200) { + throw "Resource forecast returned HTTP $($fResp.StatusCode); results are incomplete." + } + if ($fResp.StatusCode -eq 200) { + $forecastTotal = 0.0 + $rowCount = 0 + $forecastCurrencies = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($responsePage in (Get-CostQueryResponsePage -FirstResponse $fResp -Payload $fBody -Context "resource forecast for $($sub.Name)")) { + $fResult = $responsePage.Content | ConvertFrom-Json + if ($fResult.properties.rows.Count -eq 0) { continue } + $costIndex = Get-CostColumnIndex -Columns $fResult.properties.columns -Names @('cost', 'pretaxcost', 'costusd') + $currencyIndex = Get-CostColumnIndex -Columns $fResult.properties.columns -Names @('currency') + if ($costIndex -lt 0 -or $currencyIndex -lt 0) { throw 'Forecast response did not expose the expected Cost and Currency columns.' } + foreach ($row in $fResult.properties.rows) { + $forecastTotal += [double]$row[$costIndex] + [void]$forecastCurrencies.Add(([string]$row[$currencyIndex]).Trim().ToUpperInvariant()) + $rowCount++ + } + } + if ($rowCount -gt 0) { + $subForecast = [math]::Round($forecastTotal, 2) + $hasForecast = $true + # Costs aren't converted, so a forecast in another currency can't scale these resources. + $forecastCurrencyMismatch = $forecastCurrencies.Count -ne 1 -or $actualCurrencies.Count -ne 1 -or -not $forecastCurrencies.Contains([string]$actualCurrencies[0]) + } + else { + throw 'Resource forecast returned no rows; results are incomplete.' + } + } + } + catch { + $forecastIssue = "Resource forecasts for $($sub.Name) are unavailable; actual costs are kept. $($_.Exception.Message)" + Write-Warning $forecastIssue + } + } + + # Apply forecast ratio proportionally to each resource + if ($forecastIssue -or $forecastCurrencyMismatch -or $forecastUnapportionable -or ($hasForecast -and $subTotalActual -le 0 -and $subForecast -ne 0)) { + # A failed forecast, one in another currency, or one without actual cost to apportion by can't be split across resources. + foreach ($entry in $actualRows) { + $entry.Forecast = $null + $entry.ForecastSource = 'Unavailable' + # Reports show CostIssue as limited data, so a failed request stays visible. + if ($forecastIssue) { $entry | Add-Member -NotePropertyName CostIssue -NotePropertyValue $forecastIssue } + } + } + elseif ($subTotalActual -gt 0 -and $hasForecast) { + $ratio = $subForecast / $subTotalActual + foreach ($entry in $actualRows) { + $entry.Forecast = [math]::Round($entry.Actual * $ratio, 2) + $entry.ForecastSource = 'Forecast' + } + } + + # Collect rows from this sub + foreach ($entry in $actualRows) { + [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..997d47154 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SavingsRealized.ps1 @@ -0,0 +1,435 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Read-only: builds in-memory objects and changes no state.')] +param() + +########################################################################### +# GET-SAVINGSREALIZED.PS1 +# AZURE FINOPS MULTITOOL - Estimated Savings from Commitments +########################################################################### +# Purpose: Estimate how much existing RIs, Savings Plans, and AHB are saving +# versus pay-as-you-go. RI and savings plan figures apply an assumed +# effective discount rate, so they are an estimate rather than +# measured savings - see EstimateBasis on the result. +########################################################################### + +function Get-SavingsRealized { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$Subscriptions, + + [Parameter()] + [string]$TenantId, + + [Parameter()] + [object]$CommitmentData + ) + + Write-Host " Estimating savings from commitments..." -ForegroundColor Cyan + + $riSavings = 0 + $spSavings = 0 + $ahbSavings = 0 + $periodEndUtc = (Get-Date).ToUniversalTime() + $periodStartUtc = $periodEndUtc.Date.AddDays(1 - $periodEndUtc.Day) + $periodStart = $periodStartUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + $periodEnd = $periodEndUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + $period = "$periodStart to $periodEnd" + $currencies = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $monetaryCurrencies = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($culture in [System.Globalization.CultureInfo]::GetCultures([System.Globalization.CultureTypes]::SpecificCultures)) { + try { + $region = [System.Globalization.RegionInfo]::new($culture.Name) + if ($region.ISOCurrencySymbol -notin @('XXX', 'XTS')) { [void]$monetaryCurrencies.Add($region.ISOCurrencySymbol) } + } + catch [System.ArgumentException] { Write-Verbose "No currency metadata for culture $($culture.Name)." } + } + $ahbIssue = $null + + # Assumed effective discount versus pay-as-you-go. Real discounts vary by + # SKU, term, region, and agreement, so the RI/SP numbers below are an + # estimate rather than measured savings. + $riDiscountRate = 0.40 + $spDiscountRate = 0.25 + + # Savings is the gap up to the PAYG price, not a share of what was paid: + # payg = paid / (1 - d) + # savings = payg - paid = paid * d / (1 - d) + # At a 40% discount, $100 paid implies $66.67 saved, not $40. + $script:FinOpsRiSavingsFactor = $riDiscountRate / (1 - $riDiscountRate) + $script:FinOpsSpSavingsFactor = $spDiscountRate / (1 - $spDiscountRate) + # Amortized cost split by pricing model, used for commitment COVERAGE + # (how much of eligible spend rides on a commitment) - distinct from the + # savings amounts above. Spot is excluded from the eligible base because it + # 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, [hashtable]$SubscriptionFilter) + $query = @{ + type = $Type + timeframe = 'Custom' + timePeriod = @{ from = $periodStart; to = $periodEnd } + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + grouping = @($Dimensions | ForEach-Object { @{ type = 'Dimension'; name = $_ } }) + } + } + $filters = @() + if ($Type -eq 'AmortizedCost') { + $filters += @{ dimensions = @{ name = 'ChargeType'; operator = 'In'; values = @('Usage') } } + } + if ($SubscriptionFilter) { $filters += $SubscriptionFilter } + if ($filters.Count -eq 1) { $query.dataset.filter = $filters[0] } + elseif ($filters.Count -gt 1) { $query.dataset.filter = @{ and = $filters } } + $query | ConvertTo-Json -Depth 10 + } + + # Resolve named column indices from a Cost Management query result + function Get-SavingsColMap { + param($Columns) + $map = @{ Cost = 0; ChargeType = -1; PricingModel = -1; SubscriptionId = -1; Currency = -1 } + for ($c = 0; $c -lt $Columns.Count; $c++) { + switch ($Columns[$c].name) { + 'Cost' { $map.Cost = $c } + 'ChargeType' { $map.ChargeType = $c } + 'PricingModel' { $map.PricingModel = $c } + 'SubscriptionId' { $map.SubscriptionId = $c } + 'Currency' { $map.Currency = $c } + } + } + $map + } + + function Assert-SavingsCurrency { + param($Row, $Columns) + if ($Columns.Currency -lt 0) { throw 'Savings currency is missing; results are incomplete.' } + $currency = [string]$Row[$Columns.Currency] + if ($currency -notmatch '^[A-Za-z]{3}$' -or -not $monetaryCurrencies.Contains($currency)) { + throw 'Savings currency is missing or is not a recognized monetary currency; results are incomplete.' + } + $currency = $currency.ToUpperInvariant() + [void]$currencies.Add($currency) + if ($currencies.Count -gt 1) { throw 'Savings include multiple billing currencies. Scan each currency separately; no currency conversion is applied.' } + return $currency + } + + # Parse an ActualCost result for UnusedReservation waste; returns detail rows + function Read-SavingsActual { + param($Result) + $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) { + # A management-group response can include subscriptions outside the selection. + if ($m.SubscriptionId -ge 0 -and -not $subNameById.ContainsKey([string]$row[$m.SubscriptionId])) { continue } + $currency = Assert-SavingsCurrency -Row $row -Columns $m + $charge = if ($m.ChargeType -ge 0) { [string]$row[$m.ChargeType] } else { '' } + if ($charge -match 'UnusedReservation') { + $sub = 'All (MG scope)' + 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' + Currency = $currency + Period = $period + }) + } + } + 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) { + if ($m.SubscriptionId -ge 0 -and -not $subNameById.ContainsKey([string]$row[$m.SubscriptionId])) { continue } + $currency = Assert-SavingsCurrency -Row $row -Columns $m + $pm = if ($m.PricingModel -ge 0) { [string]$row[$m.PricingModel] } else { '' } + if ([double]$row[$m.Cost] -lt 0) { + throw 'Savings usage costs include negative adjustments; a comparable estimate is unavailable.' + } + $cost = [math]::Round([double]$row[$m.Cost], 2) + $sub = 'All (MG scope)' + if ($m.SubscriptionId -ge 0) { + $sid = [string]$row[$m.SubscriptionId] + $sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid } + } + if ($pm -match 'Reservation') { + $ri += $cost * $script:FinOpsRiSavingsFactor + $committed += $cost + $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Reservation Benefit'; Amount = $cost; Type = 'Commitment'; Currency = $currency; Period = $period }) + } + elseif ($pm -match 'SavingsPlan') { + $sp += $cost * $script:FinOpsSpSavingsFactor + $committed += $cost + $rows.Add([PSCustomObject]@{ Subscription = $sub; Category = 'Savings Plan Benefit'; Amount = $cost; Type = 'Commitment'; Currency = $currency; Period = $period }) + } + 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" + + $actualBody = New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('ChargeType') + $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $actualBody + if (-not $actualResp -or $actualResp.StatusCode -ne 200) { + throw "Savings charge query returned HTTP $($actualResp.StatusCode); results are incomplete." + } + if ($actualResp.StatusCode -eq 200) { + $actualResult = Get-CostQueryResult -FirstResponse $actualResp -Payload $actualBody -Context "savings charges for $($only.Name)" + foreach ($d in (Read-SavingsActual -Result $actualResult)) { + $d.Subscription = $only.Name; [void]$details.Add($d) + } + } + + $amortBody = New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('PricingModel') + $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $amortBody + if (-not $amortResp -or $amortResp.StatusCode -ne 200) { + throw "Savings benefit query returned HTTP $($amortResp.StatusCode); results are incomplete." + } + if ($amortResp.StatusCode -eq 200) { + $amortResult = Get-CostQueryResult -FirstResponse $amortResp -Payload $amortBody -Context "savings benefits for $($only.Name)" + $parsed = Read-SavingsAmort -Result $amortResult + foreach ($d in $parsed.Rows) { $d.Subscription = $only.Name; [void]$details.Add($d) } + $riSavings += $parsed.RI + $spSavings += $parsed.SP + $committedAmort += $parsed.Committed + $onDemandAmort += $parsed.OnDemand + $spotAmort += $parsed.Spot + } + + $gotMgData = $true + Write-Host " Single-subscription savings calculated" -ForegroundColor Green + } + catch { + throw "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 -and -not (Test-CostMgCoverage -ManagementGroupId $mgScopeId -TenantId $TenantId -Subscriptions $Subscriptions)) { $mgScopeId = $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" + $subFilter = Get-CostSubscriptionFilter -Subscriptions $Subscriptions + + $actualBody = New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('SubscriptionId', 'ChargeType') -SubscriptionFilter $subFilter + $actualResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $actualBody + if ($actualResp.StatusCode -in @(401, 403)) { + Set-MgCostScopeFailed + throw "MG-scope savings query returned HTTP $($actualResp.StatusCode)" + } + if (-not $actualResp -or $actualResp.StatusCode -ne 200) { + throw "MG-scope savings charge query returned HTTP $($actualResp.StatusCode); results are incomplete." + } + if ($actualResp.StatusCode -eq 200) { + $actualResult = Get-CostQueryResult -FirstResponse $actualResp -Payload $actualBody -Context 'management-group savings charges' + foreach ($d in (Read-SavingsActual -Result $actualResult)) { [void]$details.Add($d) } + } + + $amortBody = New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('SubscriptionId', 'PricingModel') -SubscriptionFilter $subFilter + $amortResp = Invoke-AzRestMethodWithRetry -Path $mgPath -Method POST -Payload $amortBody + if (-not $amortResp -or $amortResp.StatusCode -ne 200) { + throw "MG-scope savings benefit query returned HTTP $($amortResp.StatusCode); results are incomplete." + } + if ($amortResp.StatusCode -eq 200) { + $amortResult = Get-CostQueryResult -FirstResponse $amortResp -Payload $amortBody -Context 'management-group savings benefits' + $parsed = Read-SavingsAmort -Result $amortResult + foreach ($d in $parsed.Rows) { [void]$details.Add($d) } + $riSavings += $parsed.RI + $spSavings += $parsed.SP + $committedAmort += $parsed.Committed + $onDemandAmort += $parsed.OnDemand + $spotAmort += $parsed.Spot + } + + $gotMgData = $true + Write-Host " MG scope savings calculated" -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) { + $details.Clear() + $currencies.Clear() + $riSavings = 0.0 + $spSavings = 0.0 + $committedAmort = 0.0 + $onDemandAmort = 0.0 + $spotAmort = 0.0 + $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 { + $actualBody = New-SavingsQueryBody -Type 'ActualCost' -Dimensions @('ChargeType') + + $subPath = "/subscriptions/$($sub.Id)/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $actualResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $actualBody + if (-not $actualResp -or $actualResp.StatusCode -ne 200) { + throw "Savings charge retry returned HTTP $($actualResp.StatusCode); results are incomplete." + } + + if ($actualResp.StatusCode -eq 200) { + $actualResult = Get-CostQueryResult -FirstResponse $actualResp -Payload $actualBody -Context "savings charges for $($sub.Name)" + foreach ($detail in (Read-SavingsActual -Result $actualResult)) { + $detail.Subscription = $sub.Name + [void]$details.Add($detail) + } + } + + $amortBody = New-SavingsQueryBody -Type 'AmortizedCost' -Dimensions @('PricingModel') + + $amortResp = Invoke-AzRestMethodWithRetry -Path $subPath -Method POST -Payload $amortBody + if (-not $amortResp -or $amortResp.StatusCode -ne 200) { + throw "Savings benefit retry returned HTTP $($amortResp.StatusCode); results are incomplete." + } + if ($amortResp.StatusCode -eq 200) { + $amortResult = Get-CostQueryResult -FirstResponse $amortResp -Payload $amortBody -Context "savings benefits for $($sub.Name)" + $parsed = Read-SavingsAmort -Result $amortResult + foreach ($detail in $parsed.Rows) { + $detail.Subscription = $sub.Name + [void]$details.Add($detail) + } + $riSavings += $parsed.RI + $spSavings += $parsed.SP + $committedAmort += $parsed.Committed + $onDemandAmort += $parsed.OnDemand + $spotAmort += $parsed.Spot + } + } + catch { + throw "Savings query failed for $($sub.Name): $($_.Exception.Message)" + } + } + } # end per-sub fallback + + # -- Step 2: Separate 730-hour AHB estimate for the current VM inventory --- + try { + $ahbQuery = @" +resources +| where type =~ 'microsoft.compute/virtualmachines' +| where properties.licenseType == 'Windows_Server' +| project id, vmSize = tostring(properties.hardwareProfile.vmSize), location +"@ + $subIds = $Subscriptions | ForEach-Object { $_.Id } + $ahbResult = Search-AzGraphSafe -Query $ahbQuery -Subscription $subIds -All + $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' + Currency = 'USD' + Period = '730-hour estimate for current VM inventory' + }) + } + } + catch { + $ahbSavings = $null + $ahbIssue = "AHB estimate is unavailable: $($_.Exception.Message)" + Write-Warning " AHB savings query failed: $($_.Exception.Message)" + } + + $currency = if ($currencies.Count -eq 1) { @($currencies)[0] } else { $null } + $commitmentSavings = if ($currency) { [math]::Round($riSavings + $spSavings, 2) } else { $null } + + # Commitment coverage = committed eligible spend / total eligible spend. + # Eligible = everything except Spot (Spot cannot be covered by a commitment). + $eligibleBase = $committedAmort + $onDemandAmort + $commitmentCoverage = if ($eligibleBase -gt 0) { + [math]::Round(100 * $committedAmort / $eligibleBase, 1) + } + else { $null } + + return [PSCustomObject]@{ + RISavingsMonthToDate = if ($currency) { [math]::Round($riSavings, 2) } else { $null } + SPSavingsMonthToDate = if ($currency) { [math]::Round($spSavings, 2) } else { $null } + CommitmentSavingsMonthToDate = $commitmentSavings + Currency = $currency + Period = $period + CostPeriodStartUtc = $periodStartUtc + CostPeriodEndUtc = $periodEndUtc + RISavingsMonthly = $null + SPSavingsMonthly = $null + AHBSavingsMonthly = if ($null -ne $ahbSavings) { [math]::Round($ahbSavings, 2) } else { $null } + AHBCurrency = 'USD' + AHBPeriod = '730-hour estimate for current VM inventory' + AHBIssue = $ahbIssue + TotalMonthly = $null + TotalAnnual = $null + CommittedAmortized = [math]::Round($committedAmort, 2) + OnDemandAmortized = [math]::Round($onDemandAmort, 2) + SpotAmortized = [math]::Round($spotAmort, 2) + CommitmentCoveragePct = $commitmentCoverage + Details = @($details) + IsEstimate = $true + EstimateBasis = "Commitment estimates cover usage charges for $period in the reported billing currency, using assumed $([int]($riDiscountRate * 100))% reservation and $([int]($spDiscountRate * 100))% savings plan discounts. Purchases, refunds, and unused commitment charges are excluded from that estimate. AHB is a separate USD estimate for 730 hours on the current VM inventory, using retail license premiums or a USD 50 per-VM fallback. These amounts are not combined or annualized. Monthly commitment and combined total fields are unavailable; use the month-to-date fields. Validate against matching pay-as-you-go rates and benefit usage before reporting realized savings." + HasData = ($null -ne $commitmentSavings -or $ahbSavings -gt 0 -or $details.Count -gt 0) + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 new file mode 100644 index 000000000..c18438fc8 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-SharedCostAllocation.ps1 @@ -0,0 +1,479 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity across the allocation helper family.')] +param() + +########################################################################### +# GET-SHAREDCOSTALLOCATION.PS1 +# AZURE FINOPS MULTITOOL - Shared Hub Cost Allocation (Showback) +########################################################################### +# 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 1000 -All + if ($null -eq $res) { throw 'Shared resource inventory is incomplete.' } + $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 -All + 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 = $null + $source = 'LiveApi' + + $fromHub = ($HubData -and @($HubData).Count -gt 0) + + if ($fromHub) { + $source = 'FinOpsHub' + $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData -CostBasis 'AmortizedCost' + $costCol = $costSchema.CostColumn + $currency = $costSchema.Currency + foreach ($row in $HubData) { + $rid = [string](Get-HubRowValue -Row $row -Names @('ResourceId', 'x_ResourceId', 'InstanceId') -Props $props) + $cost = Get-HubCostValue -Row $row -Column $costCol + $sub = [string](Get-HubRowValue -Row $row -Names @('SubAccountId', 'SubscriptionId', 'x_SubscriptionId', 'SubscriptionGuid') -Props $props) + $cur = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency') -Props $props) + if ($cur) { $currency = $cur } + + # 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 (-not $bySub.ContainsKey($sub)) { $bySub[$sub] = 0.0 } + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context "shared cost for $sub")) { + $data = $page.Content | ConvertFrom-Json + $cols = @($data.properties.columns.name) + $iCost = [array]::IndexOf($cols, 'Cost') + $iRes = [array]::IndexOf($cols, 'ResourceId') + $iCur = [array]::IndexOf($cols, 'Currency') + if ($data.properties.rows.Count -gt 0 -and ($iCost -lt 0 -or $iRes -lt 0 -or $iCur -lt 0)) { + throw 'Cost, resource, or currency columns are missing; allocation cost coverage is incomplete.' + } + foreach ($row in @($data.properties.rows)) { + $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 } + $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } + $rowCurrency = ([string]$row[$iCur]).Trim().ToUpperInvariant() + if (-not $rowCurrency -or ($currency -and $rowCurrency -ne $currency)) { + throw 'Allocation cost currency is missing or mixed; cost coverage is incomplete.' + } + $currency = $rowCurrency + $bySub[$sub] += $amount + if ($rid) { + if (-not $byResource.ContainsKey($rid)) { $byResource[$rid] = 0.0 } + $byResource[$rid] += $amount + } + } + } + } + catch { + throw "Cost query failed for $sub : $($_.Exception.Message)" + } + } + } + + return [PSCustomObject]@{ + ByResource = $byResource + BySub = $bySub + Currency = $currency + Source = $source + Period = if ($fromHub) { $costSchema.Period } else { 'MonthToDate' } + } +} + +# Whole percentages that sum to exactly 100.00; the rounding residual lands on +# the largest share so the set stays paste-ready for an allocation rule. +function ConvertTo-AllocationPercentage { + param([object[]]$Targets) + + $raw = @(foreach ($t in $Targets) { + if (-not $t.subscriptionId) { continue } + [PSCustomObject]@{ Name = [string]$t.subscriptionId; Value = [double]$t.allocatedShared } + }) + + if ($raw.Count -eq 0) { + return @{ Ok = $false; Error = 'No usable targets: each one needs subscriptionId and allocatedShared.'; Values = @() } + } + + $sum = ($raw | Measure-Object -Property Value -Sum).Sum + if ($sum -le 0) { + return @{ Ok = $false; Error = ('Allocated shares sum to {0}; cannot build percentages.' -f $sum); Values = @() } + } + + $scaled = @(foreach ($r in $raw) { + [PSCustomObject]@{ Name = $r.Name; Percentage = [math]::Round(($r.Value / $sum) * 100, 2) } + }) + + $residual = [math]::Round(100 - ($scaled | Measure-Object -Property Percentage -Sum).Sum, 2) + if ($residual -ne 0) { + $top = $scaled | Sort-Object -Property Percentage -Descending | Select-Object -First 1 + $top.Percentage = [math]::Round($top.Percentage + $residual, 2) + } + + return @{ + Ok = $true + Error = $null + Values = @($scaled | ForEach-Object { @{ name = $_.Name; percentage = $_.Percentage } }) + } +} + +# -- Main: allocate shared cost across spokes ------------------------------ +function Get-SharedCostAllocation { + [CmdletBinding()] + 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.' + } + } + # A repeated subscription ID would inflate $nSpokes and dilute every spoke's fixed share. + $spokeIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + $Spokes = @($Spokes | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $spokeIds.Add($_) }) + if ($Spokes.Count -eq 0) { return [pscustomobject]@{ HasData = $false; Note = 'Provide at least one nonempty spoke subscription ID.' } } + 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 amortized 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 amortized costs (month-to-date). Per-spoke ExpressRoute attribution cannot come from billing - it relies on the weighting key.' + } + + # Ready-to-paste targets for set_cost_allocation_rule: each spoke's share of + # 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-AllocationPercentage -Targets $rtInput + if ($rt.Ok) { + $ruleTargets = @($rt.Values | ForEach-Object { [PSCustomObject]@{ subscriptionId = $_.name; percentage = $_.percentage } }) + $notes += 'RuleTargets is ready to paste straight into set_cost_allocation_rule (targets); percentages already sum to 100.' + } + } + + return [PSCustomObject]@{ + HasData = $true + Period = $maps.Period + 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..ac88b2b66 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-StorageTierAdvice.ps1 @@ -0,0 +1,159 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + +########################################################################### +# GET-STORAGETIERADVICE.PS1 +# AZURE FINOPS MULTITOOL - Storage Tier Optimization +########################################################################### +# 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() + $metricFailures = [System.Collections.Generic.List[string]]::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 id, 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 -All + $hotAccounts = if ($result) { @($result.Data) } else { @() } + Write-Host " Hot-tier storage accounts: $($hotAccounts.Count)" -ForegroundColor Gray + } + catch { + throw "Storage account inventory is incomplete: $($_.Exception.Message)" + } + + # -- 2: For each hot account, check last access metrics --------------- + $armBase = Get-FinOpsArmEndpoint + $token = Get-PlainAccessToken -ResourceUrl $armBase + $headers = @{ 'Authorization' = "Bearer $token"; 'Content-Type' = 'application/json' } + $now = (Get-Date).ToUniversalTime() + $thirtyDaysAgo = $now.AddDays(-30).ToString('yyyy-MM-ddTHH:mm:ssZ') + $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 + # FULL is the only way to get one datapoint for the whole span: + # P30D is not a published timegrain and the API rejects it. + $metricUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=Transactions×pan=$thirtyDaysAgo/$nowStr&aggregation=Total&interval=FULL" + $resp = Invoke-WebRequest -Uri $metricUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -MaximumRedirection 0 -ErrorAction Stop + $metricData = ($resp.Content | ConvertFrom-Json) + + $totalTx = 0.0 + $transactionSamples = 0 + if ($metricData.value -and $metricData.value.Count -gt 0) { + foreach ($ts in $metricData.value[0].timeseries) { + foreach ($dp in $ts.data) { + $value = Get-HubCostValue -Row $dp -Column 'total' + if ($value -lt 0) { throw 'Transactions contain a negative measurement.' } + $totalTx += $value + $transactionSamples++ + } + } + } + if ($transactionSamples -eq 0) { throw 'No transaction measurements were returned for the requested period.' } + + # Also query used capacity. Every recommendation below requires a + # capacity reading, so a silent failure here would suppress the + # recommendation instead of reporting the account as unevaluated. + $capacityUri = "$armBase$scope/blobServices/default/providers/Microsoft.Insights/metrics?api-version=2023-10-01&metricnames=BlobCapacity×pan=$thirtyDaysAgo/$nowStr&aggregation=Average&interval=FULL" + $capResp = Invoke-WebRequest -Uri $capacityUri -Headers $headers -Method Get -UseBasicParsing -TimeoutSec 15 -MaximumRedirection 0 -ErrorAction Stop + $capacityBytes = 0.0 + $capacitySamples = 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) { + $value = Get-HubCostValue -Row $dp -Column 'average' + if ($value -lt 0) { throw 'Capacity contains a negative measurement.' } + if ($value -gt $capacityBytes) { $capacityBytes = $value } + $capacitySamples++ + } + } + } + } + if ($capacitySamples -eq 0) { throw 'No capacity measurements were returned for the requested period.' } + + $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 { + # A classic account with no blob service is expected; a throttled or + # unauthorized call is not. Count both rather than reporting neither. + [void]$metricFailures.Add("$($sa.name): $($_.Exception.Message)") + } + } + + if ($metricFailures.Count -gt 0) { + Write-Warning " Metrics unavailable for $($metricFailures.Count) of $($hotAccounts.Count) storage account(s); those accounts were not evaluated." + foreach ($f in ($metricFailures | Select-Object -First 3)) { Write-Verbose " $f" } + } + + Write-Host " Storage tier recommendations: $($results.Count)" -ForegroundColor Gray + + [PSCustomObject]@{ + Recommendations = @($results) + TotalHotAccounts = $hotAccounts.Count + Count = $results.Count + HasData = ($results.Count -gt 0) + # Evaluated excludes accounts whose metrics could not be read. + EvaluatedAccounts = ($hotAccounts.Count - $metricFailures.Count) + MetricFailures = $metricFailures.Count + MetricFailureDetail = @($metricFailures) + } +} 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..bccc180ae --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagInventory.ps1 @@ -0,0 +1,301 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + +########################################################################### +# GET-TAGINVENTORY.PS1 +# AZURE FINOPS MULTITOOL - Tag Inventory Across the Tenant +########################################################################### +# 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 } + $readErrors = [Collections.Generic.List[string]]::new() + + # -- 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 = @() + $result = Search-AzGraphSafe -Query $tagQuery -Subscription $subIds -First 1000 -All + if ($null -eq $result) { throw 'Tag values could not be read.' } + $allResults = @($result.Data) + + } + catch { + $readErrors.Add("Tag values: $($_.Exception.Message)") + 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 = $null + 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 (-not $countResp -or $countResp.StatusCode -ne 200) { throw "HTTP $($countResp.StatusCode) while reading untagged count." } + $countRows = @(($countResp.Content | ConvertFrom-Json -ErrorAction Stop).data) + if ($countRows.Count -ne 1) { throw 'Untagged count response has no single measured count.' } + $count = Get-HubCostValue -Row $countRows[0] -Column 'UntaggedCount' + if ($count -lt 0 -or $count -ne [math]::Floor($count)) { throw 'Untagged resource count is invalid.' } + $untaggedCount = [long]$count + } + catch { + $readErrors.Add("Untagged count: $($_.Exception.Message)") + 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 id, name, type, resourceGroup, subscriptionId, location +| order by type asc, name asc +"@ + $allUntagged = @() + $udResult = Search-AzGraphSafe -Query $untaggedDetailQuery -Subscription $subIds -First 1000 -All + if ($null -eq $udResult) { throw 'Untagged resource details could not be read.' } + $allUntagged = @($udResult.Data) + if ($null -eq $untaggedCount) { $untaggedCount = $allUntagged.Count } + + 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 { + $readErrors.Add("Untagged details: $($_.Exception.Message)") + 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 = $null + 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 (-not $totalResp -or $totalResp.StatusCode -ne 200) { throw "HTTP $($totalResp.StatusCode) while reading total count." } + $totalRows = @(($totalResp.Content | ConvertFrom-Json -ErrorAction Stop).data) + if ($totalRows.Count -ne 1) { throw 'Total count response has no single measured count.' } + $count = Get-HubCostValue -Row $totalRows[0] -Column 'TotalCount' + if ($count -lt 0 -or $count -ne [math]::Floor($count)) { throw 'Total resource count is invalid.' } + $totalCount = [long]$count + } + catch { + $readErrors.Add("Total count: $($_.Exception.Message)") + 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 ($null -eq $totalCount) { + 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) { + $count = Get-HubCostValue -Row $tcRows[0] -Column 'TotalCount' + if ($count -lt 0 -or $count -ne [math]::Floor($count)) { throw 'Fallback resource count is invalid.' } + $totalCount = [long]$count + } + } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + } + + # Fallback: derive counts from detail data if REST queries failed + if ($null -eq $untaggedCount -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 } + } + # Case-variant keys fold into one hashtable entry, so the same value can + # arrive twice. Merge on an exact match; a different casing is a different value. + $existingValue = $tagNames[$name].Values | Where-Object { [string]$_.Value -ceq [string]$row.tagValue } | Select-Object -First 1 + if ($existingValue) { + $existingValue.ResourceCount += $row.ResourceCount + } + else { + $tagNames[$name].Values += [PSCustomObject]@{ + Value = $row.tagValue + ResourceCount = $row.ResourceCount + ResourceTypes = $row.ResourceTypes + } + } + $tagNames[$name].TotalResources += $row.ResourceCount + } + + # -- 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 = @() + $locResult = Search-AzGraphSafe -Query $locQuery -Subscription $subIds -First 1000 -All + if ($null -eq $locResult) { throw 'Tag locations could not be read.' } + $locResults = @($locResult.Data) + + 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 { + $readErrors.Add("Tag locations: $($_.Exception.Message)") + 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 ($null -eq $totalCount) { + $taggedFromArg = $null + 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 -eq 1) { + $count = Get-HubCostValue -Row $tagCountRows[0] -Column 'TaggedCount' + if ($count -lt 0 -or $count -ne [math]::Floor($count)) { throw 'Tagged resource count is invalid.' } + $taggedFromArg = [long]$count + } + } + } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + if ($null -ne $taggedFromArg -and $null -ne $untaggedCount) { + $totalCount = $taggedFromArg + $untaggedCount + } + } + + if ($null -ne $totalCount -and $null -ne $untaggedCount -and $untaggedCount -gt $totalCount) { + $readErrors.Add('Resource counts disagree; tagging coverage cannot be calculated.') + } + $taggedCount = if ($null -ne $totalCount -and $null -ne $untaggedCount -and $untaggedCount -le $totalCount) { $totalCount - $untaggedCount } else { $null } + $tagCoverage = if ($readErrors.Count -eq 0 -and $null -ne $taggedCount) { + if ($totalCount -gt 0) { [math]::Round(($taggedCount / $totalCount) * 100, 1) } else { 0 } + } + else { $null } + + # -- Case-variant tag keys ------------------------------------------- + # Azure stores tag keys case-preserving but resolves them case-insensitively. + # PowerShell hashtables fold case too, so $tagNames has already merged the + # spellings; only the raw Resource Graph rows still carry them apart. Their + # values stay on separate rows, which is why one value can appear twice. + $caseVariants = @() + $bySpelling = @($allResults) | Group-Object -Property tagName -CaseSensitive + foreach ($fold in ($bySpelling | Group-Object { ([string]$_.Name).ToLowerInvariant() })) { + if ($fold.Count -le 1) { continue } + $variants = @($fold.Group | ForEach-Object { + [PSCustomObject]@{ + Spelling = $_.Name + ResourceCount = (@($_.Group) | Measure-Object -Property ResourceCount -Sum).Sum + } + } | Sort-Object ResourceCount -Descending) + $caseVariants += [PSCustomObject]@{ + TagKey = $fold.Name + VariantCount = $fold.Count + Spellings = @($variants | ForEach-Object { $_.Spelling }) + Variants = $variants + ResourceCount = ($variants | Measure-Object -Property ResourceCount -Sum).Sum + Detail = (($variants | ForEach-Object { "$($_.Spelling) ($($_.ResourceCount))" }) -join ', ') + } + } + $spellingCount = @($bySpelling).Count + if ($caseVariants.Count -gt 0) { + Write-Host " Case-variant tag keys: $($caseVariants.Count) ($spellingCount spellings across $($tagNames.Count) keys)" -ForegroundColor Yellow + } + + return [PSCustomObject]@{ + TagNames = $tagNames + CoverageIncomplete = ($readErrors.Count -gt 0) + ReadErrors = @($readErrors) + Note = if ($readErrors.Count -gt 0) { 'Tag inventory coverage is incomplete. ' + ($readErrors -join ' ') } else { $null } + TagCount = $tagNames.Count + SpellingCount = $spellingCount + CaseVariants = @($caseVariants) + 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..d30176e40 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-TagRecommendations.ps1 @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-TAGRECOMMENDATIONS.PS1 +# AZURE FINOPS MULTITOOL - Tag Recommendations (MS Best Practices) +########################################################################### +# 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 + } + } + + # Every catalog entry is Required, so there is no Recommended tier to report. + $missingRequired = @($analysis | Where-Object { $_.Status -eq 'Missing' -and $_.Priority -eq 'Required' }) + $present = @($analysis | Where-Object { $_.Status -ne 'Missing' }) + + return [PSCustomObject]@{ + Analysis = $analysis + MissingRequired = $missingRequired + Present = $present + CompliancePercent = [math]::Round(($present.Count / $analysis.Count) * 100, 0) + } +} 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..64d6ee3f3 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UnitEconomics.ps1 @@ -0,0 +1,433 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-UNITECONOMICS.PS1 +# AZURE FINOPS MULTITOOL - Unit Economics ($/vCPU, $/GB) +########################################################################### +# 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); an unreadable account makes cost per GB unavailable. +# - 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 { + # Double the quotes for OData, then encode so a percent-encoded quote + # in the input cannot decode back into a literal one. + $safeLocation = [uri]::EscapeDataString($Location.Replace("'", "''")) + $next = "/subscriptions/$SubId/providers/Microsoft.Compute/skus?api-version=2021-07-01&`$filter=location eq '$safeLocation'" + $pages = 0 + while ($next -and $pages -lt 6) { + $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 { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + $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, + [hashtable]$CurrencySeen + ) + $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] + $rowCurrency = if ($row.Count -ge 3) { ([string]$row[2]).Trim().ToUpperInvariant() } else { '' } + if ($rowCurrency -notmatch '^[A-Z]{3}$' -or $rowCurrency -in @('XXX', 'XTS')) { $rowCurrency = 'Unknown' } + Add-CurrencySeen -Seen $CurrencySeen -Currency $rowCurrency + switch -Wildcard ($category) { + 'Virtual Machines*' { $ComputeRef.Value += $amount } + 'Storage*' { $StorageRef.Value += $amount } + default { } + } + } + } + } + catch { + Write-Verbose "Meter cost parse failed: $($_.Exception.Message)" + } + 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. Returns used GB, or $null when any account can't be read: +# a partial capacity total would overstate cost per GB. +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 -All + $accounts = if ($result) { @($result.Data) } else { @() } + } + catch { + throw "Storage account inventory is incomplete: $($_.Exception.Message)" + } + + 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 + $unreadable = [math]::Max(0, $accounts.Count - 500) + foreach ($a in @($accounts | Select-Object -First 500)) { + 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) { $unreadable++; continue } + $json = $resp.Content | ConvertFrom-Json + $measured = $false + 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; $measured = $true } + } + } + # An account with no reported measurement isn't known to be empty. + if (-not $measured) { $unreadable++ } + } + catch { + $unreadable++ + Write-Verbose "Storage capacity read failed: $($_.Exception.Message)" + } + } + if ($unreadable -gt 0) { + Write-Warning " Storage-account used capacity couldn't be read for $unreadable of $($accounts.Count) account(s)." + return $null + } + 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 -All + $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 { + throw "Compute capacity inventory is incomplete: $($_.Exception.Message)" + } + + # -- 2: Provisioned storage (managed disk GB) ------------------------- + $diskGb = 0 + $diskOk = $false + try { + $diskQuery = @" +resources +| where type =~ 'microsoft.compute/disks' +| summarize totalGb = sum(toint(properties.diskSizeGB)) +"@ + $result = Search-AzGraphSafe -Query $diskQuery -Subscription $subIds -First 1000 + if (-not $result) { throw 'Managed disk inventory returned no response.' } + $rows = @($result.Data) + if ($rows.Count -gt 0 -and $null -ne $rows[0].totalGb) { $diskGb = [double]$rows[0].totalGb } + $diskOk = $true + 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 { + throw "Storage account capacity is incomplete: $($_.Exception.Message)" + } + + # Total storage denominator = managed disks + storage-account used capacity. + $totalGb = $diskGb + $blobFileGb + + # -- 3: Amortized cost by meter category (sub-scoped, with fallback) -- + $costPeriodEndUtc = (Get-Date).ToUniversalTime() + $costPeriodStartUtc = $costPeriodEndUtc.Date.AddDays(1 - $costPeriodEndUtc.Day) + $costTimePeriod = @{ from = $costPeriodStartUtc.ToString('yyyy-MM-ddTHH:mm:ssZ'); to = $costPeriodEndUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') } + $computeCost = 0.0 + $storageCost = 0.0 + # A tenant can bill subscriptions in different currencies; keep them all. + $currenciesSeen = @{} + $costOk = $false + $costScope = 'none' + $mgFailed = $false + try { + $mgScopeId = Resolve-CostMgId -TenantId $TenantId + if ($mgScopeId -and -not (Test-CostMgCoverage -ManagementGroupId $mgScopeId -TenantId $TenantId -Subscriptions $Subscriptions)) { $mgScopeId = $null } + 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 = 'Custom' + timePeriod = $costTimePeriod + 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) { + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context 'management-group unit costs')) { + if (Add-MeterCosts -Content $page.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencySeen $currenciesSeen) { + $costOk = $true + $costScope = "mg:$mgScopeId" + } + } + } + else { + $mgFailed = $true + } + } + else { + $mgFailed = $true + } + } + 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) { + $computeCost = 0.0 + $storageCost = 0.0 + $currenciesSeen.Clear() + foreach ($sid in $subIds) { + try { + $body = @{ + type = 'AmortizedCost' + timeframe = 'Custom' + timePeriod = $costTimePeriod + 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 + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $resp -Payload $body -Context "unit costs for $sid")) { + if (Add-MeterCosts -Content $page.Content -ComputeRef ([ref]$computeCost) -StorageRef ([ref]$storageCost) -CurrencySeen $currenciesSeen) { + $costOk = $true + $costScope = 'per-sub' + } + } + } + catch { + throw "Per-sub cost query failed for $sid : $($_.Exception.Message)" + } + } + } + + # -- 4: Derived KPIs -------------------------------------------------- + $currency = Resolve-CurrencyLabel -Seen $currenciesSeen -Fallback 'Unknown' + $costIssue = if ($currenciesSeen.ContainsKey('UNKNOWN') -or $currency -eq 'Unknown') { + 'Cost currency is missing or invalid; combined costs and unit rates are unavailable.' + } + elseif (Test-CurrencyMixed -Seen $currenciesSeen) { + 'Multiple billing currencies cannot be combined; costs, cost shares, and unit rates are unavailable.' + } + else { $null } + $costAvailable = $costOk -and -not $costIssue + $costPerVCpu = if ($costAvailable -and $totalVCpu -gt 0) { $computeCost / $totalVCpu } else { $null } + $costPerVm = if ($costAvailable -and $vmCount -gt 0) { $computeCost / $vmCount } else { $null } + $costPerGb = if ($costAvailable -and $diskOk -and $blobFileOk -and $totalGb -gt 0) { $storageCost / $totalGb } else { $null } + $costPerGbRam = if ($costAvailable -and $totalMemGb -gt 0) { $computeCost / $totalMemGb } else { $null } + + $totalKnown = if ($costAvailable) { $computeCost + $storageCost } else { $null } + $computeSharePct = if ($costAvailable -and $totalKnown -gt 0) { [math]::Round(100 * $computeCost / $totalKnown, 1) } else { $null } + $storageSharePct = if ($costAvailable -and $totalKnown -gt 0) { [math]::Round(100 * $storageCost / $totalKnown, 1) } else { $null } + + $hasData = $costOk -and (($totalVCpu -gt 0) -or ($totalGb -gt 0)) + + # -- 5: Honest diagnostics so $0 is explained, not silent ------------- + $notes = @() + if ($costIssue) { $notes += $costIssue } + if (-not $costOk) { + $notes += 'No cost data returned - check Cost Management Reader at the management-group or subscription scope.' + } + if ($costAvailable -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 $diskOk -or -not $blobFileOk) { + $notes += 'Storage capacity could not be read for every managed disk and storage account, so cost per GB stored is unavailable.' + } + 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 + CostAvailable = $costAvailable + CostIssue = $costIssue + CostPeriodStartUtc = $costPeriodStartUtc + CostPeriodEndUtc = $costPeriodEndUtc + ComputeCost = if ($costAvailable) { [math]::Round($computeCost, 2) } else { $null } + StorageCost = if ($costAvailable) { [math]::Round($storageCost, 2) } else { $null } + 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..f4414ff67 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-UsageProportionalAllocation.ps1 @@ -0,0 +1,337 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; the dispatcher passes -TenantId to every scan module.')] +param() + +########################################################################### +# GET-USAGEPROPORTIONALALLOCATION.PS1 +# TELEMETRY-KEYED SHOWBACK FOR SHARED PLATFORMS (AKS / APIM / AOAI) +########################################################################### +# 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}', (ConvertTo-KqlLiteral $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()] + [ValidatePattern('(?i)^(?!XXX$|XTS$)[A-Z]{3}$')] + [string]$PoolCurrency, + + [Parameter()] + [string]$PoolPeriod, + + [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 = 'Unknown' + $period = 'Unknown' + $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 + $period = $maps.Period + 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' + $currency = if ($PoolCurrency) { $PoolCurrency.ToUpperInvariant() } else { 'Unknown' } + $period = if ($PoolPeriod) { $PoolPeriod } else { 'Unknown' } + } + + 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) + $residual = [math]::Round([math]::Round($poolTotal, 2) - ($allocations | Measure-Object AllocatedCost -Sum).Sum, 2) + if ($residual -gt 0 -and $allocations.Count -gt 0) { + $allocations[0].AllocatedCost = [math]::Round($allocations[0].AllocatedCost + $residual, 2) + } + elseif ($residual -lt 0) { + foreach ($allocation in $allocations) { + $adjustment = [math]::Min($allocation.AllocatedCost, - $residual) + $allocation.AllocatedCost = [math]::Round($allocation.AllocatedCost - $adjustment, 2) + $residual = [math]::Round($residual + $adjustment, 2) + if ($residual -eq 0) { break } + } + } + } + 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 ($source -eq 'Explicit' -and ($currency -eq 'Unknown' -or $period -eq 'Unknown')) { $notes += 'Provide PoolCurrency and PoolPeriod to identify the units of the explicit amount; none are assumed.' } + 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 = $period + 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..f188eb6a0 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/Get-VmCostBreakdown.ps1 @@ -0,0 +1,374 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; the dispatcher passes -TenantId to every scan module.')] +param() + +########################################################################### +# GET-VMCOSTBREAKDOWN.PS1 +# AZURE FINOPS MULTITOOL - Full VM Cost Decomposition +########################################################################### +# 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 +| order by id asc +"@ + + $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 + # Rows are ordered by id, so an ambiguous name resolves to the same VM every run. + $vm = $rows[0] + + $assoc = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + [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 = $null + $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 + $costSchema = Get-HubCostSchema -HubData $HubData -CostBasis 'AmortizedCost' + $costCol = $costSchema.CostColumn + $currency = $costSchema.Currency + + foreach ($row in $HubData) { + $rid = [string](Get-HubRowValue -Row $row -Names @('ResourceId', 'x_ResourceId', 'InstanceId') -Props $props) + if (-not $rid -or -not $vm.Associated.Contains($rid)) { continue } + + $cost = Get-HubCostValue -Row $row -Column $costCol + $qty = [double](Get-HubRowValue -Row $row -Names @('ConsumedQuantity', 'Quantity', 'UsageQuantity') -Props $props) + $cur = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency') -Props $props) + if ($cur) { $currency = $cur } + + $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 = Get-CostQueryResult -FirstResponse $resp -Payload $body -Context "VM costs for $($vm.Name)" + $cols = @($data.properties.columns.name) + $iCost = [array]::IndexOf($cols, 'Cost') + $iQty = [array]::IndexOf($cols, 'UsageQuantity') + $iRes = [array]::IndexOf($cols, 'ResourceId') + $iCat = [array]::IndexOf($cols, 'MeterCategory') + $iCur = [array]::IndexOf($cols, 'Currency') + if ($data.properties.rows.Count -gt 0 -and ($iCost -lt 0 -or $iRes -lt 0 -or $iCur -lt 0)) { + throw 'Cost, resource, or currency columns are missing; VM cost coverage is incomplete.' + } + + foreach ($row in @($data.properties.rows)) { + $amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { 0 } + $qty = if ($iQty -ge 0) { [double]$row[$iQty] } else { 0 } + $rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' } + $mc = if ($iCat -ge 0) { [string]$row[$iCat] } else { '' } + $rowCurrency = ([string]$row[$iCur]).Trim().ToUpperInvariant() + if (-not $rowCurrency -or ($currency -and $rowCurrency -ne $currency)) { + throw 'VM cost currency is missing or mixed; cost coverage is incomplete.' + } + $currency = $rowCurrency + + # Bandwidth often bills with an empty resource ID at sub + # scope - record it as an unattributable caveat. + 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 = if ($fromHub) { $costSchema.Period } else { 'MonthToDate' } + Source = $source + TotalCost = [math]::Round($total, 2) + EgressGb = $egressQty + Breakdown = $breakdown + ResourcesIncluded = @($vm.Associated) + Note = if ($notes.Count -gt 0) { $notes -join ' ' } else { "Amortized VM solution cost for $($costSchema.Period): compute + disks + network + extensions." } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 new file mode 100644 index 000000000..2307ba356 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostExport.ps1 @@ -0,0 +1,1215 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by export schema and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity across the export converter family.')] +param() + +########################################################################### +# GET-COSTEXPORT.PS1 +# COST MANAGEMENT EXPORT DETECTION & FAST READ +########################################################################### +# Purpose: Detect existing Cost Management exports (any export, not just a +# FinOps Hub) and read their CSV data from blob storage so the +# server can serve cost tools from a pre-materialized export +# instead of the throttle-bound live Cost Management query API. +# Date: Created for FinOps Multitool generic export detection +# +# Description: +# Read-only port of the GUI scanner's export module. Supplies the same +# cost data contracts the FinOps Hub fast path provides, but sourced from +# any Cost Management export the caller has read access to: +# 1. Find-CostExport - enumerate exports at each subscription scope +# 2. Get-CostExportData - read the newest run's CSV into normalized rows +# 3. ConvertTo-*FromExport - shape rows into the cost-tool data contracts +# +# Format: CSV only. Parquet exports are detected and reported but not parsed +# natively in PowerShell. +# +# ── Parameters ────────────────────────────────────────────── +# (per-function; see each function below) +# +# Prerequisites: +# - Get-PlainAccessToken + Invoke-AzRestMethodWithRetry helpers loaded +# - Storage Blob Data Reader on the export's storage account for the read +# +# Reference: https://learn.microsoft.com/rest/api/cost-management/exports +########################################################################### + +# -- Blob endpoint suffix for the active cloud ---------------------------- +function Get-ExportBlobSuffix { + param([string]$Environment = 'AzureCloud') + switch ($Environment) { + 'AzureUSGovernment' { 'blob.core.usgovcloudapi.net' } + 'AzureChinaCloud' { 'blob.core.chinacloudapi.cn' } + default { 'blob.core.windows.net' } + } +} + +# -- Storage blob data-plane REST call (list / get) ----------------------- +# Uses an AAD bearer token scoped to storage.azure.com. Returns the raw +# response content (XML for list, CSV text for get) or $null on failure. +function Invoke-StorageBlobRest { + param( + [Parameter(Mandatory)][string]$Uri, + [string]$StorageToken, + [int]$TimeoutSeconds = 60 + ) + $null = Resolve-FinOpsRequestUri -Uri $Uri + if (-not $StorageToken) { + try { $StorageToken = Get-PlainAccessToken -ResourceUrl 'https://storage.azure.com' } + catch { Write-Warning " Could not acquire storage token: $($_.Exception.Message)"; return $null } + } + $headers = @{ + Authorization = "Bearer $StorageToken" + 'x-ms-version' = '2021-08-06' + } + try { + return Invoke-RestMethod -Uri $Uri -Headers $headers -Method GET -TimeoutSec $TimeoutSeconds -MaximumRedirection 0 -ErrorAction Stop + } + catch { + $code = $null + if ($_.Exception.Response) { $code = [int]$_.Exception.Response.StatusCode } + Write-Warning " Storage request failed (HTTP $code): $Uri" + return $null + } +} + +# -- Download a blob's raw bytes (binary-safe) ---------------------------- +# Invoke-RestMethod decodes a response body as text using the content-type +# charset, which corrupts binary payloads - notably '.csv.gz' parts, where the +# mangled bytes can no longer be gunzipped ("unsupported compression method"). +# Use HttpClient to read the exact bytes so gzip and plain CSV both decode. +function Get-StorageBlobBytes { + param( + [Parameter(Mandatory)][string]$Uri, + [string]$StorageToken, + [int]$TimeoutSeconds = 120 + ) + $null = Resolve-FinOpsRequestUri -Uri $Uri + if (-not $StorageToken) { + try { $StorageToken = Get-PlainAccessToken -ResourceUrl 'https://storage.azure.com' } + catch { Write-Warning " Could not acquire storage token: $($_.Exception.Message)"; return $null } + } + $client = $null; $handler = $null; $req = $null; $resp = $null + try { + $handler = [System.Net.Http.HttpClientHandler]::new() + $handler.AllowAutoRedirect = $false + $client = [System.Net.Http.HttpClient]::new($handler) + $client.Timeout = [TimeSpan]::FromSeconds($TimeoutSeconds) + $req = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $Uri) + [void]$req.Headers.TryAddWithoutValidation('Authorization', "Bearer $StorageToken") + [void]$req.Headers.TryAddWithoutValidation('x-ms-version', '2021-08-06') + $resp = $client.SendAsync($req).GetAwaiter().GetResult() + if (-not $resp.IsSuccessStatusCode) { + Write-Warning " Storage blob GET failed (HTTP $([int]$resp.StatusCode)): $Uri" + return $null + } + $data = $resp.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult() + # Unary comma stops PowerShell from unrolling the byte[] into a stream + # of individual bytes (which the caller would receive as an Object[]). + return , $data + } + catch { Write-Warning " Storage blob GET error: $($_.Exception.Message)"; return $null } + finally { + if ($resp) { $resp.Dispose() } + if ($req) { $req.Dispose() } + if ($client) { $client.Dispose() } + elseif ($handler) { $handler.Dispose() } + } +} + +# -- Flat blob listing under a prefix ------------------------------------- +# Lists every blob under $Prefix (no delimiter = recursive). Robust to two +# quirks: (1) Invoke-RestMethod often returns the list XML as a raw string +# (with a UTF-8 BOM) instead of an XmlDocument, so parse defensively; and +# (2) follows NextMarker so large accounts are not silently truncated. +function Get-StorageBlobList { + param( + [Parameter(Mandatory)][string]$BlobBase, + [Parameter(Mandatory)][string]$Container, + [string]$Prefix = '', + [string]$StorageToken + ) + $out = [System.Collections.Generic.List[PSCustomObject]]::new() + $marker = $null + $listed = $false + $markers = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + do { + $listUri = "$BlobBase/$Container`?restype=container&comp=list" + if ($Prefix) { $listUri += "&prefix=$([uri]::EscapeDataString($Prefix))" } + if ($marker) { $listUri += "&marker=$([uri]::EscapeDataString($marker))" } + $resp = Invoke-StorageBlobRest -Uri $listUri -StorageToken $StorageToken + if (-not $resp) { throw 'Export blob listing failed; listing coverage is incomplete.' } + $listed = $true + + # Normalize the response into an XmlDocument. + $doc = $null + if ($resp -is [System.Xml.XmlDocument]) { + $doc = $resp + } + elseif ($resp -is [string]) { + $txt = $resp + $i = $txt.IndexOf('.*?)/(?\d{8}-\d{8})/') + if (-not $m.Success) { + # No date-range folder: treat the directory holding the CSV + # as the export folder so flat layouts still surface. + $dir = [System.IO.Path]::GetDirectoryName($b.Name) -replace '\\', '/' + if (-not $dir) { continue } + $folder = $dir + } + else { $folder = $m.Groups['folder'].Value } + if ([string]::IsNullOrWhiteSpace($folder)) { continue } + if (-not $groups.ContainsKey($folder)) { $groups[$folder] = [System.Collections.Generic.List[PSCustomObject]]::new() } + [void]$groups[$folder].Add($b) + } + + foreach ($folder in $groups.Keys) { + $segs = @($folder.Trim('/') -split '/') + $name = $segs[-1] + $root = if ($segs.Count -gt 1) { ($segs[0..($segs.Count - 2)] -join '/') } else { '' } + + $key = ("$($sa.ResourceId)|$container|$root|$name").ToLowerInvariant() + if ($KnownKeys.ContainsKey($key) -or $seen.ContainsKey($key)) { continue } + $seen[$key] = $true + + $parts = $groups[$folder] + $lastRun = ($parts | ForEach-Object { $_.LastModified } | Where-Object { $_ } | Sort-Object -Descending | Select-Object -First 1) + $partitioned = (@($parts | Where-Object { $_.Name -match '/part_\d+(?:_\d+)?\.csv(?:\.gz)?$' }).Count -gt 1) + + # Infer the cost type from the folder name (best-effort, display only) + $type = if ($name -match 'amortiz') { 'AmortizedCost' } + elseif ($name -match 'actual') { 'ActualCost' } + elseif ($name -match 'focus') { 'FocusCost' } + else { 'Usage' } + + [void]$found.Add([PSCustomObject]@{ + Name = $name + SubId = $sa.SubId + SubName = $sa.SubName + Scope = $sa.ResourceId + ScopeKind = 'Storage' + ScopeLabel = "Storage: $($sa.Name)/$container" + Type = $type + Granularity = 'Daily' + Format = if (@($parts | Where-Object Name -Match '\.parquet$').Count) { 'Parquet' } else { 'Csv' } + Partitioned = $partitioned + StorageResourceId = $sa.ResourceId + Container = $container + RootFolder = $root + LastRunDate = $lastRun + }) + } + } + } + Write-Progress -Id 73 -Activity 'Scanning storage accounts for exports' -Completed + + return $found +} + +# -- Read an export's newest CSV data into normalized rows ---------------- +# Lists blobs under the export's folder, locates the newest run, downloads +# the CSV (or manifest-referenced CSV parts), and returns normalized rows. +function Get-CostExportData { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$Export, + [string]$Environment = 'AzureCloud' + ) + + if ($Export.Format -and $Export.Format -notmatch 'csv') { + Write-Warning " Export '$($Export.Name)' is $($Export.Format) format - CSV required." + return [PSCustomObject]@{ Rows = @(); DataDate = $null; Currency = 'USD'; Unsupported = $true } + } + + # Parse the storage account name from its ARM resource id + if ($Export.StorageResourceId -notmatch '^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.Storage/storageAccounts/([a-z0-9]{3,24})$') { throw 'The selected export has an invalid storage resource ID.' } + $account = $Matches[1] + $suffix = Get-ExportBlobSuffix -Environment $Environment + $blobBase = "https://$account.$suffix" + $container = [string]$Export.Container + if ($container -cnotmatch '^[a-z0-9](?:[a-z0-9-]{1,61})[a-z0-9]\z' -or $container.Contains('--')) { throw 'The selected export has an invalid container name.' } + $root = ([string]$Export.RootFolder).Trim('/') + $exportName = [string]$Export.Name + if ([string]::IsNullOrWhiteSpace($exportName) -or $exportName -match '[/\\\x00-\x1f]' -or $exportName -in @('.', '..') -or + $root -match '(?:^|/)\.{1,2}(?:/|$)|[\\\x00-\x1f]') { throw 'The selected export has an invalid folder path.' } + + $token = $null + try { $token = Get-PlainAccessToken -ResourceUrl 'https://storage.azure.com' } + catch { Write-Warning " Storage token error: $($_.Exception.Message)"; return [PSCustomObject]@{ Rows = @(); DataDate = $null; Currency = 'USD' } } + + $prefix = if ($root) { "$root/$exportName/" } else { "$exportName/" } + $listed = Get-StorageBlobList -BlobBase $blobBase -Container $container -Prefix $prefix -StorageToken $token + if (-not $listed.Listed) { throw 'The selected export folder could not be listed; cost coverage is incomplete.' } + $blobs = @($listed.Blobs | Where-Object { ([string]$_.Name).StartsWith($prefix, [StringComparison]::Ordinal) }) + foreach ($blob in $blobs) { + if ([string]$blob.Name -match '(?:^|/)\.{1,2}(?:/|$)|[\\\p{Cc}\p{Cf}]') { + throw 'The selected export listing contains an unsafe blob path; no export data was downloaded.' + } + } + $csvBlobs = @($blobs | Where-Object { $_.Name -match '\.csv(\.gz)?$' }) + + if ($csvBlobs.Count -eq 0) { + $hasParquet = @($blobs | Where-Object { $_.Name -match '\.parquet$' }).Count -gt 0 + $reason = if ($hasParquet) { 'Export writes Parquet, not CSV. Recreate the export with CSV format.' } + else { "No CSV data blobs found for export '$($Export.Name)' in container '$container'." } + Write-Warning " $reason" + return [PSCustomObject]@{ Rows = @(); DataDate = $null; Currency = 'USD'; Unsupported = $hasParquet; Reason = $reason; NoData = $true } + } + + $newest = ($csvBlobs | Sort-Object LastModified -Descending | Select-Object -First 1) + # A partitioned export writes multiple CSV parts in the same run folder. + # Group by the run folder (everything up to the last '/') of the newest blob. + $runFolder = ($newest.Name -replace '/[^/]+$', '/') + $runParts = @($csvBlobs | Where-Object { ($_.Name -replace '/[^/]+$', '/') -ceq $runFolder }) + if ($runParts.Count -eq 0) { $runParts = @($newest) } + + # Improved exports write manifest.json beside the parts, declaring every + # partition in the run. Without this check a run that is still being written, + # or one whose parts are partly unreadable, would total up as if complete. + $manifestBlob = @($blobs | Where-Object { ([string]$_.Name) -ceq ($runFolder + 'manifest.json') })[0] + # Partitioned and run-ID folders come from exports that always write a manifest. + $requiresManifest = [bool]$Export.Partitioned -or $runFolder -match '/[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/$' -or + @($runParts | Where-Object { $_.Name -match '/part_\d+(?:_\d+)?\.csv(?:\.gz)?$' }).Count -gt 0 + if (-not $manifestBlob -and $requiresManifest) { + throw 'The export run has no manifest.json, so it might still be writing or be incomplete. No export data was read.' + } + if ($manifestBlob) { + $manifestEncoded = (($manifestBlob.Name -split '/' | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/') + $manifestBytes = Get-StorageBlobBytes -Uri "$blobBase/$container/$manifestEncoded" -StorageToken $token + if (-not $manifestBytes) { throw 'The export run manifest could not be read; cost coverage is incomplete.' } + $manifest = $null + try { $manifest = [System.Text.Encoding]::UTF8.GetString($manifestBytes) | ConvertFrom-Json -ErrorAction Stop } + catch { throw 'The export run manifest could not be parsed; cost coverage is incomplete.' } + $declared = @($manifest.blobs) + if ($declared.Count -eq 0) { throw 'The export run manifest lists no partitions; cost coverage is incomplete.' } + $foundNames = [Collections.Generic.HashSet[string]]::new([string[]]@($runParts | ForEach-Object { [string]$_.Name }), [StringComparer]::Ordinal) + $declaredNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $invalidDeclarations = 0 + foreach ($declaration in $declared) { + $declaredName = ([string]$declaration.blobName).TrimStart('/') + if ([string]::IsNullOrWhiteSpace($declaredName) -or -not $declaredNames.Add($declaredName)) { $invalidDeclarations++ } + } + if ($invalidDeclarations -gt 0 -or -not $declaredNames.SetEquals($foundNames)) { + throw "The export run declares $($declared.Count) partition(s) but $($runParts.Count) readable CSV part(s) matched; cost coverage is incomplete." + } + } + elseif ($runParts.Count -gt 1 -and $runFolder -match '/\d{8}-\d{8}/$' -and + @($runParts | Where-Object { ([string]$_.Name).Substring($runFolder.Length) -notmatch ('^' + [regex]::Escape($exportName) + '_[^/]+\.csv(?:\.gz)?$') }).Count -eq 0) { + # Legacy unpartitioned exports add a month-to-date snapshot on each run; only the newest is current. + $runParts = @($newest) + } + + $dataDate = ($runParts | Sort-Object LastModified -Descending | Select-Object -First 1).LastModified + + # Download + parse each CSV part + $rows = [System.Collections.Generic.List[object]]::new() + $colMap = $null + $firstHeader = @() + foreach ($part in $runParts) { + $encodedName = (($part.Name -split '/' | ForEach-Object { [uri]::EscapeDataString($_) }) -join '/') + $blobUri = "$blobBase/$container/$encodedName" + $bytes = Get-StorageBlobBytes -Uri $blobUri -StorageToken $token + if (-not $bytes) { throw "Export part '$($part.Name)' could not be read; cost coverage is incomplete." } + $csvText = $null + if ($part.Name -match '\.gz$') { + $csvText = Expand-GzipText -Content $bytes + } + else { $csvText = [System.Text.Encoding]::UTF8.GetString($bytes) } + if (-not $csvText) { throw "Export part '$($part.Name)' could not be decoded; cost coverage is incomplete." } + + $parsed = @($csvText | ConvertFrom-Csv -ErrorAction Stop) + if ($parsed.Count -eq 0) { throw "Export part '$($part.Name)' contains no cost rows; coverage is unverified." } + if (-not $colMap) { + $firstHeader = @($parsed[0].PSObject.Properties.Name) + $colMap = Resolve-ExportColumns -Header $firstHeader + } + $headerSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$firstHeader, [System.StringComparer]::OrdinalIgnoreCase) + if (-not $headerSet.SetEquals([string[]]$parsed[0].PSObject.Properties.Name)) { + throw 'Export part schemas differ; cost coverage is incomplete.' + } + foreach ($r in $parsed) { [void]$rows.Add($r) } + } + + # Determine currency from the first row that has one + $currency = if ($colMap.Cost -eq 'CostInUSD') { 'USD' } else { $null } + if ($colMap -and $colMap.Currency) { + $c = ($rows | Where-Object { $_.$($colMap.Currency) } | Select-Object -First 1) + if ($c) { $currency = $c.$($colMap.Currency) } + } + + return [PSCustomObject]@{ + Rows = $rows + ColMap = $colMap + DataDate = $dataDate + Currency = $currency + RowCount = $rows.Count + Headers = $firstHeader + NoCostColumn = ($colMap -and -not $colMap.Cost) + NoData = ($rows.Count -eq 0) + CostBasis = if ($Export.ScopeKind -eq 'Storage') { 'Unknown' } else { $Export.Type } + } +} + +# -- Internal: friendly resource type from an ARM resource id ------------- +function Get-ExportResourceType { + param([string]$ResourceId) + if ($ResourceId -match '/providers/([^/]+/[^/]+)/[^/]+$') { + return ($Matches[1] -replace '(?i)microsoft\.', '') + } + return 'Unknown' +} + +# -- Converter: export rows -> Get-CostData costMap ----------------------- +function ConvertTo-CostDataFromExport { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$ExportData, + [Parameter(Mandatory)][object[]]$Subscriptions + ) + $ExportData = Select-CostExportData -ExportData $ExportData -Subscriptions $Subscriptions + $costMap = @{} + $cm = $ExportData.ColMap + if (-not $cm -or -not $cm.Cost) { return $costMap } + + # Map selected-sub GUIDs (lowercased) back to their canonical sub.Id key + $guidToKey = @{} + foreach ($s in $Subscriptions) { + $g = Get-GuidFromString -Value "$($s.Id)" + if ($g) { $guidToKey[$g.ToLower()] = $s.Id } + } + + $skippedRows = 0 + foreach ($r in $ExportData.Rows) { + # SubscriptionId may be a bare GUID (classic) or a /subscriptions/ + # path (FOCUS SubAccountId). Fall back to ResourceId when absent. + $rawSub = if ($cm.SubscriptionId) { "$($r.$($cm.SubscriptionId))" } else { '' } + if ([string]::IsNullOrWhiteSpace($rawSub) -and $cm.ResourceId) { $rawSub = "$($r.$($cm.ResourceId))" } + $g = Get-GuidFromString -Value $rawSub + if (-not $g) { continue } + + # An export is written at its own scope, which is usually the whole billing + # account. A row for a subscription the user did not select is out of scope, + # so skipping it keeps the total matching the requested scope. + if (-not $guidToKey.ContainsKey($g.ToLower())) { $skippedRows++; continue } + $key = $guidToKey[$g.ToLower()] + + $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" + if (-not $costMap.ContainsKey($key)) { + $subscriptionPeriod = $ExportData.PeriodsBySubscription[$g] + $costMap[$key] = @{ + Actual = 0; Forecast = $null; ForecastSource = 'Unavailable'; Currency = $ExportData.Currency + ActualPeriod = $subscriptionPeriod.Period; ActualPeriodStart = $subscriptionPeriod.PeriodStart; ActualPeriodEnd = $subscriptionPeriod.PeriodEnd + } + } + $costMap[$key].Actual += $cost + } + + if ($skippedRows -gt 0) { + Write-Verbose " Export covers a wider scope: ignored $skippedRows row(s) for unselected subscriptions." + } + + foreach ($k in @($costMap.Keys)) { + $costMap[$k].Actual = [math]::Round($costMap[$k].Actual, 2) + } + return $costMap +} + +# -- Converter: export rows -> Get-ResourceCosts rows --------------------- +function ConvertTo-ResourceCostsFromExport { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$ExportData, + [Parameter(Mandatory)][object[]]$Subscriptions + ) + $ExportData = Select-CostExportData -ExportData $ExportData -Subscriptions $Subscriptions + $out = [System.Collections.Generic.List[PSCustomObject]]::new() + $cm = $ExportData.ColMap + if (-not $cm.ResourceId) { throw 'Resource IDs are unavailable for part of this export; resource cost coverage is incomplete.' } + + $subNameMap = @{} + foreach ($s in $Subscriptions) { $subNameMap[$s.Id.ToLower()] = $s.Name } + + $agg = @{} + foreach ($r in $ExportData.Rows) { + $rid = if ($cm.ResourceId) { "$($r.$($cm.ResourceId))".Trim() } else { '' } + $subId = [string]$r.($cm.SubscriptionId) + $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" + # Classic exports can carry a bare instance name, so the subscription has + # to be part of the key or same-named resources would merge across them. + $key = if ($rid) { "$subId|$($rid.ToLower())" } else { "$subId|non-resource charges" } + if (-not $agg.ContainsKey($key)) { + $rg = if ($cm.ResourceGroup) { "$($r.$($cm.ResourceGroup))" } else { '' } + if (-not $rg -and $rid -match '/resourcegroups/([^/]+)/') { $rg = $Matches[1] } + $agg[$key] = @{ + ResourcePath = if ($rid) { $rid } else { '(non-resource charges)' } + ResourceGroup = $rg + ResourceType = if ($rid) { Get-ExportResourceType -ResourceId $rid } else { 'Non-resource charge' } + Subscription = if ($subNameMap.ContainsKey($subId)) { $subNameMap[$subId] } else { $subId } + ActualPeriod = $ExportData.PeriodsBySubscription[$subId].Period + Cost = 0.0 + } + } + $agg[$key].Cost += $cost + } + + foreach ($v in $agg.Values) { + $c = [math]::Round($v.Cost, 2) + [void]$out.Add([PSCustomObject]@{ + Subscription = $v.Subscription + ResourceGroup = $v.ResourceGroup + ResourceType = $v.ResourceType + ResourcePath = $v.ResourcePath + Actual = $c + Forecast = $null + ForecastSource = 'Unavailable' + Currency = $ExportData.Currency + ActualPeriod = $v.ActualPeriod + }) + } + return @($out | Sort-Object Actual -Descending) +} + +# -- Converter: export rows -> Get-CostByTag result ----------------------- +function ConvertTo-CostByTagFromExport { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$ExportData, + [hashtable]$ExistingTags = @{}, + [object[]]$Subscriptions + ) + $ExportData = Select-CostExportData -ExportData $ExportData -Subscriptions $Subscriptions + $cm = $ExportData.ColMap + $results = @{} + if (-not $cm.Tags) { throw 'Tags are unavailable for part of this export; tag cost coverage is incomplete.' } + + # tagKey -> ( tagValue -> cost ) + $byKey = @{} + $keys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($tagKey in $ExistingTags.Keys) { [void]$keys.Add($tagKey) } + $tagRows = [System.Collections.Generic.List[object]]::new() + foreach ($r in $ExportData.Rows) { + $raw = "$($r.$($cm.Tags))" + $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" + $tags = ConvertFrom-ExportTagString -Raw $raw + if ($ExistingTags.Count -eq 0) { foreach ($tagKey in $tags.Keys) { [void]$keys.Add($tagKey) } } + [void]$tagRows.Add(@{ Cost = $cost; Tags = $tags }) + } + foreach ($row in $tagRows) { + foreach ($tk in $keys) { + $tv = if ($row.Tags.ContainsKey($tk)) { if ($row.Tags[$tk]) { $row.Tags[$tk] } else { '(empty)' } } else { '(untagged)' } + if (-not $byKey.ContainsKey($tk)) { $byKey[$tk] = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::Ordinal) } + if (-not $byKey[$tk].ContainsKey($tv)) { $byKey[$tk][$tv] = 0.0 } + $byKey[$tk][$tv] += $row.Cost + } + } + + foreach ($tk in $byKey.Keys) { + $vals = foreach ($tv in $byKey[$tk].Keys) { + [PSCustomObject]@{ + TagValue = $tv + Cost = [math]::Round($byKey[$tk][$tv], 2) + Currency = $ExportData.Currency + } + } + $results[$tk] = @($vals | Sort-Object Cost -Descending) + } + + return [PSCustomObject]@{ + TagsQueried = @($byKey.Keys) + CostByTag = $results + NoTagsFound = ($byKey.Count -eq 0) + UsedTimeframe = 'Export' + } +} + +# -- Converter: export rows -> Get-CostTrend result ----------------------- +# A single export run usually covers the current billing month; trend will +# show whatever months the export's date range contains. +function ConvertTo-CostTrendFromExport { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$ExportData, + [object[]]$Subscriptions + ) + + $ExportData = Select-CostExportData -ExportData $ExportData -Subscriptions $Subscriptions + $cm = $ExportData.ColMap + $months = [System.Collections.Generic.List[PSCustomObject]]::new() + $bySub = @{} + if (-not $cm.Date) { throw 'Dates are unavailable for part of this export; cost trend coverage is incomplete.' } + + $agg = @{} # yyyy-MM -> @{ Cost; Date } + $subAgg = @{} # subId -> ( yyyy-MM -> @{ Cost; Date } ) + foreach ($r in $ExportData.Rows) { + $dt = $null + $rawDate = $r.($cm.Date) + try { + $dt = if ($rawDate -is [datetime]) { $rawDate.ToUniversalTime() } + elseif ([string]$rawDate -match '^\d{8}$') { [datetime]::ParseExact([string]$rawDate, 'yyyyMMdd', [cultureinfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime() } + else { [datetimeoffset]::Parse([string]$rawDate, [cultureinfo]::InvariantCulture, [Globalization.DateTimeStyles]::AssumeUniversal).UtcDateTime } + } + catch { throw 'An export row has an invalid date; cost trend coverage is incomplete.' } + $cost = ConvertTo-ExportAmount "$($r.$($cm.Cost))" + $firstOfMo = $dt.Date.AddDays(1 - $dt.Day) + # Invariant culture keeps the key Gregorian; a Hijri or Buddhist host + # calendar would otherwise split one month across two trend rows. + $key = $dt.ToString('yyyy-MM', [cultureinfo]::InvariantCulture) + + if (-not $agg.ContainsKey($key)) { $agg[$key] = @{ Cost = 0.0; Date = $firstOfMo } } + $agg[$key].Cost += $cost + + if ($cm.SubscriptionId) { + $subId = Get-GuidFromString -Value "$($r.$($cm.SubscriptionId))" + if ($subId) { + if (-not $subAgg.ContainsKey($subId)) { $subAgg[$subId] = @{} } + if (-not $subAgg[$subId].ContainsKey($key)) { $subAgg[$subId][$key] = @{ Cost = 0.0; Date = $firstOfMo } } + $subAgg[$subId][$key].Cost += $cost + } + } + } + + foreach ($entry in $agg.GetEnumerator() | Sort-Object Key) { + [void]$months.Add([PSCustomObject]@{ + Month = $entry.Value.Date.ToString('MMM yyyy') + MonthDate = $entry.Value.Date + Cost = [math]::Round($entry.Value.Cost, 2) + Currency = $ExportData.Currency + }) + } + + foreach ($subId in $subAgg.Keys) { + $list = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($entry in $subAgg[$subId].GetEnumerator() | Sort-Object Key) { + [void]$list.Add([PSCustomObject]@{ + Month = $entry.Value.Date.ToString('MMM yyyy') + MonthDate = $entry.Value.Date + Cost = [math]::Round($entry.Value.Cost, 2) + Currency = $ExportData.Currency + }) + } + $bySub[$subId] = @($list | Sort-Object MonthDate) + } + + $sorted = @($months | Sort-Object MonthDate) + return [PSCustomObject]@{ + Months = $sorted + BySubscription = $bySub + HasData = ($sorted.Count -gt 0) + } +} + +# -- Read + merge the newest CSV data across several exports --------------- +# Reads each export's newest run and concatenates the normalized rows into a +# single ExportData object. When more than one export covers the same +# subscription, only the export with the newest run date for that sub is +# kept, so overlapping exports do not double-count cost. Returns the same +# shape as Get-CostExportData (Rows / ColMap / DataDate / Currency). +function Get-MergedCostExportData { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object[]]$Exports, + [string]$Environment = 'AzureCloud', + [object[]]$Subscriptions + ) + + $bestBySub = @{} + $sourceIndex = 0 + foreach ($exp in $Exports) { + if (-not $exp) { throw 'An export descriptor is missing; cost coverage is incomplete.' } + $sourceIndex++ + $rawData = Get-CostExportData -Export $exp -Environment $Environment + if (-not $rawData) { throw "Export '$($exp.Name)' could not be read; cost coverage is incomplete." } + $data = Select-CostExportData -ExportData $rawData -Subscriptions $Subscriptions -SkipCoverageCheck + $runDate = if ($data.DataDate) { [datetime]$data.DataDate } + elseif ($exp.LastRunDate) { [datetime]$exp.LastRunDate } + else { [datetime]::MinValue } + foreach ($group in ($data.Rows | Group-Object SubscriptionId)) { + $existing = $bestBySub[$group.Name] + if (-not $existing -or $runDate -gt $existing.RunDate) { + $bestBySub[$group.Name] = @{ Rows = @($group.Group); ColMap = $data.ColMap; RunDate = $runDate; SourceIndex = $sourceIndex } + } + } + } + + $allRows = [System.Collections.Generic.List[object]]::new() + $colMap = $null + $dataDate = $null + $usedSources = [System.Collections.Generic.HashSet[int]]::new() + foreach ($data in $bestBySub.Values) { + if (-not $colMap) { $colMap = $data.ColMap.Clone() } + else { + foreach ($column in @($colMap.Keys)) { + if (-not $data.ColMap.ContainsKey($column)) { $colMap.Remove($column) } + } + } + if (-not $dataDate -or $data.RunDate -gt $dataDate) { $dataDate = $data.RunDate } + [void]$usedSources.Add($data.SourceIndex) + foreach ($row in $data.Rows) { [void]$allRows.Add($row) } + } + $merged = [pscustomobject]@{ + Rows = $allRows.ToArray(); ColMap = $colMap; DataDate = $dataDate; ExportCount = $usedSources.Count + NoData = ($allRows.Count -eq 0); CostBasis = 'ActualCost' + } + return Select-CostExportData -ExportData $merged -Subscriptions $Subscriptions +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 new file mode 100644 index 000000000..8ad5ec015 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-CostQueryResponsePage.ps1 @@ -0,0 +1,224 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +param() + +########################################################################### +# GET-COSTQUERYRESPONSEPAGE.PS1 +# COST MANAGEMENT QUERY PAGINATION +########################################################################### +# Purpose: Follow the Cost Management query API's nextLink and return every +# page, so a caller that sums rows sees the whole result set. +# Date: Created for FinOps Multitool +# +# Description: +# The Cost Management query API returns one page at a time. A subscription +# with a large resource footprint therefore reports only its first page +# unless nextLink is followed, and the shortfall looks like lower cost +# rather than like an error. +# +# Returns the raw response objects rather than parsed rows, because callers +# read the payload differently (column-index lookups, row parsers). +# Throws without returning pages if the response chain is incomplete. +# +# ── Parameters ────────────────────────────────────────────── +# FirstResponse The already-issued first-page response +# Context Label used in errors so a failed query is attributable +# Payload Original POST body for cost query and forecast continuations +# MaxPages Bounds a pathological nextLink chain +# +# Prerequisites: +# - Invoke-AzRestMethodWithRetry.ps1 +########################################################################### + +function Resolve-NextLinkPath { + # nextLink comes from the service, so it is not trusted input. A relative or + # malformed value silently yields an empty PathAndQuery rather than throwing, + # and an absolute URL on another host would be rewritten onto the ARM host. + # Accept a rooted relative path, or an absolute https URL on the ARM endpoint + # for the cloud the caller is signed in to. + [CmdletBinding()] + param( + [Parameter()] + [string]$NextLink + ) + + if ([string]::IsNullOrWhiteSpace($NextLink)) { return $null } + $trimmed = $NextLink.Trim() + if ($trimmed.StartsWith('//') -or $trimmed.Contains('\')) { return $null } + if ($trimmed.StartsWith('/')) { return $trimmed } + + $uri = $null + if (-not [System.Uri]::TryCreate($trimmed, [System.UriKind]::Absolute, [ref]$uri)) { return $null } + if ($uri.Scheme -ne 'https') { return $null } + + $armHost = $null + try { $armHost = ([System.Uri](Get-FinOpsArmEndpoint)).Host } catch { $armHost = 'management.azure.com' } + if ($uri.Host -ne $armHost) { return $null } + + return $uri.PathAndQuery +} + +function Get-CostQueryResponsePage { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object]$FirstResponse, + + [Parameter()] + [string]$Context = 'cost query', + + [Parameter()] + [string]$Payload, + + [Parameter()] + [ValidateRange(1, 1000)] + [int]$MaxPages = 50, + + # The Cost Management query API nests nextLink under properties, while + # the Consumption and benefit list APIs return it at the root. + [Parameter()] + [switch]$RootNextLink + ) + + $pages = [System.Collections.Generic.List[object]]::new() + $resp = $FirstResponse + $pageCount = 0 + $firstColumns = $null + $visitedLinks = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + + while ($true) { + $pageCount++ + if (-not $resp -or $resp.StatusCode -ne 200) { + $code = if ($resp) { [string]$resp.StatusCode } else { 'no response' } + throw "$Context : page $pageCount failed ($code); results are incomplete." + } + if ([string]::IsNullOrWhiteSpace($resp.Content)) { + throw "$Context : page $pageCount has no content; results are incomplete." + } + + try { + $parsed = $resp.Content | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "$Context : page $pageCount contains invalid JSON; results are incomplete." + } + if ($RootNextLink) { + if ($parsed.value -isnot [array]) { + throw "$Context : page $pageCount is missing its value array; results are incomplete." + } + $next = $parsed.nextLink + } + else { + if ($parsed.properties.rows -isnot [array] -or $parsed.properties.columns -isnot [array]) { + throw "$Context : page $pageCount is missing query rows or columns; results are incomplete." + } + $pageColumns = ConvertTo-Json -InputObject @($parsed.properties.columns | Select-Object name, type) -Depth 4 -Compress + if ($null -ne $firstColumns -and $pageColumns -ne $firstColumns) { + throw "$Context : columns changed on page $pageCount; results are incomplete." + } + $firstColumns = $pageColumns + $costIndexes = @( + for ($columnIndex = 0; $columnIndex -lt $parsed.properties.columns.Count; $columnIndex++) { + if ($parsed.properties.columns[$columnIndex].name -in @('Cost', 'PreTaxCost', 'CostUSD', 'TotalCost')) { $columnIndex } + } + ) + if ($parsed.properties.rows.Count -gt 0 -and $costIndexes.Count -eq 0) { + throw "$Context : page $pageCount is missing a cost column; results are incomplete." + } + foreach ($row in $parsed.properties.rows) { + if ($row -isnot [array] -or $row.Count -ne $parsed.properties.columns.Count) { + throw "$Context : page $pageCount contains an invalid row; results are incomplete." + } + foreach ($costIndex in $costIndexes) { + $amount = 0.0 + if (-not [double]::TryParse([string]$row[$costIndex], [System.Globalization.NumberStyles]::Float, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$amount) -or + [double]::IsNaN($amount) -or [double]::IsInfinity($amount)) { + throw "$Context : page $pageCount contains an invalid cost; results are incomplete." + } + } + } + $next = $parsed.properties.nextLink + } + + [void]$pages.Add($resp) + if ([string]::IsNullOrWhiteSpace($next)) { break } + + $nextPath = Resolve-NextLinkPath -NextLink $next + if (-not $nextPath) { + throw "$Context : page $pageCount contains an unexpected nextLink; results are incomplete." + } + if (-not $visitedLinks.Add($nextPath)) { + throw "$Context : a continuation link repeated; results are incomplete." + } + + if ($pageCount -ge $MaxPages) { + throw "$Context : stopped after $MaxPages pages; results are incomplete." + } + + if (-not $RootNextLink -and [string]::IsNullOrWhiteSpace($Payload)) { + throw "$Context : the original POST payload is required for pagination; results are incomplete." + } + try { + $resp = if ($RootNextLink) { + Invoke-AzRestMethodWithRetry -Path $nextPath -Method GET + } + else { + Invoke-AzRestMethodWithRetry -Path $nextPath -Method POST -Payload $Payload + } + } + catch { + throw "$Context : continuation request failed; results are incomplete. $($_.Exception.Message)" + } + } + + return $pages +} + +function Get-FinOpsListResult { + [CmdletBinding()] + param( + [Parameter(Mandatory)][AllowNull()][object]$FirstResponse, + [string]$Context = 'resource list' + ) + + $items = [Collections.Generic.List[object]]::new() + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $FirstResponse -RootNextLink -Context $Context)) { + $parsed = $page.Content | ConvertFrom-Json -ErrorAction Stop + foreach ($item in $parsed.value) { $items.Add($item) } + } + return [pscustomobject]@{ value = $items.ToArray(); nextLink = $null } +} + +function Get-CostQueryResult { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [AllowNull()] + [object]$FirstResponse, + + [Parameter(Mandatory)] + [string]$Payload, + + [Parameter()] + [string]$Context = 'cost query' + ) + + $rows = [System.Collections.Generic.List[object]]::new() + $columns = @() + foreach ($page in (Get-CostQueryResponsePage -FirstResponse $FirstResponse -Payload $Payload -Context $Context)) { + $result = $page.Content | ConvertFrom-Json -ErrorAction Stop + $columns = $result.properties.columns + foreach ($row in $result.properties.rows) { [void]$rows.Add($row) } + } + + return [PSCustomObject]@{ + properties = [PSCustomObject]@{ + columns = $columns + rows = $rows.ToArray() + nextLink = $null + } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 new file mode 100644 index 000000000..fc2fa3433 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-FOHubProvider.ps1 @@ -0,0 +1,372 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by scan and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +param() + +########################################################################### +# GET-FOHUBPROVIDER.PS1 +# FINOPS HUB - SCALABLE DATA PROVIDER (KUSTO-FIRST) +########################################################################### +# Purpose: Resolve which FinOps Hub data provider to use and serve the +# cost-family scans by pushing aggregation into the Kusto engine, +# returning ONLY summarized results. This is the scalable hub path +# for large customer datasets (tens of GB / hundreds of millions of +# rows) that must never be loaded into PowerShell objects. +# Date: Created for FinOps Multitool scalable hub data path +# +# Description: +# Provider selection (same code path serves all three): +# 1. Explicit override - FINOPS_HUB_KUSTO_URI (+ FINOPS_HUB_KUSTO_DB). +# Covers the offline ftklocal Kusto emulator (anonymous) AND a user- +# pinned ADX/Fabric cluster. Mirrors the toolkit's "use a provided +# cluster URI" connect option. +# 2. Online discovery - Azure Resource Graph for the hub's ADX cluster +# (microsoft.kusto/clusters tagged ftk-tool == 'FinOps hubs'), exactly +# the query the FinOps Toolkit's own ftk-hubs-connect flow uses. +# 3. None - no cluster; caller falls back to the storage +# export reader (small-dataset path). +# +# Each intent (Get-FOHubCostSummary / Get-FOHubResourceCosts / +# Get-FOHubCostByTag) builds a KQL summarize against the Hub database's +# versioned cost function (Costs - the latest-version alias the toolkit +# exposes) and returns the SAME shape the storage converters +# (ConvertTo-*FromHub) produce, so the cost tools are unchanged. +# +# Cost parity: actual-cost summaries use BilledCost, including measured zero. +# +# ── Functions ─────────────────────────────────────────────────── +# Resolve-FOHubProvider Decide provider (override | discovered | none) +# Get-FOHubCostSummary -> @{ subId = @{ Actual; Forecast; Currency } } +# Get-FOHubResourceCosts -> @(PSCustomObject Subscription/RG/Type/Path/...) +# Get-FOHubCostByTag -> @{ TagsQueried; CostByTag; NoTagsFound; ... } +# +# Prerequisites: +# - Invoke-FOHubKustoQuery.ps1, Get-PlainAccessToken.ps1, Search-AzGraphSafe.ps1 +# +# Usage: +# $p = Resolve-FOHubProvider -Subscriptions $subs +# if ($p.Found) { $cost = Get-FOHubCostSummary -Provider $p } +########################################################################### + +# -- Shared KQL snippets -------------------------------------------------- +$script:FOHubCostExpr = 'todouble(BilledCost)' + +function Get-FOHubAnchorLet { + # Anchor the reporting window to the latest month that actually has data + # (max ChargePeriodStart), not the calendar month. On a live hub the latest + # month IS the current month, so this matches the storage reader; on stale or + # historical data (e.g. a demo/ftklocal dataset) it still returns the newest + # available months instead of an empty calendar-month window. + return 'let _anchor = toscalar(Costs | summarize x = startofmonth(max(ChargePeriodStart)) | project x);' +} + +function Get-FOHubWindowClause { + # Trailing N calendar months ending at the latest data month (_anchor). + param([int]$Months = 1) + $back = [math]::Max(0, $Months - 1) + return "| where isnull(ChargePeriodStart) or ChargePeriodStart >= datetime_add('month', -$back, _anchor)" +} + +function Get-FOHubScopeClause { + # Restrict to specific subscriptions (FOCUS SubAccountId is + # /subscriptions/{guid}). Parsing every id keeps the interpolation to hex + # and hyphens, and an unparseable one fails rather than dropping the filter + # and silently returning every subscription in the hub. + param([string[]]$SubscriptionIds) + + if (-not $SubscriptionIds -or @($SubscriptionIds).Count -eq 0) { return '' } + + $guids = foreach ($id in $SubscriptionIds) { + $parsed = [guid]::Empty + if (-not [guid]::TryParse($id, [ref]$parsed)) { + throw "'$id' is not a subscription GUID. Refusing to drop the scope filter." + } + $parsed.ToString() + } + + $arr = (@($guids) | ForEach-Object { '"' + $_ + '"' }) -join ', ' + return "| where SubAccountId has_any (dynamic([$arr]))" +} + +# -- Private: run a query through the resolved provider -------------------- +function Invoke-FOHubProviderQuery { + param( + [Parameter(Mandatory)][hashtable]$Provider, + [Parameter(Mandatory)][string]$Query + ) + $token = $null + try { $null = Resolve-FinOpsRequestUri -Uri $Provider.ClusterUri -AllowAnonymousLoopback:(-not $Provider.UseAuth) } + catch { return @{ Ok = $false; Rows = @(); RowCount = 0; Error = $_.Exception.Message } } + if ($Provider.UseAuth) { + try { + $token = Get-PlainAccessToken -ResourceUrl $Provider.ClusterUri -ErrorAction Stop + if ($token -isnot [string] -or [string]::IsNullOrWhiteSpace($token)) { throw 'Authentication returned no usable token.' } + } + catch { return @{ Ok = $false; Rows = @(); RowCount = 0; Error = "Could not acquire a Kusto token for $($Provider.ClusterUri): $($_.Exception.Message)" } } + } + return Invoke-FOHubKustoQuery -ClusterUri $Provider.ClusterUri -Database $Provider.Database -Query $Query -AccessToken $token +} + +function Invoke-FOHubCostQuery { + param( + [Parameter(Mandatory)][hashtable]$Provider, + [Parameter(Mandatory)][string]$Query, + [string[]]$SubscriptionIds, + [int]$Months = 1 + ) + + $scope = Get-FOHubScopeClause -SubscriptionIds $SubscriptionIds + $expectedIds = ConvertTo-Json -InputObject @($SubscriptionIds | Where-Object { $_ } | ForEach-Object { ([guid]$_).ToString() }) -Compress + # Set difference is case-sensitive; extracted IDs must match the normalized GUIDs. + $validatedQuery = @" +$(Get-FOHubAnchorLet) +let src = Costs +$(Get-FOHubWindowClause -Months $Months) +$scope +| extend _cost = $($script:FOHubCostExpr), _sub = tolower(extract('([0-9a-fA-F-]{36})', 1, SubAccountId)); +let validation = src +| summarize _InvalidCosts = countif(isnull(_cost) or not(isfinite(_cost)) or isempty(BillingCurrency) or isnull(ChargePeriodStart)), + _CurrencyCount = array_length(make_set(BillingCurrency, 2)), _SourceRows = count(), + _MissingSubscriptions = array_length(set_difference(dynamic($expectedIds), make_set(_sub))) +| extend _CostValidation = true; +union validation, ( +$Query +) +"@ + $result = Invoke-FOHubProviderQuery -Provider $Provider -Query $validatedQuery + if (-not $result.Ok) { return $result } + $validation = @($result.Rows | Where-Object { $_._CostValidation -eq $true }) + if ($validation.Count -ne 1 -or $null -eq $validation[0]._InvalidCosts -or $null -eq $validation[0]._MissingSubscriptions -or + $null -eq $validation[0]._SourceRows -or $null -eq $validation[0]._CurrencyCount -or + $validation[0]._InvalidCosts -ne 0 -or $validation[0]._MissingSubscriptions -ne 0 -or + ($validation[0]._SourceRows -gt 0 -and $validation[0]._CurrencyCount -ne 1)) { + return @{ Ok = $false; Rows = @(); Error = 'Hub cost validation failed: missing or invalid amounts, currency, dates, or subscription coverage.' } + } + $rows = @($result.Rows | Where-Object { $_._CostValidation -ne $true }) + try { + foreach ($row in $rows) { + $column = if ($row.PSObject.Properties.Name -contains 'Actual') { 'Actual' } else { 'Cost' } + $null = Get-HubCostValue -Row $row -Column $column + } + } + catch { return @{ Ok = $false; Rows = @(); Error = $_.Exception.Message } } + return @{ Ok = $true; Rows = $rows; RowCount = $rows.Count; Error = $null } +} + +# -- Provider resolution -------------------------------------------------- +function Resolve-FOHubProvider { + [CmdletBinding()] + param( + [string[]]$Subscriptions, + [object]$Decision + ) + + # 1. Explicit override (ftklocal emulator or a pinned ADX/Fabric cluster). + if (-not [string]::IsNullOrWhiteSpace($env:FINOPS_HUB_KUSTO_URI)) { + $uri = $env:FINOPS_HUB_KUSTO_URI.Trim() + $db = if ($env:FINOPS_HUB_KUSTO_DB) { $env:FINOPS_HUB_KUSTO_DB.Trim() } else { 'Hub' } + $endpoint = Resolve-FinOpsRequestUri -Uri $uri -AllowAnonymousLoopback + if ($endpoint.Query) { throw 'The cluster URL cannot contain a query string.' } + $isLocal = $endpoint.IsLoopback + return @{ + Found = $true + Mode = if ($isLocal) { 'KustoLocal' } else { 'Kusto' } + ClusterUri = $uri + Database = $db + UseAuth = (-not $isLocal) + HubVersion = $null + Source = 'EnvOverride' + } + } + + # 2. A cluster already discovered by Resolve-CostDataSource. + if ($Decision -and $Decision.KustoClusterUri) { + $null = Resolve-FinOpsRequestUri -Uri $Decision.KustoClusterUri + return @{ + Found = $true + Mode = 'Kusto' + ClusterUri = [string]$Decision.KustoClusterUri + Database = if ($Decision.KustoDatabase) { [string]$Decision.KustoDatabase } else { 'Hub' } + UseAuth = $true + HubVersion = $Decision.HubVersion + Source = 'Discovered' + } + } + + # 3. Online discovery via Resource Graph (the toolkit's own connect query). + try { + $clusterQuery = @" +resources +| where type =~ 'microsoft.kusto/clusters' +| where tags['ftk-tool'] == 'FinOps hubs' +| extend hubVersion = tostring(tags['ftk-version']) +| project clusterUri = tostring(properties.uri), hubVersion, resourceGroup, subscriptionId +| take 1 +"@ + $res = Search-AzGraphSafe -Query $clusterQuery -Subscription @($Subscriptions) -First 1 + if ($null -eq $res) { throw 'Resource Graph did not return a readable discovery response.' } + if ($res -and $res.Data -and @($res.Data).Count -gt 0) { + $row = @($res.Data)[0] + if ($row.clusterUri) { + $null = Resolve-FinOpsRequestUri -Uri $row.clusterUri + return @{ + Found = $true + Mode = 'Kusto' + ClusterUri = [string]$row.clusterUri + Database = 'Hub' + UseAuth = $true + HubVersion = $row.hubVersion + Source = 'Discovered' + } + } + } + } + catch { + # Discovery failed - fall through to None (storage fallback). + Write-Warning ([regex]::Replace("Kusto provider discovery could not be verified: $($_.Exception.Message)", '[\p{Cc}\p{Cf}]', ' ')) + } + + return @{ Found = $false; Mode = 'None'; ClusterUri = $null; Database = $null; UseAuth = $false; HubVersion = $null; Source = 'None' } +} + +# -- Intent: cost by subscription (matches ConvertTo-CostDataFromHub) ------ +function Get-FOHubCostSummary { + [CmdletBinding()] + param( + [Parameter(Mandatory)][hashtable]$Provider, + [string[]]$SubscriptionIds, + [int]$Months = 1 + ) + $query = @" +src +| summarize Actual = sum(_cost), Currency = take_any(BillingCurrency), Name = take_any(SubAccountName), + ActualPeriodStart = min(ChargePeriodStart), ActualPeriodEnd = max(ChargePeriodStart) by _sub +"@ + $r = Invoke-FOHubCostQuery -Provider $Provider -Query $query -SubscriptionIds $SubscriptionIds -Months $Months + if (-not $r.Ok) { return @{ Error = $r.Error; Source = 'Kusto' } } + + $costMap = @{} + foreach ($row in $r.Rows) { + $subId = if ($row._sub) { [string]$row._sub } else { 'unknown' } + $currency = if ($row.Currency) { [string]$row.Currency } else { 'USD' } + # Carry the subscription's display name from the FOCUS data so the UI can + # show a friendly name even for subscriptions that aren't in the caller's + # selected list (a hub commonly covers more subs than are being scanned). + $subName = if ($row.Name) { [string]$row.Name } else { '' } + $costMap[$subId] = @{ + Actual = [math]::Round([double]$row.Actual, 2) + Forecast = $null + ForecastSource = 'Unavailable' + Currency = $currency + Name = $subName + ActualPeriod = if ($row.ActualPeriodStart -and $row.ActualPeriodEnd) { '{0:yyyy-MM-dd} to {1:yyyy-MM-dd}' -f [datetime]$row.ActualPeriodStart, [datetime]$row.ActualPeriodEnd } else { 'Unknown' } + } + } + return $costMap +} + +# -- Intent: top resources by cost (matches ConvertTo-ResourceCostsFromHub) - +function Get-FOHubResourceCosts { + [CmdletBinding()] + param( + [Parameter(Mandatory)][hashtable]$Provider, + [string[]]$SubscriptionIds, + [int]$Months = 1, + [int]$Top = 500 + ) + $query = @" +src +| summarize Actual = sum(_cost), Currency = take_any(BillingCurrency) + by Subscription = SubAccountName, ResourceGroup = x_ResourceGroupName, ResourceType, ResourcePath = ResourceId +| order by Actual desc +| take $Top +"@ + $r = Invoke-FOHubCostQuery -Provider $Provider -Query $query -SubscriptionIds $SubscriptionIds -Months $Months + if (-not $r.Ok) { return @{ Error = $r.Error; Source = 'Kusto' } } + + $out = foreach ($row in $r.Rows) { + [PSCustomObject]@{ + Subscription = if ($row.Subscription) { [string]$row.Subscription } else { 'unknown' } + ResourceGroup = if ($row.ResourceGroup) { [string]$row.ResourceGroup } else { 'unknown' } + ResourceType = if ($row.ResourceType) { [string]$row.ResourceType } else { 'unknown' } + ResourcePath = [string]$row.ResourcePath + Actual = [math]::Round([double]$row.Actual, 2) + Forecast = $null + Currency = if ($row.Currency) { [string]$row.Currency } else { 'USD' } + } + } + # Sort in PowerShell too (matches ConvertTo-ResourceCostsFromHub and is + # robust even if the transport ever returns rows out of engine order). + return @($out | Sort-Object -Property Actual -Descending) +} + +# -- Intent: cost by tag (matches ConvertTo-CostByTagFromHub) -------------- +function Get-FOHubCostByTag { + [CmdletBinding()] + param( + [Parameter(Mandatory)][hashtable]$Provider, + [string[]]$SubscriptionIds, + [int]$Months = 1, + [string[]]$TagKeys + ) + $keys = @($TagKeys | Where-Object { $_ }) + + # One snapshot: a sentinel *TOTAL* row plus per-(key,value) cost. Untagged + # cost per key is derived in PowerShell as total minus the key's tagged sum + # (mirrors the converter assigning '(untagged)' to rows lacking the key). + # Escape backslash before quote, matching ConvertTo-KqlLiteral, so a tag key + # ending in a backslash cannot terminate the KQL string early. + $keyList = ($keys | Where-Object { $_ } | ForEach-Object { '"' + $_.Replace('\', '\\').Replace('"', '\"') + '"' }) -join ', ' + $tagFilter = if ($keys.Count -gt 0) { "| where k in~ ($keyList)" } else { '' } + $query = @" +union (src | summarize Cost = sum(_cost), Currency = take_any(BillingCurrency) | extend TagKey = '*TOTAL*', TagValue = '*TOTAL*'), +(src +| mv-expand k = bag_keys(Tags) to typeof(string) +$tagFilter +| extend TagValue = tostring(Tags[k]) +| summarize Cost = sum(_cost), Currency = take_any(BillingCurrency) by TagKey = k, TagValue) +"@ + $r = Invoke-FOHubCostQuery -Provider $Provider -Query $query -SubscriptionIds $SubscriptionIds -Months $Months + if (-not $r.Ok) { return @{ Error = $r.Error; Source = 'Kusto' } } + + $currency = 'USD' + $total = 0.0 + $tagged = @{} # key -> @{ value -> cost } + foreach ($row in $r.Rows) { + $tk = [string]$row.TagKey + $cost = [double]$row.Cost + if ($row.Currency) { $currency = [string]$row.Currency } + if ($tk -eq '*TOTAL*') { $total = $cost; continue } + if (-not $tagged.ContainsKey($tk)) { $tagged[$tk] = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::Ordinal) } + $tv = if ($null -ne $row.TagValue -and "$($row.TagValue)" -ne '') { [string]$row.TagValue } else { '(empty)' } + if (-not $tagged[$tk].ContainsKey($tv)) { $tagged[$tk][$tv] = 0.0 } + $tagged[$tk][$tv] += $cost + } + + if ($keys.Count -eq 0) { $keys = @($tagged.Keys) } + $costByTagOut = @{} + foreach ($key in $keys) { + $values = if ($tagged.ContainsKey($key)) { $tagged[$key] } else { @{} } + $taggedSum = 0.0 + foreach ($v in $values.Values) { $taggedSum += $v } + $untagged = [math]::Round($total - $taggedSum, 2) + + $entries = @($values.GetEnumerator() | ForEach-Object { + [PSCustomObject]@{ TagValue = $_.Key; Cost = [math]::Round($_.Value, 2); Currency = $currency } + }) + if ($untagged -ne 0) { + $entries += [PSCustomObject]@{ TagValue = '(untagged)'; Cost = $untagged; Currency = $currency } + } + $costByTagOut[$key] = @($entries | Sort-Object Cost -Descending) + } + + return [PSCustomObject]@{ + TagsQueried = @($costByTagOut.Keys) + CostByTag = $costByTagOut + NoTagsFound = ($costByTagOut.Count -eq 0) + UsedTimeframe = 'Hub query period' + Source = 'Kusto' + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-JitteredDelay.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-JitteredDelay.ps1 new file mode 100644 index 000000000..2b47b7029 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-JitteredDelay.ps1 @@ -0,0 +1,48 @@ +########################################################################### +# GET-JITTEREDDELAY.PS1 +# RETRY BACKOFF JITTER +########################################################################### +# Purpose: Spread retry wake-ups so concurrent scans stop retrying in lockstep +# Author: Zac Larsen +# Date: Created for FinOps Multitool +# +# Description: +# Pure exponential backoff is deterministic, so several scans that are +# throttled by the same Azure endpoint at the same moment will all sleep for +# the same interval and retry together, re-triggering the throttle. This +# helper adds randomness to break that synchronization: +# 1. Computed backoff uses equal jitter - half the delay is fixed, half is +# random - which keeps a sensible floor while decorrelating callers. +# 2. A server-supplied Retry-After is treated as a hard floor and only ever +# extended, never shortened, so the service's instruction is respected. +# +# -- Parameters ------------------------------------------------------------- +# BaseSeconds Computed backoff to jitter, in seconds +# RetryAfterSeconds Server-supplied Retry-After floor, in seconds +# +# Usage: Get-JitteredDelay -BaseSeconds 8 +########################################################################### + +function Get-JitteredDelay { + [CmdletBinding(DefaultParameterSetName = 'Computed')] + [OutputType([double])] + param( + [Parameter(Mandatory, ParameterSetName = 'Computed')] + [double]$BaseSeconds, + + [Parameter(Mandatory, ParameterSetName = 'RetryAfter')] + [double]$RetryAfterSeconds + ) + + if ($PSCmdlet.ParameterSetName -eq 'RetryAfter') { + # Never sleep less than the service asked for; add up to 1s of spread. + if ($RetryAfterSeconds -lt 0) { $RetryAfterSeconds = 0 } + return $RetryAfterSeconds + (Get-Random -Minimum 0.0 -Maximum 1.0) + } + + if ($BaseSeconds -le 0) { return 0.0 } + + # Equal jitter: floor at half the backoff, randomize the other half. + $half = $BaseSeconds / 2 + return $half + (Get-Random -Minimum 0.0 -Maximum $half) +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 new file mode 100644 index 000000000..4fb323aad --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-KpiInsights.ps1 @@ -0,0 +1,695 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; callers pass -Catalog across the KPI helper family.')] +param() + +########################################################################### +# GET-KPIINSIGHTS.PS1 +# FINOPS KPI CORRELATION LAYER +########################################################################### +# Purpose: Map FinOps Multitool scan output to FinOps Foundation KPIs +# (https://www.finops.org/finops-kpis/) so callers who do not +# know the KPI taxonomy still see which industry KPIs their results +# inform, with a computed value where the data allows. +# Date: Created for KPI skills +# +# Description: +# Additive only. Does not change any scan. After a scan returns, the +# server calls Add-KpiInsights to attach a kpiInsights[] block: +# - status 'computed' a value was derived from the scan fields +# - status 'informational' the scan relates to the KPI; explore to learn +# Two public entry points: +# Add-KpiInsights enrich a tool result in place (server-side) +# Get-KpiExploration browse the catalog (explore_finops_kpis tool) +# +# Usage: dot-sourced by FinOpsMultitool.psm1 +########################################################################### + +$script:KpiCatalog = $null + +# Canonical CAF allocation (chargeback/showback) tag dimensions. Cost-allocation +# coverage is measured ONLY against these - not identity/marker tags (FinOps, +# cm-resource-parent, tag1, managedBy, CreatedByPolicy, ...) that blanket +# resources and would give a misleading untagged figure. Single source of truth +# shared by the KPI compute and the TUI cost-by-tag guidance so they agree. +function Get-CafAllocationTag { + return @('CostCenter', 'Customer', 'Project', 'Environment', 'Application', 'ApplicationName', + 'Owner', 'BusinessUnit', 'Department', 'Team', 'OpsTeam', 'Service', 'WorkloadName') +} + +function Get-KpiCatalog { + if ($script:KpiCatalog) { return $script:KpiCatalog } + # kpi-catalog.json lives in ../kpi relative to modules/helpers + $root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent + $path = Join-Path (Join-Path $root 'kpi') 'kpi-catalog.json' + if (-not (Test-Path $path)) { + # Fall back to ScriptRootDir if structure differs + if ($script:ScriptRootDir) { + $path = Join-Path (Join-Path $script:ScriptRootDir 'kpi') 'kpi-catalog.json' + } + } + if (-not (Test-Path $path)) { return $null } + $script:KpiCatalog = Get-Content $path -Raw | ConvertFrom-Json + return $script:KpiCatalog +} + +# Pull a property value off a scan-result object whether it is the object +# itself or wrapped in a .data property (server wrapper). +function Get-ScanField { + param($Data, [string]$Name) + if ($null -eq $Data) { return $null } + if ($Data.PSObject.Properties[$Name]) { return $Data.$Name } + if ($Data.PSObject.Properties['data'] -and $Data.data.PSObject.Properties[$Name]) { return $Data.data.$Name } + return $null +} + +# Compute a KPI value from scan data where we have a real formula. Returns +# a string value or $null when it cannot be computed (stays informational). +# Display is what a human reads; Value is the same figure as a number so scoring +# never has to recompute (and diverge from) the displayed math. +function New-KpiValue { + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Builds an in-memory object and changes no state.')] + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Display, $Value = $null) + [PSCustomObject]@{ Display = $Display; Value = $Value } +} + +# Returns the utilization figures that were actually measured. A family with no +# commitments reports 0, which would otherwise read as a measured 0% and halve +# the average for anyone who owns reservations but no savings plans. +function Get-CommitmentUtilizationValue { + param($Data) + + $vals = @() + if ([int](Get-ScanField $Data 'RICount') -gt 0) { + $ri = Get-ScanField $Data 'RIAvgUtilization' + if ($null -ne $ri) { $vals += [double]$ri } + } + if ([int](Get-ScanField $Data 'SPCount') -gt 0) { + $sp = Get-ScanField $Data 'SPAvgUtilization' + if ($null -ne $sp) { $vals += [double]$sp } + } + return $vals +} + +function Get-BudgetKpiData { + param($Data) + + $budgets = @(Get-ScanField $Data 'Budgets') + if (-not $budgets -or (Get-ScanField $Data 'CoverageIncomplete')) { + return @{ Error = 'Unavailable: budget inventory is incomplete.' } + } + $currency = $null + $timeGrain = $null + $subscriptions = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $percentages = [System.Collections.Generic.List[double]]::new() + $totalBudget = 0.0 + $totalActual = 0.0 + $now = (Get-Date).ToUniversalTime() + $monthStart = $now.Date.AddDays(1 - $now.Day) + foreach ($budget in $budgets) { + if ($budget.Category -ne 'Cost' -or -not $budget.Currency -or -not $budget.TimeGrain -or + $budget.SpendSource -eq 'Unavailable' -or -not $budget.SubscriptionId) { + return @{ Error = 'Unavailable: budget amounts, scope, units, or current spend are unknown.' } + } + try { + if ($budget.TimeGrain -ne 'Monthly' -or -not $budget.TimePeriod.startDate -or + ([datetime]$budget.TimePeriod.startDate).ToUniversalTime() -gt $monthStart -or + ($budget.TimePeriod.endDate -and ([datetime]$budget.TimePeriod.endDate).ToUniversalTime() -lt $now)) { + return @{ Error = 'Unavailable: budgets do not have a verified common current-month window.' } + } + } + catch { return @{ Error = 'Unavailable: a budget reporting period is invalid.' } } + if (($currency -and $currency -ne $budget.Currency) -or ($timeGrain -and $timeGrain -ne $budget.TimeGrain)) { + return @{ Error = 'Unavailable: budget currencies or reporting periods differ.' } + } + if (-not $subscriptions.Add([string]$budget.SubscriptionId)) { + return @{ Error = 'Unavailable: multiple budgets can overlap within a subscription.' } + } + try { + $amount = Get-HubCostValue -Row $budget -Column 'Amount' + $actual = Get-HubCostValue -Row $budget -Column 'ActualSpend' + if ($amount -le 0) { throw 'Budget amount must be positive.' } + } + catch { return @{ Error = 'Unavailable: a budget amount or current spend is invalid.' } } + $currency = $budget.Currency + $timeGrain = $budget.TimeGrain + $totalBudget += $amount + $totalActual += $actual + [void]$percentages.Add(100 * $actual / $amount) + } + return @{ Error = $null; Currency = $currency; TotalBudget = $totalBudget; TotalActual = $totalActual; Percentages = $percentages.ToArray() } +} + +function Format-FinOpsUnitRate { + param($Value, [string]$Currency) + + if ($null -eq $Value -or [string]::IsNullOrWhiteSpace($Currency)) { return 'Unavailable' } + try { + $amount = Get-HubCostValue -Row ([pscustomobject]@{ Value = $Value }) -Column 'Value' + $format = if ($amount -ne 0 -and [math]::Abs($amount) -lt 0.00000001) { '0.########E+0' } else { '0.########' } + return "$Currency $($amount.ToString($format, [cultureinfo]::InvariantCulture))" + } + catch { return 'Unavailable' } +} + +function Get-FinOpsUnitCostContext { + param($Data) + + $currency = [string](Get-ScanField $Data 'Currency') + $subtotal = $null + if ($currency -match '^[A-Za-z]{3}$' -and $currency -notin @('XXX', 'XTS') -and + -not (Get-ScanField $Data 'CostIssue') -and (Get-ScanField $Data 'CostAvailable') -ne $false) { + try { + $compute = Get-HubCostValue -Row $Data -Column 'ComputeCost' + $storage = Get-HubCostValue -Row $Data -Column 'StorageCost' + $sum = $compute + $storage + if (-not [double]::IsNaN($sum) -and -not [double]::IsInfinity($sum)) { $subtotal = $sum } + } + catch { $subtotal = $null } + } + $period = 'Cost period unavailable' + $start = Get-ScanField $Data 'CostPeriodStartUtc' + $end = Get-ScanField $Data 'CostPeriodEndUtc' + if ($null -ne $start -and $null -ne $end) { + try { + $start = ([datetime]$start).ToUniversalTime() + $end = ([datetime]$end).ToUniversalTime() + if ($end -gt $start) { + $period = '{0} to {1} UTC' -f $start.ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture), $end.ToString('yyyy-MM-dd HH:mm', [cultureinfo]::InvariantCulture) + } + } + catch { $period = 'Cost period unavailable' } + } + $amount = Format-BudgetAmount -Value $subtotal -Currency $currency + return [pscustomobject]@{ + Summary = "Amortized cost for selected subscriptions. Period: $period. Subtotal: $amount. Other Azure services are excluded. This is a cost distribution, not an efficiency score." + Formula = 'Category share = category cost / (VM compute cost + storage cost) x 100. Shares are unavailable when the subtotal is not positive.' + Capacity = 'Unit rates divide period cost by current inventory: all VMs, including stopped VMs; provisioned managed-disk capacity plus measured storage-account used capacity. These are not time-weighted running-resource rates.' + Target = 'No universal target split applies. Compare unit costs for the same workload, scope, currency, period, capacity basis, and service requirements. A lower rate alone does not prove better efficiency.' + } +} + +function Get-FinOpsScanContext { + param([string]$FunctionName, $Data) + + switch ($FunctionName) { + 'Get-IdleVMs' { + $evaluated = Get-ScanField $Data 'EvaluatedVMs' + $evaluatedLabel = if ($null -ne $evaluated) { [string]$evaluated } else { 'Unknown' } + return [pscustomobject]@{ + Summary = "Evaluated: $evaluatedLabel of $($Data.ScannedVMs) running VMs. Missing CPU or network measurements leave a VM unevaluated, not active or idle." + Details = @( + 'Window: the 14 days preceding this scan, using available Azure Monitor measurements. Idle requires average CPU <5% AND combined network <1 MiB/day (14 MiB across the window).' + 'Otherwise, underutilized requires average CPU <10% AND combined network <10 MiB/day (140 MiB across the window). These are scanner thresholds, not Azure Advisor criteria.' + 'Only currently running VMs are candidates. Averages can hide bursts; memory, disk activity, availability requirements, and workload purpose are not assessed. No returned candidate is not proof of optimized compute spend.' + ) + } + } + 'Get-StorageTierAdvice' { + $evaluated = Get-ScanField $Data 'EvaluatedAccounts' + $evaluatedLabel = if ($null -ne $evaluated) { [string]$evaluated } else { 'Unknown' } + return [pscustomobject]@{ + Summary = "Evaluated: $evaluatedLabel of $($Data.TotalHotAccounts) storage accounts with Hot or unspecified default tier. Missing transaction or capacity measurements leave an account unevaluated." + Details = @( + 'Window: the 30 days preceding this scan. Archive candidate: fewer than 100 blob transactions and rounded reported capacity greater than zero.' + 'Otherwise, Cool candidate: fewer than 1,000 blob transactions and reported capacity greater than 1 GiB. Capacity is the largest returned time-series average, rounded to two decimal places; display labels GB/MB use binary units.' + 'This is account-level screening, not per-blob last-access analysis. Active accounts can contain cold blobs; the account default does not establish every blob tier.' + 'Validate tier eligibility, retrieval costs, access latency, and retention before changing tiers. The scan does not model a net saving; its 50%/90% estimates are assumptions. Archive is offline and has a 180-day minimum retention charge.' + ) + } + } + 'Get-BudgetStatus' { + $budgets = @($Data.Budgets | Where-Object { $null -ne $_ }) + $available = 0 + foreach ($budget in $budgets) { + if ($budget.ForecastSource -eq 'Unavailable' -or $budget.Currency -notmatch '^[A-Za-z]{3}$' -or $budget.Currency -in @('XXX', 'XTS')) { continue } + try { + $null = Get-HubCostValue -Row $budget -Column 'Forecast' + $available++ + } + catch { continue } + } + return [pscustomobject]@{ + Summary = "Forecasts available: $available of $($budgets.Count); $($budgets.Count - $available) unavailable. A zero at-risk count is not an all-clear when forecasts are missing." + Details = @( + 'Budget coverage = selected subscriptions with at least one budget / selected subscriptions x 100. It is not spend coverage or forecast availability. Unreadable subscriptions leave coverage unverified.' + 'PctUsed = current spend / budget amount x 100, using each budget scope, filters, currency, and reset period. Budget scopes can overlap; do not add their amounts or spend as a subscription total.' + 'Risk checks, in priority order: a missing or invalid budget amount is Unknown; actual >100% is Over Budget; forecast >100% is Forecast Over; missing current spend is Unknown; forecast >90% is At Risk; actual >90% is Near Limit; an unavailable forecast is Forecast unavailable; forecast >75% is Watch; otherwise On Track. Unknown amounts or forecasts are not replaced with zero.' + 'There is no universal target burn rate. Compare the budget reset period, expected workload demand, and available forecast with the planned spending profile.' + ) + } + } + } + return $null +} + +function Get-KpiComputedValue { + param([string]$KpiId, $Data, $Catalog) + + if ($KpiId -in @('cost-per-gb-stored', 'hourly-cost-per-cpu-core', 'effective-avg-compute-cost-per-core')) { + $costIssue = Get-ScanField $Data 'CostIssue' + $currency = [string](Get-ScanField $Data 'Currency') + if ($costIssue) { return (New-KpiValue "Unavailable: $costIssue") } + if ($currency -notmatch '^[A-Za-z]{3}$' -or $currency -in @('XXX', 'XTS')) { + return (New-KpiValue 'Unavailable: one known billing currency is required.') + } + if ((Get-ScanField $Data 'CostAvailable') -eq $false) { return (New-KpiValue 'Unavailable: cost measurements could not be verified.') } + $field = if ($KpiId -eq 'cost-per-gb-stored') { 'CostPerGb' } else { 'CostPerVCpu' } + if ($null -eq (Get-ScanField $Data $field)) { return $null } + try { $unitRate = Get-HubCostValue -Row ([pscustomobject]@{ Value = (Get-ScanField $Data $field) }) -Column 'Value' } + catch { return (New-KpiValue 'Unavailable: a finite numeric unit rate is required.') } + } + + switch ($KpiId) { + 'cost-per-gb-stored' { + $v = $unitRate + $cur = Get-ScanField $Data 'Currency' + if ($null -ne $v -and $v -ge 0) { return (New-KpiValue "$(Format-FinOpsUnitRate -Value $v -Currency $cur) per GB (month-to-date)" ([double]$v)) } + } + 'hourly-cost-per-cpu-core' { + $v = $unitRate + $cur = Get-ScanField $Data 'Currency' + if ($null -ne $v -and $v -ge 0) { + $periodStart = Get-ScanField $Data 'CostPeriodStartUtc' + $periodEnd = Get-ScanField $Data 'CostPeriodEndUtc' + if ($null -ne $periodStart -and $null -ne $periodEnd) { + $periodStart = ([datetime]$periodStart).ToUniversalTime() + $periodEnd = ([datetime]$periodEnd).ToUniversalTime() + } + else { + $periodEnd = (Get-Date).ToUniversalTime() + $periodStart = $periodEnd.Date.AddDays(1 - $periodEnd.Day) + } + $elapsedHours = [math]::Max(($periodEnd - $periodStart).TotalHours, 1) + $hourly = [double]$v / $elapsedHours + return (New-KpiValue "$(Format-FinOpsUnitRate -Value $hourly -Currency $cur) per vCPU / hour" $hourly) + } + } + 'effective-avg-compute-cost-per-core' { + $v = $unitRate + $cur = Get-ScanField $Data 'Currency' + # Month-to-date, not a full month, so say so rather than implying a run rate. + if ($null -ne $v -and $v -ge 0) { return (New-KpiValue "$(Format-FinOpsUnitRate -Value $v -Currency $cur) per vCPU (month-to-date)" ([double]$v)) } + } + 'commitment-utilization-score' { + # Get-CommitmentUtilization seeds both averages to 0 and only fills the + # ones it found, so 0 usually means "none of this kind" (or access + # denied) rather than a measured 0%. Gate on the counts: a real 0% with + # commitments present still counts, an absent family does not drag the + # average down. + $vals = @(Get-CommitmentUtilizationValue -Data $Data) + if ($vals.Count -gt 0) { + $avg = [math]::Round(($vals | Measure-Object -Average).Average, 1) + return (New-KpiValue "$avg%" $avg) + } + } + 'anomaly-detection-rate' { + if (Get-ScanField $Data 'CoverageIncomplete') { return (New-KpiValue 'Unavailable: alert and rule coverage is incomplete.') } + # No true rate is possible (Azure does not expose how many anomalies + # actually occurred, only what it caught). Report an honest PROXY: + # anomaly alerts triggered + detection rules configured. Both are + # countable. Returns $null only when neither field is present. + $anom = Get-ScanField $Data 'AnomalyAlertCount' + $rules = Get-ScanField $Data 'ConfiguredRuleCount' + if ($null -ne $anom -or $null -ne $rules) { + $a = if ($null -ne $anom) { [int]$anom } else { 0 } + $r = if ($null -ne $rules) { [int]$rules } else { 0 } + $alertWord = if ($a -eq 1) { 'alert' } else { 'alerts' } + $ruleWord = if ($r -eq 1) { 'rule' } else { 'rules' } + # Score on rules configured: that is the controllable maturity signal. + return (New-KpiValue "$a anomaly $alertWord caught, $r detection $ruleWord configured (proxy)" $r) + } + } + 'percent-unused-resources' { + # No per-orphan cost in the scan, so report the orphaned-resource + # count (still a concrete waste signal). + $n = Get-ScanField $Data 'TotalCount' + if ($null -ne $n) { + $word = if ([int]$n -eq 1) { 'orphaned resource' } else { 'orphaned resources' } + return (New-KpiValue "$([int]$n) $word" ([int]$n)) + } + } + 'computational-waste' { + if ((Get-ScanField $Data 'MetricFailures') -gt 0) { return (New-KpiValue 'Unavailable: VM utilization coverage is incomplete.') } + # Share of running VMs flagged idle/underutilized. + $idle = Get-ScanField $Data 'Count' + $scanned = Get-ScanField $Data 'ScannedVMs' + if ($null -ne $idle -and $null -ne $scanned -and [int]$scanned -gt 0) { + $pct = [math]::Round(100 * [int]$idle / [int]$scanned, 1) + return (New-KpiValue "$pct% of running VMs idle ($([int]$idle) of $([int]$scanned))" $pct) + } + } + 'budget-burn-rate' { + $budgetData = Get-BudgetKpiData -Data $Data + if ($budgetData.Error) { return (New-KpiValue $budgetData.Error) } + $pcts = $budgetData.Percentages + $avg = [math]::Round(($pcts | Measure-Object -Average).Average, 1) + $word = if ($pcts.Count -eq 1) { 'budget' } else { 'budgets' } + return (New-KpiValue "$avg% of budget consumed (average of $($pcts.Count) comparable $word)" $avg) + } + 'variance-budget-vs-actual' { + $budgetData = Get-BudgetKpiData -Data $Data + if ($budgetData.Error) { return (New-KpiValue $budgetData.Error) } + $totBudget = $budgetData.TotalBudget + $totActual = $budgetData.TotalActual + $cur = $budgetData.Currency + $pctOfPlan = [math]::Round(100 * $totActual / $totBudget, 1) + $spend = '{0:N0}' -f [math]::Round([double]$totActual, 0) + $plan = '{0:N0}' -f [math]::Round([double]$totBudget, 0) + $variance = [math]::Abs([math]::Round(100 * ($totActual - $totBudget) / $totBudget, 1)) + return (New-KpiValue "Actual is $pctOfPlan% of planned ($cur $spend of $cur $plan, comparable budgets)" $variance) + } + 'effective-savings-rate' { + $savings = Get-ScanField $Data 'CommitmentSavingsMonthToDate' + $cur = Get-ScanField $Data 'Currency' + if (-not $cur) { return (New-KpiValue 'Unavailable: savings currency is unknown.') } + $period = Get-ScanField $Data 'Period' + if ($null -eq $savings -or -not $period -or [double]$savings -lt 0 -or [double]::IsNaN($savings) -or [double]::IsInfinity($savings)) { + return (New-KpiValue 'Unavailable: a valid commitment estimate and cost period are required.') + } + return (New-KpiValue "$cur $([math]::Round([double]$savings, 2)) estimated savings ($period; assumed discounts, not a measured rate)" ([math]::Round([double]$savings, 2))) + } + 'pct-compute-covered-by-commitment' { + # Commitment coverage = committed eligible spend / total eligible + # spend (excludes Spot). Computed in Get-SavingsRealized from + # amortized cost grouped by pricing model. + $cov = Get-ScanField $Data 'CommitmentCoveragePct' + $committed = Get-ScanField $Data 'CommittedAmortized' + $onDemand = Get-ScanField $Data 'OnDemandAmortized' + $cur = Get-ScanField $Data 'Currency' + if ($null -ne $cov) { + $detail = '' + if ($cur -and $null -ne $committed -and $null -ne $onDemand) { + $base = [double]$committed + [double]$onDemand + $detail = " ($cur $([math]::Round([double]$committed, 0)) committed of $cur $([math]::Round($base, 0)) eligible)" + } + return (New-KpiValue "$cov% covered by commitments$detail" ([double]$cov)) + } + } + 'token-consumption-metrics' { + $tokens = Get-ScanField $Data 'TotalTokens' + $cost = Get-ScanField $Data 'TotalAICost' + $cur = Get-ScanField $Data 'Currency' + if ($null -ne $tokens -and [long]$tokens -gt 0) { + $costStr = if ($null -ne $cost -and [double]$cost -gt 0 -and $cur -match '^[A-Za-z]{3}$' -and $cur -notin @('XXX', 'XTS') -and + -not (Get-ScanField $Data 'CostIssue')) { " for $cur $([math]::Round([double]$cost, 2))" } else { '' } + $period = Get-ScanField $Data 'Period' + if ($period -eq 'MonthToDate') { $period = 'Month to date' } + elseif (-not $period) { $period = 'Unknown period' } + return (New-KpiValue "$('{0:N0}' -f [long]$tokens) tokens$costStr ($period)" ([long]$tokens)) + } + } + 'cost-per-api-call' { + $cpr = Get-ScanField $Data 'CostPerRequest' + $cur = Get-ScanField $Data 'Currency' + $costIssue = Get-ScanField $Data 'CostIssue' + if ($costIssue) { return (New-KpiValue "Unavailable: $costIssue") } + $rateIssue = Get-ScanField $Data 'RateIssue' + if ($rateIssue) { return (New-KpiValue "Unavailable: $rateIssue") } + if ($cur -notmatch '^[A-Za-z]{3}$' -or $cur -in @('XXX', 'XTS')) { return (New-KpiValue 'Unavailable: one known AI billing currency is required.') } + if ($null -ne $cpr -and [double]$cpr -ge 0) { + return (New-KpiValue "$cur $([math]::Round([double]$cpr, 5)) per AI request" ([math]::Round([double]$cpr, 5))) + } + return (New-KpiValue 'Unavailable: comparable AI costs and measured request counts are required.') + } + 'pct-commitment-discount-waste' { + $vals = @(Get-CommitmentUtilizationValue -Data $Data) + if ($vals.Count -gt 0) { + $avg = ($vals | Measure-Object -Average).Average + $waste = [math]::Round(100 - $avg, 1) + return (New-KpiValue "$waste%" $waste) + } + } + { $_ -in @('pct-costs-untagged', 'pct-costs-unallocated', 'tagging-policy-compliant') } { + if (Get-ScanField $Data 'CoverageIncomplete') { + return (New-KpiValue 'Unavailable: cost coverage is incomplete; allocation cannot be scored for the whole selected scope.') + } + # Prefer the per-resource allocation figure: a resource counts once, + # and is allocated if it carries any CAF allocation tag. Per-tag + # totals cannot answer this because a resource shows as untagged + # under every tag it lacks, so summing them double-counts. + $seen = Get-ScanField $Data 'ResourceCostSeen' + $unalloc = Get-ScanField $Data 'UnallocatedCost' + if ($null -ne $seen -and $null -ne $unalloc) { + if ([double]$seen -le 0 -or [double]::IsNaN($seen) -or [double]::IsInfinity($seen) -or + [double]::IsNaN($unalloc) -or [double]::IsInfinity($unalloc)) { + return (New-KpiValue 'Unavailable: allocation percentages require finite amounts and a positive net cost total.') + } + if ([double]$unalloc -lt 0 -or [double]$unalloc -gt [double]$seen) { + return (New-KpiValue 'Unavailable: credits or negative net costs prevent a comparable allocation percentage.') + } + $pct = [math]::Round(100 * [double]$unalloc / [double]$seen, 1) + switch ($KpiId) { + 'pct-costs-untagged' { return (New-KpiValue "$pct% of resource spend carries no allocation tag" $pct) } + 'pct-costs-unallocated' { return (New-KpiValue "$pct% unallocated across all allocation tags" $pct) } + 'tagging-policy-compliant' { return (New-KpiValue "$([math]::Round(100 - $pct, 1))% of resource spend is allocated" ([math]::Round(100 - $pct, 1))) } + } + } + + # Fallback for sources that aggregate server-side and never walk + # resources. Report the WORST-covered allocation tag: the best-covered + # one flatters the estate and hides the gap. + $cbt = Get-ScanField $Data 'CostByTag' + if (-not $cbt) { return $null } + $allocTags = Get-CafAllocationTag + $tagPairs = @() + if ($cbt -is [System.Collections.IDictionary]) { + foreach ($k in $cbt.Keys) { $tagPairs += [PSCustomObject]@{ Name = $k; Value = $cbt[$k] } } + } + else { + foreach ($prop in $cbt.PSObject.Properties) { $tagPairs += [PSCustomObject]@{ Name = $prop.Name; Value = $prop.Value } } + } + $worst = $null + foreach ($tp in $tagPairs) { + if ($allocTags -notcontains $tp.Name) { continue } # allocation tags only + $rows = @($tp.Value) + if ($rows.Count -eq 0) { continue } + $total = ($rows | Measure-Object -Property Cost -Sum).Sum + if ($null -eq $total -or $total -le 0 -or [double]::IsNaN($total) -or [double]::IsInfinity($total)) { + return (New-KpiValue 'Unavailable: allocation percentages require finite amounts and a positive net cost total.') + } + if (@($rows | Where-Object { [double]$_.Cost -lt 0 }).Count -gt 0) { + return (New-KpiValue 'Unavailable: credits or negative net costs prevent a comparable allocation percentage.') + } + $untag = ($rows | Where-Object { $_.TagValue -eq '(untagged)' } | Measure-Object -Property Cost -Sum).Sum + if ($null -eq $untag) { $untag = 0 } + $pctUntag = [math]::Round(100 * $untag / $total, 1) + if ($null -eq $worst -or $pctUntag -gt $worst.PctUntag) { + $worst = [PSCustomObject]@{ Tag = $tp.Name; PctUntag = $pctUntag } + } + } + if ($null -eq $worst) { return $null } # no allocation tags -> stays informational + switch ($KpiId) { + 'pct-costs-untagged' { return (New-KpiValue "$($worst.PctUntag)% untagged (worst allocation tag: '$($worst.Tag)')" $worst.PctUntag) } + 'pct-costs-unallocated' { return (New-KpiValue "$($worst.PctUntag)% unallocated (worst: '$($worst.Tag)')" $worst.PctUntag) } + 'tagging-policy-compliant' { return (New-KpiValue "$([math]::Round(100 - $worst.PctUntag, 1))% compliant (worst: '$($worst.Tag)')" ([math]::Round(100 - $worst.PctUntag, 1))) } + } + } + } + return $null +} + +# Enrich a server tool-result hashtable in place with a kpiInsights array. +function Add-KpiInsights { + param([Parameter(Mandatory)]$Result) + + $catalog = Get-KpiCatalog + if (-not $catalog) { return $Result } + + $toolName = $null + if ($Result -is [hashtable]) { $toolName = $Result['tool'] } + elseif ($Result.PSObject.Properties['tool']) { $toolName = $Result.tool } + if (-not $toolName) { return $Result } + + $matched = @($catalog.kpis | Where-Object { $_.sourceTool -eq $toolName }) + if ($matched.Count -eq 0) { return $Result } + + $data = if ($Result -is [hashtable]) { $Result['data'] } else { $Result.data } + + $insights = @() + foreach ($kpi in $matched) { + $value = $null + if ($kpi.compute) { $value = Get-KpiComputedValue -KpiId $kpi.id -Data $data -Catalog $catalog } + $status = if ($value -and $null -ne $value.Value) { 'computed' } elseif ($value) { 'unavailable' } else { 'informational' } + $insights += [PSCustomObject]@{ + kpiId = $kpi.id + kpiName = $kpi.name + domain = $kpi.domain + definition = $kpi.definition + status = $status + yourValue = if ($value) { $value.Display } else { $null } + numericValue = if ($value) { $value.Value } else { $null } + plainLanguage = $kpi.plainLanguage + exploreHint = $kpi.exploreHint + learnMore = $catalog.learnMoreBase + } + } + + if ($insights.Count -gt 0) { + if ($Result -is [hashtable]) { $Result['kpiInsights'] = @($insights) } + else { $Result | Add-Member -NotePropertyName 'kpiInsights' -NotePropertyValue @($insights) -Force } + } + return $Result +} + +# Map a raw scan function name (as used by the TUI/automated editions) to the +# scan name the KPI catalog keys off (sourceTool). Lets every caller reuse the +# exact same compute path, so KPI behavior stays in parity. +function Get-KpiScanMap { + return @{ + 'Get-UnitEconomics' = 'scan_unit_economics' + 'Get-CostByTag' = 'scan_cost_by_tag' + 'Get-CommitmentUtilization' = 'scan_commitment_utilization' + 'Get-ReservationAdvice' = 'scan_reservation_advice' + 'Get-OrphanedResources' = 'scan_orphaned_resources' + 'Get-IdleVMs' = 'scan_idle_vms' + 'Get-StorageTierAdvice' = 'scan_storage_tier_advice' + 'Get-BudgetStatus' = 'scan_budget_status' + 'Get-AnomalyAlerts' = 'scan_anomaly_alerts' + 'Get-SavingsRealized' = 'scan_savings_realized' + 'Get-LegacyResources' = 'scan_legacy_resources' + 'Get-CarbonMetrics' = 'scan_carbon' + 'Get-AIWorkloadMetrics' = 'scan_ai_workloads' + 'Get-CostTrend' = 'scan_cost_trend' + 'Get-ResourceCosts' = 'scan_resource_costs' + 'Get-VmCostBreakdown' = 'scan_vm_cost_breakdown' + 'Get-SharedCostAllocation' = 'scan_allocate_shared_cost' + 'Get-UsageProportionalAllocation' = 'scan_usage_allocation' + } +} + +function Get-KpiToolNameForFunction { + param([Parameter(Mandatory)][string]$FunctionName) + $map = Get-KpiScanMap + if ($map.ContainsKey($FunctionName)) { return $map[$FunctionName] } + return $null +} + +# Compute the kpiInsights array for a raw scan output (where the result IS the +# data, not a { tool; data } envelope). Wraps the output in the same +# envelope the catalog expects so Add-KpiInsights/Get-KpiComputedValue run the +# identical logic. Returns an array of insight objects (possibly empty). +function Get-KpiInsightsForResult { + param( + [Parameter(Mandatory)][string]$FunctionName, + $Output + ) + if ($null -eq $Output) { return @() } + $toolName = Get-KpiToolNameForFunction -FunctionName $FunctionName + if (-not $toolName) { return @() } + $envelope = @{ tool = $toolName; data = $Output } + $enriched = Add-KpiInsights -Result $envelope + if ($enriched -is [System.Collections.IDictionary] -and $enriched.Contains('kpiInsights')) { + return @($enriched['kpiInsights']) + } + return @() +} + +function Get-FinOpsKpiReference { + param([System.Collections.IDictionary]$Results, [object[]]$Modules, [object[]]$Insights) + + $catalog = Get-KpiCatalog + if (-not $catalog) { return @() } + $scanMap = Get-KpiScanMap + foreach ($kpi in $catalog.kpis) { + $functionName = $scanMap.Keys | Where-Object { $scanMap[$_] -eq $kpi.sourceTool } | Select-Object -First 1 + $sourceModule = $Modules | Where-Object { $_.Fn -eq $functionName } | Select-Object -First 1 + $selected = $sourceModule | Where-Object Selected + $status = if ($kpi.compute) { 'Not run' } else { 'Informational' } + $value = if ($kpi.compute) { 'The source scan was not selected for this report.' } else { 'Reference only. This tool does not calculate this KPI.' } + $context = $null + if ($selected) { + if ($Results.Contains("_error_$functionName")) { + if ($kpi.compute) { + $status = 'Unavailable' + $value = [string]$Results["_error_$functionName"] + } + else { $context = "Related scan failed: $($Results["_error_$functionName"])" } + } + elseif (-not $Results.Contains($functionName) -or $null -eq $Results[$functionName]) { + if ($kpi.compute) { + $status = 'Unavailable' + $value = 'The scan returned no result; this is not a measured zero.' + } + else { $context = 'The related scan returned no result.' } + } + else { + if ($kpi.compute) { + $insight = $Insights | Where-Object { $_.kpiId -eq $kpi.id } | Select-Object -First 1 + $status = if ($insight.status -eq 'computed' -and $null -ne $insight.numericValue) { 'Computed' } else { 'Unavailable' } + $value = if ($insight.yourValue) { [string]$insight.yourValue } else { 'Required comparable measurements were not available in this run.' } + } + if ($functionName -eq 'Get-UnitEconomics') { $context = (Get-FinOpsUnitCostContext -Data $Results[$functionName]).Summary } + else { $context = (Get-FinOpsScanContext -FunctionName $functionName -Data $Results[$functionName]).Summary } + } + } + [pscustomobject]@{ + Id = $kpi.id + Name = $kpi.name + Definition = $kpi.definition + Domain = $kpi.domain + Unit = $kpi.unit + Calculation = $kpi.calculation + RequiredInputs = @($kpi.requiredInputs) + Interpretation = $kpi.interpretation + Limitations = $kpi.limitations + Status = $status + Value = $value + Context = $context + SourceFunction = $functionName + SourceName = if ($sourceModule) { $sourceModule.Name } elseif ($functionName) { $functionName } else { 'Data-source discovery (not a menu scan)' } + SourceCategory = $selected.Category + SourceSelected = $null -ne $selected + } + } +} + +# Browse the KPI catalog for the explore_finops_kpis tool. +function Get-KpiExploration { + param([string]$KpiId) + + $catalog = Get-KpiCatalog + if (-not $catalog) { return @{ error = 'KPI catalog not found.' } } + + if ($KpiId) { + $kpi = $catalog.kpis | Where-Object { $_.id -eq $KpiId } | Select-Object -First 1 + if (-not $kpi) { return @{ error = "Unknown KPI id '$KpiId'. Call explore_finops_kpis with no id to list all." } } + return @{ + kpi = [PSCustomObject]@{ + id = $kpi.id + name = $kpi.name + domain = $kpi.domain + definition = $kpi.definition + sourceTool = $kpi.sourceTool + computable = [bool]$kpi.compute + plainLanguage = $kpi.plainLanguage + exploreHint = $kpi.exploreHint + learnMore = $catalog.learnMoreBase + } + howTo = "Run $($kpi.sourceTool) to inform this KPI. $(if ($kpi.compute) { 'The server computes a value from the scan.' } else { 'The scan relates to this KPI; use the explore hint to dig in.' })" + } + } + + # No id: list grouped by domain with computable flag + $byDomain = @{} + foreach ($kpi in $catalog.kpis) { + $d = $kpi.domain + if (-not $byDomain.ContainsKey($d)) { $byDomain[$d] = @() } + $byDomain[$d] += [PSCustomObject]@{ + id = $kpi.id + name = $kpi.name + sourceTool = $kpi.sourceTool + status = if ($kpi.compute) { 'Computable now' } else { 'Informational (run the tool)' } + } + } + return @{ + catalogVersion = $catalog.version + totalKpis = @($catalog.kpis).Count + learnMore = $catalog.learnMoreBase + note = 'These FinOps Foundation KPIs can be informed by this server today. Run the listed tool, then read the kpiInsights block it returns. More KPIs will be added over time.' + byDomain = $byDomain + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 new file mode 100644 index 000000000..2eefd2aad --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Get-PlainAccessToken.ps1 @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# ARM endpoint for the cloud the user is actually signed in to. Hardcoding the +# public URL breaks Azure Government and Azure China. +function Get-FinOpsArmEndpoint { + $url = $null + try { $url = (Get-AzContext).Environment.ResourceManagerUrl } catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + if ([string]::IsNullOrWhiteSpace($url)) { $url = 'https://management.azure.com' } + return $url.TrimEnd('/') +} + +function Resolve-FinOpsRequestUri { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Uri, + [switch]$AllowAnonymousLoopback + ) + + $parsed = $null + if ($Uri -match '[\x00-\x20\\]' -or -not [Uri]::TryCreate($Uri, [UriKind]::Absolute, [ref]$parsed) -or + $parsed.UserInfo -or $parsed.Fragment -or -not $parsed.Host) { + throw 'The endpoint must be an absolute URL without credentials, fragments, or control characters.' + } + if ($parsed.Scheme -ne 'https' -and -not ($AllowAnonymousLoopback -and $parsed.Scheme -eq 'http' -and $parsed.IsLoopback)) { + throw 'Authenticated and remote endpoints must use HTTPS. HTTP is allowed only for a token-free loopback emulator.' + } + return $parsed +} + +function Get-PlainAccessToken { + [CmdletBinding()] + param([string]$ResourceUrl) + if ([string]::IsNullOrWhiteSpace($ResourceUrl)) { $ResourceUrl = Get-FinOpsArmEndpoint } + $null = Resolve-FinOpsRequestUri -Uri $ResourceUrl + $tok = (Get-AzAccessToken -ResourceUrl $ResourceUrl -ErrorAction Stop).Token + $plainToken = if ($tok -is [securestring]) { + $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tok) + # PtrToStringBSTR, not PtrToStringAuto: a BSTR is always UTF-16, but Auto + # picks the platform default and truncates the token to one char on macOS. + try { [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } + finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } + } + else { $tok } + if ($plainToken -isnot [string] -or [string]::IsNullOrWhiteSpace($plainToken)) { + throw 'Azure authentication returned no usable access token.' + } + return $plainToken +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 new file mode 100644 index 000000000..076140194 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-AzRestMethodWithRetry.ps1 @@ -0,0 +1,191 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + +# -- Shared Runspace Pool -------------------------------------------------- +# Created once at module load. Reused by Invoke-AzRestMethodWithRetry and +# Search-AzGraphSafe to avoid the ~1-2s cold-start per runspace creation. +if (-not $script:RunspacePool -or $script:RunspacePool.RunspacePoolStateInfo.State -ne 'Opened') { + $script:RunspacePool = [runspacefactory]::CreateRunspacePool(1, 6) + $script:RunspacePool.Open() +} + +# -- Friendly throttle message --------------------------------------------- +# While waiting out a 429 rate limit, show a single friendly status instead +# of a technical "throttled" notice. Shared by all retry paths. +function Get-NextThrottleMessage { + return 'Fetching numbers......' +} + +# -- WPF Detection --------------------------------------------------------- +# When running standalone (no GUI), skip DispatcherFrame pumping and use +# simple Start-Sleep instead. This lets the same code work in both contexts. +function Test-WpfLoaded { + try { + $dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher + return ($null -ne $dispatcher -and + -not $dispatcher.HasShutdownStarted -and + $null -ne [System.Windows.Application]::Current) + } + catch { return $false } +} + +function Wait-WithDispatcher { + param([int]$Milliseconds) + if (Test-WpfLoaded) { + $waitEnd = (Get-Date).AddMilliseconds($Milliseconds) + while ((Get-Date) -lt $waitEnd) { + $frame = [System.Windows.Threading.DispatcherFrame]::new() + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke( + [System.Windows.Threading.DispatcherPriority]::Background, + [action] { $frame.Continue = $false } + ) + [System.Windows.Threading.Dispatcher]::PushFrame($frame) + Start-Sleep -Milliseconds 100 + } + } + else { + Start-Sleep -Milliseconds $Milliseconds + } +} + +function Wait-ForRunspace { + param( + [System.IAsyncResult]$AsyncResult, + [int]$TimeoutSeconds = 60 + ) + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + if (Test-WpfLoaded) { + while (-not $AsyncResult.IsCompleted -and (Get-Date) -lt $deadline) { + $frame = [System.Windows.Threading.DispatcherFrame]::new() + [System.Windows.Threading.Dispatcher]::CurrentDispatcher.BeginInvoke( + [System.Windows.Threading.DispatcherPriority]::Background, + [action] { $frame.Continue = $false } + ) + [System.Windows.Threading.Dispatcher]::PushFrame($frame) + Start-Sleep -Milliseconds 100 + } + } + else { + while (-not $AsyncResult.IsCompleted -and (Get-Date) -lt $deadline) { + Start-Sleep -Milliseconds 200 + } + } +} + +function Invoke-AzRestMethodWithRetry { + param( + [string]$Path, + [string]$Method = 'POST', + [string]$Payload, + [int]$MaxRetries = 3, + [int]$TimeoutSeconds = 60 + ) + for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) { + $ps = [powershell]::Create() + $ps.RunspacePool = $script:RunspacePool + [void]$ps.AddScript({ + param($p, $m, $pl) + $params = @{ Path = $p; Method = $m; ErrorAction = 'Stop' } + if ($pl) { $params['Payload'] = $pl } + try { + $r = Invoke-AzRestMethod @params + $hdrs = @{} + if ($r.Headers) { + foreach ($k in $r.Headers.Keys) { $hdrs[$k] = $r.Headers[$k] } + } + [PSCustomObject]@{ + StatusCode = $r.StatusCode + Content = $r.Content + Headers = $hdrs + } + } + catch { + # A transport-level failure ("An error occurred while sending + # the request", TLS/socket drop, token-acquisition error) has + # no HTTP status and would otherwise re-throw out of EndInvoke + # as a raw ErrorActionPreference=Stop exception. Surface it as + # a synthetic 503 so the caller degrades gracefully. + $msg = "$($_.Exception.Message)" -replace '[\\"]', "'" + [PSCustomObject]@{ + StatusCode = 503 + Content = ('{"error":{"message":"' + $msg + '"}}') + Headers = @{} + } + } + }).AddArgument($Path).AddArgument($Method).AddArgument($Payload) + + $asyncResult = $ps.BeginInvoke() + Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds + + $resp = $null + if ($asyncResult.IsCompleted) { + try { + $raw = $ps.EndInvoke($asyncResult) + $resp = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null } + } + catch { + # Should be rare now that the runspace script catches internally, + # but never let an EndInvoke failure bubble up as a raw + # terminating error - degrade to a synthetic 503 instead. + $ps.Dispose() + Write-Warning " REST call failed in runspace: $($_.Exception.Message)" + return [PSCustomObject]@{ + StatusCode = 503 + Content = '{"error":{"message":"Request failed (transport error)."}}' + Headers = @{} + } + } + } + else { + $ps.Stop() + Write-Warning " REST call timed out after $($TimeoutSeconds)s: $Method $Path" + $ps.Dispose() + return [PSCustomObject]@{ StatusCode = 408; Content = '{"error":{"message":"Request timed out"}}'; Headers = @{} } + } + + $ps.Dispose() + + if (-not $resp) { + $resp = [PSCustomObject]@{ StatusCode = 0; Content = $null; Headers = @{} } + } + if ($null -eq $resp.Content) { + $resp = [PSCustomObject]@{ StatusCode = $resp.StatusCode; Content = '{}'; Headers = if ($resp.Headers) { $resp.Headers } else { @{} } } + } + + # 429 and transient 5xx are both retryable. 5xx also covers the synthetic + # 503 this function raises for transport failures, which are the most + # likely thing to succeed on a second attempt. + $isThrottled = ($resp.StatusCode -eq 429) + $isServerErr = ($resp.StatusCode -ge 500 -and $resp.StatusCode -le 599) + if (-not ($isThrottled -or $isServerErr)) { return $resp } + if ($attempt -eq $MaxRetries) { return $resp } + + # Parse Retry-After header or default to exponential backoff. Both paths + # are jittered so parallel scans do not retry in lockstep. + $retryAfter = Get-JitteredDelay -BaseSeconds 10 + if ($isThrottled -and $resp.Headers -and $resp.Headers['Retry-After']) { + $parsed = 0 + if ([int]::TryParse($resp.Headers['Retry-After'], [ref]$parsed)) { + $retryAfter = Get-JitteredDelay -RetryAfterSeconds ([math]::Max($parsed, 5)) + } + } + elseif ($isServerErr) { + $retryAfter = Get-JitteredDelay -BaseSeconds ([math]::Min(2 * [math]::Pow(2, $attempt), 30)) + } + else { + $retryAfter = Get-JitteredDelay -BaseSeconds ([math]::Min(10 * [math]::Pow(2, $attempt), 60)) + } + $friendly = if ($isThrottled) { Get-NextThrottleMessage } else { "Azure returned $($resp.StatusCode) - retrying..." } + Write-Host " $friendly" -ForegroundColor Yellow + + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus $friendly + } + + Wait-WithDispatcher -Milliseconds ($retryAfter * 1000) + } + return $resp +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 new file mode 100644 index 000000000..ebedcbc0e --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Invoke-FOHubKustoQuery.ps1 @@ -0,0 +1,201 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by query and is not a declared contract.')] +param() + +########################################################################### +# INVOKE-FOHUBKUSTOQUERY.PS1 +# FINOPS HUB - KUSTO (ADX / FABRIC / FTKLOCAL) QUERY TRANSPORT +########################################################################### +# Purpose: Execute a KQL query against a FinOps Hub database and return +# only the (already-summarized) result rows. This is the scalable +# hub path: aggregation is pushed into the engine so PowerShell +# never materializes tens of GB / hundreds of millions of rows. +# Date: Created for FinOps Multitool scalable hub data path +# +# Description: +# Thin REST transport over the Kusto query endpoint (POST {cluster}/v1/rest/query): +# 1. Works against a deployed Azure Data Explorer / Fabric cluster (with a +# bearer token) AND a local ftklocal Kusto emulator (anonymous, no token). +# 2. Sends { db, csl } and parses the v1 response, mapping the primary +# result table's columns + rows into PSCustomObjects keyed by column name. +# 3. Returns a small wrapper ({ Ok; Rows; RowCount; Error }) so callers can +# branch without try/catch. No Az.Kusto module / SDK dependency. +# +# ── Parameters ────────────────────────────────────────────────── +# ClusterUri Cluster query URI (e.g. https://..kusto.windows.net +# or http://localhost:8082 for the ftklocal emulator) +# Query KQL query text (csl). Should already aggregate/summarize. +# Database Hub database name (default 'Hub' - the FinOps Hub cost db) +# AccessToken Optional bearer token. Omit for an anonymous local emulator. +# TimeoutSec Per-request timeout (default 120) +# +# Prerequisites: +# - Network reachability to the cluster query endpoint +# - For a deployed cluster: a token from Get-PlainAccessToken -ResourceUrl +# +# Usage: +# $r = Invoke-FOHubKustoQuery -ClusterUri $uri -Database 'Hub' ` +# -Query 'Costs_v1_2() | summarize Cost=sum(EffectiveCost)' +# if ($r.Ok) { $r.Rows | ForEach-Object { $_.Cost } } +########################################################################### + +function Invoke-FOHubKustoQuery { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$ClusterUri, + + [Parameter(Mandatory)] + [string]$Query, + + [Parameter()] + [string]$Database = 'Hub', + + [Parameter()] + [string]$AccessToken, + + [Parameter()] + [int]$TimeoutSec = 120 + ) + + if ([string]::IsNullOrWhiteSpace($ClusterUri)) { + return @{ Ok = $false; Rows = @(); RowCount = 0; Error = 'ClusterUri is required.' } + } + + try { + $endpoint = Resolve-FinOpsRequestUri -Uri $ClusterUri -AllowAnonymousLoopback:([string]::IsNullOrWhiteSpace($AccessToken)) + if ($endpoint.Query) { throw 'The cluster URL cannot contain a query string.' } + } + catch { return @{ Ok = $false; Rows = @(); RowCount = 0; Error = $_.Exception.Message } } + $base = $ClusterUri.TrimEnd('/') + $uri = "$base/v1/rest/query" + + $headers = @{ + 'Content-Type' = 'application/json' + 'Accept' = 'application/json' + } + if (-not [string]::IsNullOrWhiteSpace($AccessToken)) { + $headers['Authorization'] = "Bearer $AccessToken" + } + + $body = @{ db = $Database; csl = $Query } | ConvertTo-Json -Depth 3 + + try { + $resp = Invoke-RestMethod -Uri $uri -Method Post -Headers $headers -Body $body ` + -TimeoutSec $TimeoutSec -MaximumRedirection 0 -ErrorAction Stop + + if (($resp -isnot [System.Collections.IDictionary] -and $resp -isnot [PSCustomObject]) -or + $resp.Tables -isnot [System.Collections.IList]) { + throw 'The Kusto response does not contain a valid Tables array.' + } + + # Validate every table, including query status, before returning the primary rows. + $tables = [System.Collections.Generic.List[object]]::new() + $statusIndexes = [System.Collections.Generic.HashSet[int]]::new() + for ($tableIndex = 0; $tableIndex -lt $resp.Tables.Count; $tableIndex++) { + $table = $resp.Tables[$tableIndex] + if (($table -isnot [System.Collections.IDictionary] -and $table -isnot [PSCustomObject]) -or + $table.Columns -isnot [System.Collections.IList] -or $table.Rows -isnot [System.Collections.IList]) { + throw 'The Kusto response contains an invalid table.' + } + if ($null -ne $table.TableName -and $table.TableName -isnot [string]) { + throw 'The Kusto response contains an invalid table name.' + } + $columnNames = [System.Collections.Generic.List[string]]::new() + $seenNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($column in $table.Columns) { + if ($column -isnot [System.Collections.IDictionary] -and $column -isnot [PSCustomObject]) { + throw 'The Kusto response contains an invalid column descriptor.' + } + $columnName = $column.ColumnName + if ($columnName -isnot [string] -or [string]::IsNullOrWhiteSpace($columnName) -or -not $seenNames.Add($columnName)) { + throw 'The Kusto response contains a missing or duplicate column name.' + } + $columnNames.Add($columnName) + } + $mappedRows = [System.Collections.Generic.List[object]]::new() + foreach ($row in $table.Rows) { + if ($row -isnot [System.Collections.IList] -or $row.Count -ne $table.Columns.Count) { + throw 'The Kusto response contains a row that does not match its columns.' + } + $values = [ordered]@{} + for ($columnIndex = 0; $columnIndex -lt $columnNames.Count; $columnIndex++) { + $values[$columnNames[$columnIndex]] = $row[$columnIndex] + } + $mappedRows.Add([PSCustomObject]$values) + } + $tables.Add([PSCustomObject]@{ Columns = $columnNames.ToArray(); Rows = $mappedRows.ToArray() }) + if ($table.TableName -eq 'QueryStatus' -or + ($tableIndex -gt 0 -and $seenNames.Contains('Severity') -and $seenNames.Contains('StatusDescription'))) { + $null = $statusIndexes.Add($tableIndex) + } + } + if ($tables.Count -gt 1) { + $contents = $tables[$tables.Count - 1] + if ($contents.Columns -contains 'Ordinal' -or $contents.Columns -contains 'Kind' -or + $resp.Tables[$tables.Count - 1].TableName -eq 'TableOfContents') { + if ($contents.Columns -notcontains 'Ordinal' -or $contents.Columns -notcontains 'Kind' -or $contents.Columns -notcontains 'Name') { + throw 'The Kusto response contains an incomplete table-of-contents schema.' + } + foreach ($entry in $contents.Rows) { + if ($entry.Kind -isnot [string] -or $entry.Name -isnot [string]) { + throw 'The Kusto response contains an invalid table-of-contents entry.' + } + if ($entry.Kind -ne 'QueryStatus') { continue } + if (($entry.Ordinal -isnot [int] -and $entry.Ordinal -isnot [long]) -or + $entry.Ordinal -lt 0 -or $entry.Ordinal -ge ($tables.Count - 1)) { + throw 'The Kusto response contains an invalid query-status table reference.' + } + $null = $statusIndexes.Add([int]$entry.Ordinal) + } + } + } + foreach ($statusIndex in $statusIndexes) { + $statusTable = $tables[$statusIndex] + if ($statusTable.Columns -notcontains 'Severity' -or $statusTable.Rows.Count -eq 0) { + throw 'The Kusto response contains an invalid query status.' + } + foreach ($status in $statusTable.Rows) { + if ($status.Severity -isnot [int] -and $status.Severity -isnot [long]) { + throw 'The Kusto response contains an invalid query severity.' + } + if ($status.Severity -le 2) { throw "The Kusto response reports a partial query failure: $($status.StatusDescription)" } + } + } + $rows = @() + if ($tables.Count) { $rows = @($tables[0].Rows) } + + return @{ Ok = $true; Rows = $rows; RowCount = $rows.Count; Error = $null } + } + catch { + $msg = $_.Exception.Message + # Surface the Kusto error body when present (one-api error envelope). + $detail = $_.ErrorDetails.Message + if (-not $detail) { + try { + $respStream = $_.Exception.Response.GetResponseStream() + if ($respStream) { + $reader = New-Object System.IO.StreamReader($respStream) + $detail = $reader.ReadToEnd() + $reader.Dispose() + } + } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + } + if ($detail) { + try { + $err = $detail | ConvertFrom-Json -ErrorAction Stop + if ($err.error -and $err.error.'@message') { $msg = $err.error.'@message' } + elseif ($err.error -and $err.error.message) { $msg = $err.error.message } + } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + } + return @{ Ok = $false; Rows = @(); RowCount = 0; Error = "Kusto query failed: $msg" } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 new file mode 100644 index 000000000..2fcf15e1a --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/MgCostScope.ps1 @@ -0,0 +1,173 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Read-only: resolves a scope and changes no state.')] +param() + +# -- MG-Scope State -------------------------------------------------------- +# First cost module that gets 401/403 at MG scope sets this to $true. +# All subsequent modules check it and skip to per-sub immediately. +$script:MgCostScopeFailed = $false + +function Test-MgCostScope { + return (-not $script:MgCostScopeFailed) +} + +function Set-MgCostScopeFailed { + $script:MgCostScopeFailed = $true + Write-Host " Management-group cost queries are disabled for this scan. Subsequent cost modules will query the selected subscriptions individually." -ForegroundColor Yellow +} + +# -- Resolved Cost MG Scope ------------------------------------------------ +# Many orgs assign Cost Management Reader on a CHILD management group rather +# than the tenant-root group (whose id == tenant GUID), so querying +# managementGroups/ returns 401. Resolve-CostMgId probes the tenant +# root after a bounded set of accessible MGs and caches the first usable scope +# for this tenant. Falls back to per-subscription when none work. +$script:CostMgId = $null +$script:CostMgTenantId = $null +$script:CostMgCoverage = @{} + +function Reset-CostMgScope { + $script:CostMgId = $null + $script:CostMgTenantId = $null + $script:MgCostScopeFailed = $false + $script:CostMgCoverage = @{} +} + +function Resolve-CostMgId { + param( + [Parameter(Mandatory)] + [string]$TenantId + ) + + if ($script:CostMgTenantId -ne $TenantId) { + Reset-CostMgScope + $script:CostMgTenantId = $TenantId + } + if ($script:MgCostScopeFailed) { return $null } + if ($script:CostMgId) { return $script:CostMgId } + + # Candidates are the management groups the caller can actually see. Cost + # access usually lives on a child MG (not the tenant root), so probe the + # visible MGs first and fall back to the tenant root last. A throttled + # (429) probe must not abandon discovery - keep trying the rest. + $probeLimit = 25 + $candidates = [System.Collections.Generic.List[string]]::new() + $seenCandidates = @{} + + try { + $listResp = Invoke-AzRestMethodWithRetry -Path '/providers/Microsoft.Management/managementGroups?api-version=2020-05-01' -Method GET + if ($listResp -and $listResp.StatusCode -eq 200) { + $mgs = (Get-FinOpsListResult -FirstResponse $listResp -Context 'management-group discovery').value + foreach ($mg in @($mgs)) { + $name = [string]$mg.name + if (-not [string]::IsNullOrWhiteSpace($name) -and $name -ne $TenantId -and -not $seenCandidates.ContainsKey($name)) { + $seenCandidates[$name] = $true + $candidates.Add($name) + } + } + } + } + catch { + Write-Warning "Management-group discovery is incomplete; only the tenant root can be probed. $($_.Exception.Message)" + } + + # Tenant root as a last-resort candidate (covers orgs where the cost role + # is assigned at the root management group). + if ($candidates.Count -ge $probeLimit) { + Write-Warning "Management-group cost discovery is limited to $probeLimit candidates, including the tenant root. Remaining groups won't be probed. If these candidates fail, cost scans query the selected subscriptions individually." + $candidates.RemoveRange(($probeLimit - 1), ($candidates.Count - $probeLimit + 1)) + } + $candidates.Add($TenantId) + + $probeBody = @{ + type = 'ActualCost' + timeframe = 'MonthToDate' + dataset = @{ + granularity = 'None' + aggregation = @{ totalCost = @{ name = 'Cost'; function = 'Sum' } } + } + } | ConvertTo-Json -Depth 10 + + # Use a low retry budget per probe so a throttled candidate fails fast and + # we move on to the next one. The real cost queries keep the full budget. + foreach ($mgId in $candidates) { + $path = "/providers/Microsoft.Management/managementGroups/$mgId/providers/Microsoft.CostManagement/query?api-version=2023-11-01" + $resp = Invoke-AzRestMethodWithRetry -Path $path -Method POST -Payload $probeBody -MaxRetries 2 + if ($resp -and $resp.StatusCode -eq 200) { + $script:CostMgId = $mgId + if ($mgId -ne $TenantId) { + Write-Host " Cost scope resolved to management group '$mgId'." -ForegroundColor Yellow + } + return $mgId + } + # 401/403 = no cost role here; 429 = throttled; anything else = unusable + # at this scope. In every case, keep probing the remaining candidates so + # a throttled tenant-root probe never blocks reaching the child MG. + } + + Set-MgCostScopeFailed + return $null +} + +# -- Selected-Subscription Coverage ----------------------------------------- +# Resolve-CostMgId returns the first management group that accepts a cost +# query, which might not contain every selected subscription. Scans that can't +# detect a missing subscription in their grouped response confirm membership +# first. An unverified scope makes the caller query subscriptions individually. +function Test-CostMgCoverage { + param( + [Parameter(Mandatory)][string]$ManagementGroupId, + [Parameter(Mandatory)][string]$TenantId, + [object[]]$Subscriptions + ) + + $ids = @($Subscriptions | ForEach-Object { [string]$_.Id } | Where-Object { $_ } | Sort-Object -Unique) + if ($ids.Count -eq 0) { return $false } + if ($ManagementGroupId -eq $TenantId) { return $true } + + $key = "$ManagementGroupId|$($ids -join ',')".ToLowerInvariant() + if ($script:CostMgCoverage.ContainsKey($key)) { return $script:CostMgCoverage[$key] } + + $covered = $false + try { + $ancestry = Search-AzGraphSafe -Query "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, ancestors = properties.managementGroupAncestorsChain" -Subscription $ids -First 1000 -All + $members = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($row in @($ancestry.Data)) { + $ancestors = if ($row.ancestors -is [string]) { @($row.ancestors | ConvertFrom-Json -ErrorAction Stop) } else { @($row.ancestors) } + if (@($ancestors | Where-Object { [string]$_.name -eq $ManagementGroupId }).Count -gt 0) { [void]$members.Add([string]$row.subscriptionId) } + } + $covered = @($ids | Where-Object { -not $members.Contains($_) }).Count -eq 0 + } + catch { + Write-Verbose "Management-group membership could not be verified: $($_.Exception.Message)" + } + if (-not $covered) { + Write-Host " Management group '$ManagementGroupId' isn't verified to contain every selected subscription. This scan queries the selected subscriptions individually." -ForegroundColor Yellow + } + $script:CostMgCoverage[$key] = $covered + return $covered +} + +# -- Shared Subscription-Scope Filter ------------------------------------- +# When the user picks a subset of subscriptions we still want the single fast +# MG-scope cost query (one call covers the whole management group), but scoped +# to only the selected subscriptions. The Cost Management Query API supports a +# server-side dataset filter on the SubscriptionId dimension, so we build that +# filter once and inject it into each cost query body. This avoids the slow +# per-subscription fan-out (N calls per timeframe) that hammers the throttle. +function Get-CostSubscriptionFilter { + param([object[]]$Subscriptions) + if (-not $Subscriptions -or $Subscriptions.Count -eq 0) { return $null } + $ids = @($Subscriptions | ForEach-Object { [string]$_.Id } | Where-Object { $_ }) + if ($ids.Count -eq 0) { return $null } + return @{ + dimensions = @{ + name = 'SubscriptionId' + operator = 'In' + values = $ids + } + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 new file mode 100644 index 000000000..a7e2a88a1 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1 @@ -0,0 +1,1724 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by hub schema and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Private helper named for the collection it processes.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Reads Azure data and manages private local cache and download files only.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity across the hub reader family.')] +param() + +########################################################################### +# READ-FINOPSHUBDATA.PS1 +# FINOPS HUB STORAGE DATA READER +########################################################################### +# Purpose: Read cost data from a FinOps Hub storage account +# Date: Created for FinOps Multitool TUI integration +# +# Description: +# Reads FOCUS-schema cost data from a Hub storage account. +# Prefers parquet from the ingestion container (normalized FOCUS); +# falls back to CSV from msexports if ingestion is empty. +# Parquet.Net + all transitive deps are restored with whichever NuGet client the +# host supports (nuget.exe on Windows, the dotnet SDK on Linux), honouring the +# machine's configured feeds. Parquet setup is unavailable on macOS. +# +# 1. Checks ingestion container for parquet (preferred) +# 2. Falls back to msexports CSV if no parquet found +# 3. Returns FOCUS-schema cost objects for Multitool consumption +# +# ── Parameters ────────────────────────────────────────────── +# StorageAccountName Hub storage account name +# ResourceGroupName Resource group containing the storage account +# Months Number of months to read (default: 1) +# +# Prerequisites: +# - Az.Storage module +# - Storage Blob Data Reader RBAC on the Hub storage account +########################################################################### + +function ConvertTo-HashtableFromJson { + param([string]$Json) + return ConvertFrom-ExportTagString -Raw $Json +} + +function New-FinOpsPrivateDirectory { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path, + [switch]$RequireNew + ) + + $fullPath = [IO.Path]::GetFullPath($Path) + if ($fullPath -match '^[\\/]{2}|[\x00-\x1f]') { throw 'Private data requires a local filesystem directory.' } + $items = [Collections.Generic.List[object]]::new() + $missingDirectories = [Collections.Generic.List[string]]::new() + $ancestor = $fullPath + while ($ancestor) { + try { + if (([IO.File]::GetAttributes($ancestor) -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Private data paths cannot contain links or junctions.' } + $item = Get-Item -LiteralPath $ancestor -Force -ErrorAction Stop + if (-not $item.PSIsContainer) { throw 'Private data paths must be directories.' } + $items.Add($item) + } + catch [IO.FileNotFoundException] { $missingDirectories.Add($ancestor) } + catch [IO.DirectoryNotFoundException] { $missingDirectories.Add($ancestor) } + $ancestor = [IO.Path]::GetDirectoryName($ancestor) + } + $parentDirectory = [IO.Path]::GetDirectoryName($fullPath) + $creationParent = if ($missingDirectories.Count -gt 0) { $items[0].FullName } else { $parentDirectory } + if (Test-Path -LiteralPath $fullPath) { + if ($RequireNew) { throw 'The private data directory already exists.' } + foreach ($item in Get-ChildItem -LiteralPath $fullPath -Force -Recurse -ErrorAction Stop) { $items.Add($item) } + } + if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + try { + $trusted = @($identity.User.Value, 'S-1-5-18', 'S-1-5-32-544', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464') + $writeRights = [Security.AccessControl.FileSystemRights]'Write, Delete, DeleteSubdirectoriesAndFiles, ChangePermissions, TakeOwnership' + $replacementRights = [Security.AccessControl.FileSystemRights]'Delete, DeleteSubdirectoriesAndFiles, ChangePermissions, TakeOwnership' + foreach ($item in $items) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Private data cannot contain linked files or directories.' } + $security = Get-Acl -LiteralPath $item.FullName -ErrorAction Stop + if ($security.GetOwner([Security.Principal.SecurityIdentifier]).Value -notin $trusted) { throw 'The private data directory contains an untrusted owner.' } + $rights = if ($item.FullName -eq $creationParent -or $item.FullName -eq $parentDirectory -or $item.FullName -eq $fullPath -or + $item.FullName.StartsWith($fullPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { $writeRights } else { $replacementRights } + foreach ($rule in $security.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) { + if (($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue } + if ($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin $trusted -and ($rule.FileSystemRights -band $rights) -ne 0) { + throw 'The private data directory permits writes by another account.' + } + } + } + } + finally { $identity.Dispose() } + } + else { + $identityCommand = Get-Command id -CommandType Application -ErrorAction Stop + $ownerId = (& $identityCommand.Source -u).Trim() + if ($LASTEXITCODE -ne 0 -or $ownerId -notmatch '^\d+$') { throw 'The current user ID could not be verified.' } + $stat = Get-Command stat -CommandType Application -ErrorAction Stop + foreach ($item in $items) { + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Private data cannot contain linked files or directories.' } + $metadata = if ($IsMacOS) { & $stat.Source -f '%u:%p' $item.FullName } else { & $stat.Source -c '%u:%a' -- $item.FullName } + if ($LASTEXITCODE -ne 0 -or [string]$metadata -notmatch '^(\d+):([0-7]+)$') { throw 'Private data ownership and permissions could not be verified.' } + $itemOwner = $Matches[1] + $mode = [Convert]::ToInt32($Matches[2], 8) -band 4095 + $isAncestor = $item.FullName -ne $fullPath -and -not $item.FullName.StartsWith($fullPath + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) + $stickyAncestor = $isAncestor -and $item.FullName -ne $creationParent -and $item.FullName -ne $parentDirectory -and $itemOwner -eq '0' -and ($mode -band 512) -ne 0 + if (($itemOwner -ne $ownerId -and -not ($isAncestor -and $itemOwner -eq '0')) -or (($mode -band 18) -ne 0 -and -not $stickyAncestor)) { + throw 'Private data must be owned by the current user and not writable by other accounts.' + } + } + } + $directoriesToCreate = @($missingDirectories.ToArray()) + [array]::Reverse($directoriesToCreate) + if ($directoriesToCreate.Count -eq 0) { $directoriesToCreate = @($fullPath) } + foreach ($directoryPath in $directoriesToCreate) { + if ($IsWindows) { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + try { + $security = [Security.AccessControl.DirectorySecurity]::new() + $security.SetAccessRuleProtection($true, $false) + $security.SetOwner($identity.User) + $security.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new( + $identity.User, [Security.AccessControl.FileSystemRights]::FullControl, + [Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit', + [Security.AccessControl.PropagationFlags]::None, [Security.AccessControl.AccessControlType]::Allow)) + $directory = [IO.DirectoryInfo]::new($directoryPath) + if ($directory.Exists) { [IO.FileSystemAclExtensions]::SetAccessControl($directory, $security) } + else { [IO.FileSystemAclExtensions]::Create($directory, $security) } + } + finally { $identity.Dispose() } + } + else { + $unixModeType = 'System.IO.UnixFileMode' -as [type] + if ($unixModeType) { + [void][IO.Directory]::CreateDirectory($directoryPath, [Enum]::ToObject($unixModeType, 448)) + [IO.File]::SetUnixFileMode($directoryPath, [Enum]::ToObject($unixModeType, 448)) + } + else { + $command = Get-Command $(if (Test-Path -LiteralPath $directoryPath) { 'chmod' } else { 'mkdir' }) -CommandType Application -ErrorAction Stop + if ([IO.Directory]::Exists($directoryPath)) { & $command.Source 700 $directoryPath } + else { & $command.Source -m 700 $directoryPath } + if ($LASTEXITCODE -ne 0) { throw 'A private data directory could not be created.' } + } + } + } + return $fullPath +} + +function Get-FinOpsParquetCachePath { + # Per-user cache, not the world-writable shared temp dir. On a multi-user or + # shared host, %TEMP%/tmp lets another local principal pre-plant DLLs at a + # predictable path that we would then load into this process. + $base = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData, [Environment+SpecialFolderOption]::DoNotVerify) + if ([string]::IsNullOrWhiteSpace($base)) { throw 'Private application data is unavailable. The Parquet cache cannot use a shared temporary directory.' } + return (Join-Path (Join-Path $base 'FinOpsMultitool') 'parquet') +} + +# Every managed/native DLL the loader can pull in, in a stable order. +function Get-ParquetPayloadFile { + param([Parameter(Mandatory)][string]$BasePath) + $roots = @((Join-Path $BasePath 'lib'), (Join-Path $BasePath 'runtimes')) + $files = foreach ($r in $roots) { + if (Test-Path -LiteralPath $r) { + # Native payloads are .dll on Windows but .so/.dylib elsewhere, and a + # .dll-only filter would leave those unhashed on Linux and macOS. + Get-ChildItem -LiteralPath $r -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '\.(dll|dylib|so)(\.\d+)*$' } + } + } + return @($files | Sort-Object FullName) +} + +function New-ParquetManifest { + param( + [Parameter(Mandatory)][string]$BasePath, + [Parameter(Mandatory)][string]$ManifestPath + ) + $entries = @{} + foreach ($f in (Get-ParquetPayloadFile -BasePath $BasePath)) { + $rel = $f.FullName.Substring($BasePath.Length).TrimStart('\', '/') + $entries[$rel] = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash + } + [PSCustomObject]@{ + version = '4.24.0' + created = (Get-Date).ToUniversalTime().ToString('o') + files = $entries + } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $ManifestPath -Encoding UTF8 +} + +# Re-hashes every payload DLL against the manifest recorded at install time. +# Runs on EVERY load, so a tampered cache cannot ride in behind a marker file. +function Test-ParquetManifest { + param( + [Parameter(Mandatory)][string]$BasePath, + [Parameter(Mandatory)][string]$ManifestPath + ) + if (-not (Test-Path -LiteralPath $ManifestPath)) { return $false } + try { + $manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json -ErrorAction Stop + } + catch { return $false } + if (-not $manifest.files) { return $false } + + $recorded = @{} + foreach ($p in $manifest.files.PSObject.Properties) { $recorded[$p.Name] = [string]$p.Value } + if ($recorded.Count -eq 0) { return $false } + + $onDisk = Get-ParquetPayloadFile -BasePath $BasePath + if ($onDisk.Count -ne $recorded.Count) { return $false } + + foreach ($f in $onDisk) { + $rel = $f.FullName.Substring($BasePath.Length).TrimStart('\', '/') + if (-not $recorded.ContainsKey($rel)) { return $false } + if ((Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash -ne $recorded[$rel]) { return $false } + } + return $true +} + +function Get-FinOpsParquetPackageLock { + @( + @{ Id = 'Parquet.Net'; Version = '4.24.0'; Sha512 = 'UAWKArPr96Oea1PfCvZqYhQ7xS+LewgMnMXxZijTAMKuujTYjf9jwLHzOB52e2wrBxGpkdmG3suIPdb7kNRRqQ==' } + @{ Id = 'IronCompress'; Version = '1.5.2'; Sha512 = '9ZxjgVMP7BBfCQSQ14IT+05XABHz5lTiZWd7t7Jpg+0bVOZHtuwjHKnExUJJgM8OHKqyqolNkqmlyd8b/gJq7Q==' } + @{ Id = 'Microsoft.Data.Analysis'; Version = '0.21.1'; Sha512 = 'OuEm3LZ6GoZCMU3X5hh02fGye9Iu4tPYvSbcHl62X41jZHQ+W5Z3CoJgzIOOKqUK7lpOkhTz6v/j/5ena2eh8g==' } + @{ Id = 'Microsoft.IO.RecyclableMemoryStream'; Version = '3.0.0'; Sha512 = '7xVI6zAdiOAKchkPULjy33WRp3MPxwoYIB3kx0QWVdF9flb3DhoiGtSHre7c/1R9DIe3shQrriYeiwXWOkA3yg==' } + @{ Id = 'Snappier'; Version = '1.3.1'; Sha512 = 'uo6Wvo127r6j6zfBayLdR3LxJ6eu82hM4iH+wJBhyKuN79tpCXlTIKDOE8Du0Cohq3VQ4o7GCpoXQtjF37ZC6w==' } + @{ Id = 'ZstdSharp.Port'; Version = '0.8.1'; Sha512 = 'se/fJ+LE7xM4dpUxhwHS8DROQZWzZMs3MGF2bcgmnXJ2rcCiKCyA2QDph7soErYohHJYeECavuul95bNgTldhQ==' } + @{ Id = 'Microsoft.ML.DataView'; Version = '3.0.1'; Sha512 = 'OhBIx0fq4p5ggwJnFFWSthy7FxuTqj61qVzinU4U1e6JuS3LqBt+bMe8MxFX69yjwvCU2lT0ja2ln7Kfuk/N9A==' } + @{ Id = 'Apache.Arrow'; Version = '11.0.0'; Sha512 = 'PUK1/AQdcQg3epd1DRbiXQ3jhyY0noWm0MVTN3OTmKBsmvaapbO1My827Ui0RzH3u9P4avjKly52sX8sv6rQ3w==' } + @{ Id = 'System.Buffers'; Version = '4.5.1'; Sha512 = 'gNphWOVbm89+C15jebnPRaYykU8De1PFv1YJV24814IfeGGVa3PXRHDS0MLlbdI1pe9Mpv/n4ZK4INwtAjqv8g==' } + @{ Id = 'System.Memory'; Version = '4.5.5'; Sha512 = '6MjlNsl7lKw0Q8lAsw2tQ89ul9x6jD2Yk3EEj+dOFoYGOE9eAUO9wNhvd4O/n97oQXlkyzqKXXUnE+kLElFy3A==' } + @{ Id = 'System.Runtime.CompilerServices.Unsafe'; Version = '6.0.0'; Sha512 = '1AVzAb5OxJNvJLnOADtexNmWgattm2XVOT3TjQTN7Dd4SqoSwai1CsN2fth42uQldJSQdz/sAec0+TzxBFgisw==' } + @{ Id = 'System.Collections.Immutable'; Version = '1.5.0'; Sha512 = 'T5XGQlcHhEO75Qx38GGCUDPdk4n/7yrRmTgy4yczzJV8alPHaxPU55TBC2UFrkQ42bu34sZPfK0dU+nWZUOEJA==' } + ) +} + +function Get-VerifiedParquetPackage { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$PackageDir) + + $expected = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + foreach ($package in Get-FinOpsParquetPackageLock) { $expected[$package.Sha512] = $package } + $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + $verified = [Collections.Generic.List[object]]::new() + foreach ($package in Get-ChildItem -LiteralPath $PackageDir -Filter '*.nupkg' -Recurse -File -ErrorAction Stop) { + $stream = [IO.File]::OpenRead($package.FullName) + $algorithm = [Security.Cryptography.SHA512]::Create() + try { $hash = [Convert]::ToBase64String($algorithm.ComputeHash($stream)) } + finally { $algorithm.Dispose(); $stream.Dispose() } + if (-not $expected.ContainsKey($hash) -or -not $seen.Add($hash)) { throw 'The Parquet cache contains an unexpected or duplicate package archive.' } + $verified.Add([pscustomobject]@{ Path = $package.FullName; Id = $expected[$hash].Id; Version = $expected[$hash].Version }) + } + if ($verified.Count -ne $expected.Count) { throw 'The Parquet cache is missing pinned package archives.' } + return $verified.ToArray() +} + +# Validates package signatures with the configured NuGet client. +# TLS alone only proves who we talked to, not that the payload is authentic. +function Assert-NuGetPackageSignature { + param( + [Parameter(Mandatory)][object]$Client, + [Parameter(Mandatory)][string]$PackageDir + ) + $nupkgs = @(Get-VerifiedParquetPackage -PackageDir $PackageDir) + foreach ($pkg in $nupkgs) { + $output = if ($Client.Kind -eq 'dotnet') { + & $Client.Path nuget verify $pkg.Path --all 2>&1 + } + else { + & $Client.Path verify -Signatures $pkg.Path 2>&1 + } + if ($LASTEXITCODE -ne 0) { + throw "NuGet signature verification failed for $($pkg.Id) $($pkg.Version): $($output -join ' ')" + } + } +} + +# Runtime identifier for the native payload: IronCompress ships a separate +# native library per RID, so the host's own RID decides which one to stage. +function Get-FinOpsNativeRid { + $isWin = if ($null -ne $IsWindows) { $IsWindows } else { $true } + $os = if ($isWin) { 'win' } elseif ($IsMacOS) { 'osx' } else { 'linux' } + $arch = try { + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + } + catch { + if ([Environment]::Is64BitOperatingSystem) { 'x64' } else { 'x86' } + } + return "$os-$arch" +} + +# Picks a package client that reads the machine's NuGet configuration, so a +# corporate feed proxy, mirror or credential provider keeps working. nuget.exe +# is a Windows binary, so on macOS and Linux it is neither downloaded nor run. +function Resolve-NuGetClient { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$CachePath) + + $isWin = if ($null -ne $IsWindows) { $IsWindows } else { $true } + + if (-not $isWin) { + if ($IsMacOS) { + return [PSCustomObject]@{ Kind = $null; Path = $null; Reason = 'NuGet signed-package verification is not supported on macOS; use a configured Kusto endpoint or available CSV exports' } + } + $dotnet = Get-Command dotnet -CommandType Application -ErrorAction SilentlyContinue + if (-not $dotnet) { + return [PSCustomObject]@{ Kind = $null; Path = $null; Reason = 'the .NET SDK is not installed (dotnet is not on PATH)' } + } + # A runtime-only install still answers 'dotnet' but cannot restore, and + # an SDK older than the restore project's target framework fails late + # with a confusing error, so both are rejected up front. + $sdks = @(& $dotnet.Source --list-sdks 2>$null) + if ($sdks.Count -eq 0) { + return [PSCustomObject]@{ Kind = $null; Path = $null; Reason = 'only the .NET runtime is present and restoring packages needs the .NET SDK' } + } + $hasSupportedSdk = $false + foreach ($line in $sdks) { + if ([string]$line -match '^\s*(\d+)\.' -and [int]$matches[1] -ge 8) { $hasSupportedSdk = $true; break } + } + if (-not $hasSupportedSdk) { + return [PSCustomObject]@{ Kind = $null; Path = $null; Reason = 'the installed .NET SDK is older than 8.0, which the Parquet packages target' } + } + return [PSCustomObject]@{ Kind = 'dotnet'; Path = $dotnet.Source; Reason = $null } + } + + $nugetExe = Join-Path $CachePath 'nuget.exe' + if (-not (Test-Path -LiteralPath $nugetExe)) { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri 'https://dist.nuget.org/win-x86-commandline/latest/nuget.exe' -OutFile $nugetExe -UseBasicParsing + } + + # Validated on every use, not just on download: a cached copy in a writable + # path can be replaced between runs. The download URL is mutable ('/latest/') + # too, so a tampered or unsigned binary is deleted and refused rather than + # executed. The subject is matched as a whole RDN so a crafted value such as + # 'O=Not Microsoft Corporation Ltd' cannot satisfy it. + $sig = Get-AuthenticodeSignature -FilePath $nugetExe + $signerSubject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '' } + $signerOk = $sig.SignerCertificate -and ($signerSubject -match '(^|,\s*)O=Microsoft Corporation(\s*,|$)') + if ($sig.Status -ne 'Valid' -or -not $signerOk) { + Remove-Item $nugetExe -Force -ErrorAction SilentlyContinue + throw "nuget.exe failed Authenticode validation (status: $($sig.Status); signer: $signerSubject). Refusing to execute it." + } + + return [PSCustomObject]@{ Kind = 'nuget.exe'; Path = $nugetExe; Reason = $null } +} + +# Restores the pinned dependency set with the host's configured feeds. +function Invoke-NuGetRestore { + [CmdletBinding()] + param( + [Parameter(Mandatory)][object]$Client, + [Parameter(Mandatory)][string]$PackageId, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$PackageDir, + [Parameter(Mandatory)][string]$WorkingPath + ) + + $packages = @(Get-FinOpsParquetPackageLock) + if (@($packages | Where-Object { $_.Id -eq $PackageId -and $_.Version -eq $Version }).Count -ne 1) { + throw 'Only the pinned Parquet dependency set can be restored.' + } + $projDir = Join-Path $WorkingPath 'restore' + New-Item -ItemType Directory -Path $projDir -Force | Out-Null + $document = [Xml.XmlDocument]::new() + if ($Client.Kind -eq 'dotnet') { + $document.LoadXml('net8.0false') + foreach ($package in $packages) { + $reference = $document.CreateElement('PackageReference') + $reference.SetAttribute('Include', $package.Id) + $reference.SetAttribute('Version', "[$($package.Version)]") + [void]$document.SelectSingleNode('/Project/ItemGroup').AppendChild($reference) + } + $proj = Join-Path $projDir 'parquet-restore.csproj' + $document.Save($proj) + $output = & $Client.Path restore $proj --packages $PackageDir 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "dotnet restore failed for $PackageId $Version : $($output -join ' ')" + } + } + else { + $document.LoadXml('') + foreach ($package in $packages) { + $reference = $document.CreateElement('package') + $reference.SetAttribute('id', $package.Id) + $reference.SetAttribute('version', $package.Version) + $reference.SetAttribute('targetFramework', 'net8.0') + [void]$document.DocumentElement.AppendChild($reference) + } + $config = Join-Path $projDir 'packages.config' + $document.Save($config) + $output = & $Client.Path restore $config -PackagesDirectory $PackageDir -NonInteractive 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "nuget.exe restore failed for $PackageId $Version : $($output -join ' ')" + } + } +} + +# nuget.exe stages ./, dotnet restore stages //, so a +# package root is any directory that directly holds lib/ or runtimes/. +function Get-RestoredPackageRoot { + param([Parameter(Mandatory)][string]$PackageDir) + $roots = [System.Collections.Generic.List[string]]::new() + foreach ($d in @(Get-ChildItem -LiteralPath $PackageDir -Directory -ErrorAction SilentlyContinue)) { + if ((Test-Path -LiteralPath (Join-Path $d.FullName 'lib')) -or (Test-Path -LiteralPath (Join-Path $d.FullName 'runtimes'))) { + [void]$roots.Add($d.FullName) + continue + } + foreach ($v in @(Get-ChildItem -LiteralPath $d.FullName -Directory -ErrorAction SilentlyContinue)) { + if ((Test-Path -LiteralPath (Join-Path $v.FullName 'lib')) -or (Test-Path -LiteralPath (Join-Path $v.FullName 'runtimes'))) { + [void]$roots.Add($v.FullName) + } + } + } + return @($roots) +} + +function Assert-ParquetPackagePayload { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$BasePath, + [Parameter(Mandatory)][string]$PackageDir + ) + + $payload = @(Get-ParquetPayloadFile -BasePath $BasePath) + if ($payload.Count -eq 0 -or -not (Test-Path -LiteralPath (Join-Path $BasePath 'lib/Parquet.dll') -PathType Leaf)) { + throw 'The Parquet payload is incomplete.' + } + $verifiedHashes = @{} + $frameworks = @('net8.0', 'net7.0', 'net6.0', 'net5.0', 'netcoreapp3.1', 'netstandard2.1', 'netstandard2.0') + $rid = Get-FinOpsNativeRid + foreach ($package in Get-VerifiedParquetPackage -PackageDir $PackageDir) { + $archive = [System.IO.Compression.ZipFile]::OpenRead($package.Path) + try { + $framework = $frameworks | Where-Object { $archive.Entries.FullName -like "lib/$_/*" } | Select-Object -First 1 + foreach ($entry in $archive.Entries) { + $relative = if ($framework -and $entry.FullName -like "lib/$framework/*.dll" -and $entry.FullName -match '^lib/[^/]+/([^/]+\.dll)$') { "lib/$($Matches[1])" } + elseif ($entry.FullName.StartsWith("runtimes/$rid/native/", [StringComparison]::Ordinal) -and $entry.FullName -match '^runtimes/[^/]+/native/[^/]+\.(dll|dylib|so)(\.\d+)*$') { $entry.FullName } + else { continue } + $stream = $entry.Open() + $algorithm = [System.Security.Cryptography.SHA256]::Create() + try { $hash = [BitConverter]::ToString($algorithm.ComputeHash($stream)).Replace('-', '') } + finally { $algorithm.Dispose(); $stream.Dispose() } + if (-not $verifiedHashes.ContainsKey($relative)) { $verifiedHashes[$relative] = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) } + [void]$verifiedHashes[$relative].Add($hash) + } + } + finally { $archive.Dispose() } + } + foreach ($file in $payload) { + if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw 'Linked Parquet payload files are not allowed.' } + $relative = $file.FullName.Substring($BasePath.Length).TrimStart('\', '/').Replace('\', '/') + $hash = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash + if (-not $verifiedHashes.ContainsKey($relative) -or -not $verifiedHashes[$relative].Contains($hash)) { + throw "Parquet payload '$relative' does not match a verified package." + } + } +} + +function Install-ParquetReader { + [CmdletBinding()] + param() + + $script:FinOpsParquetUnavailableReason = $null + # Check if Parquet is already loaded in this session + $loaded = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { + $_.GetName().Name -eq 'Parquet' + } + if ($loaded) { return $true } + + try { $parquetDir = New-FinOpsPrivateDirectory -Path (Get-FinOpsParquetCachePath) } + catch { + $script:FinOpsParquetUnavailableReason = $_.Exception.Message + Write-Warning "The Parquet cache is unavailable: $($_.Exception.Message)" + return $false + } + $manifestFile = Join-Path $parquetDir 'parquet-manifest.json' + + # Cached checksums are not provenance: verify packages and staged bytes too. + if (Test-Path -LiteralPath $manifestFile) { + try { + if (-not (Test-ParquetManifest -BasePath $parquetDir -ManifestPath $manifestFile)) { throw 'Parquet cache failed its checksum check.' } + try { + $cachedClient = Resolve-NuGetClient -CachePath $parquetDir + } + catch { + $script:FinOpsParquetUnavailableReason = "The cached reader could not be verified: $($_.Exception.Message)" + Write-Warning $script:FinOpsParquetUnavailableReason + return $false + } + if (-not $cachedClient.Kind) { + $script:FinOpsParquetUnavailableReason = "A package signature verifier is required before loading the cached reader. $($cachedClient.Reason)" + Write-Warning $script:FinOpsParquetUnavailableReason + return $false + } + Assert-NuGetPackageSignature -Client $cachedClient -PackageDir (Join-Path $parquetDir 'packages') + Assert-ParquetPackagePayload -BasePath $parquetDir -PackageDir (Join-Path $parquetDir 'packages') + $null = New-FinOpsPrivateDirectory -Path $parquetDir + Import-ParquetAssemblies -BasePath $parquetDir + return $true + } + catch { + Write-Warning "The cached Parquet reader failed verification or loading: $($_.Exception.Message) Reinstalling." + try { + $null = New-FinOpsPrivateDirectory -Path $parquetDir + Remove-Item -LiteralPath $parquetDir -Recurse -Force -ErrorAction Stop + } + catch { + $script:FinOpsParquetUnavailableReason = "Could not remove the unusable Parquet cache: $($_.Exception.Message)" + Write-Warning $script:FinOpsParquetUnavailableReason + return $false + } + } + } + + $script:FinOpsParquetUnavailableReason = $null + $client = $null + try { + if (Test-Path -LiteralPath $parquetDir) { + $null = New-FinOpsPrivateDirectory -Path $parquetDir + Remove-Item -LiteralPath $parquetDir -Recurse -Force -ErrorAction Stop + } + $null = New-FinOpsPrivateDirectory -Path $parquetDir + $client = Resolve-NuGetClient -CachePath $parquetDir + } + catch { + $script:FinOpsParquetUnavailableReason = $_.Exception.Message + Write-Warning "Failed to prepare the Parquet reader: $($_.Exception.Message)" + return $false + } + + # No usable client is a reportable outcome, not a silent downgrade: the + # caller states the reason before it changes where the numbers come from. + if (-not $client.Kind) { + $script:FinOpsParquetUnavailableReason = $client.Reason + return $false + } + + Write-Host " Installing Parquet reader (one-time setup)..." -ForegroundColor DarkGray + + try { + $pkgDir = Join-Path $parquetDir 'packages' + Invoke-NuGetRestore -Client $client -PackageId 'Parquet.Net' -Version '4.24.0' -PackageDir $pkgDir -WorkingPath $parquetDir + + # Verify package signatures on the fetched packages before any of + # their assemblies are copied or loaded into this process. + Assert-NuGetPackageSignature -Client $client -PackageDir $pkgDir + + # Copy managed DLLs to flat directory (prefer net8.0 > net6.0 > netstandard2.0) + $libDir = Join-Path $parquetDir 'lib' + New-Item -ItemType Directory -Path $libDir -Force | Out-Null + + $fxPriority = @('net8.0', 'net7.0', 'net6.0', 'net5.0', 'netcoreapp3.1', 'netstandard2.1', 'netstandard2.0') + $rid = Get-FinOpsNativeRid + $ridCandidates = @($rid) + # Matches what the integrity manifest hashes, including versioned names + # such as libfoo.so.1 - a plain *.so filter would skip those and stage + # an incomplete set. + $nativePattern = if ($rid.StartsWith('win')) { '\.dll$' } elseif ($rid.StartsWith('osx')) { '\.dylib(\.\d+)*$' } else { '\.so(\.\d+)*$' } + + foreach ($pkgRoot in (Get-RestoredPackageRoot -PackageDir $pkgDir)) { + $libRoot = Join-Path $pkgRoot 'lib' + if (Test-Path -LiteralPath $libRoot) { + foreach ($fx in $fxPriority) { + $fxDir = Join-Path $libRoot $fx + if (Test-Path -LiteralPath $fxDir) { + Get-ChildItem -LiteralPath $fxDir -Filter '*.dll' -File | ForEach-Object { + Copy-Item $_.FullName $libDir -Force + } + break + } + } + } + + # IronCompress ships one native library per RID; stage the one this + # host can actually load rather than assuming win-x64. + foreach ($candidate in $ridCandidates) { + $nativeDir = Join-Path (Join-Path (Join-Path $pkgRoot 'runtimes') $candidate) 'native' + if (-not (Test-Path -LiteralPath $nativeDir)) { continue } + $targetNative = Join-Path (Join-Path (Join-Path $parquetDir 'runtimes') $candidate) 'native' + New-Item -ItemType Directory -Path $targetNative -Force | Out-Null + Get-ChildItem -LiteralPath $nativeDir -File | Where-Object { $_.Name -match $nativePattern } | ForEach-Object { + Copy-Item $_.FullName $targetNative -Force + } + break + } + } + + # Record hashes of everything we just staged so later loads can detect + # tampering instead of trusting a bare marker file. + Assert-ParquetPackagePayload -BasePath $parquetDir -PackageDir $pkgDir + New-ParquetManifest -BasePath $parquetDir -ManifestPath $manifestFile + + $null = New-FinOpsPrivateDirectory -Path $parquetDir + Import-ParquetAssemblies -BasePath $parquetDir + Write-Host " Parquet reader installed." -ForegroundColor DarkGray + return $true + } + catch { + $script:FinOpsParquetUnavailableReason = $_.Exception.Message + Write-Warning "Failed to install Parquet reader: $($_.Exception.Message)" + return $false + } +} + +function Import-ParquetAssemblies { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$BasePath + ) + + $libDir = Join-Path $BasePath 'lib' + + # Load order matters — dependencies before dependents + $loadOrder = @( + 'System.Buffers.dll' + 'System.Memory.dll' + 'System.Runtime.CompilerServices.Unsafe.dll' + 'System.Collections.Immutable.dll' + 'Microsoft.IO.RecyclableMemoryStream.dll' + 'ZstdSharp.dll' + 'Snappier.dll' + 'IronCompress.dll' + 'Apache.Arrow.dll' + 'Microsoft.ML.DataView.dll' + 'Microsoft.Data.Analysis.dll' + 'Parquet.dll' + ) + + foreach ($dll in $loadOrder) { + $path = Join-Path $libDir $dll + if (-not (Test-Path $path)) { continue } + + $asmName = [IO.Path]::GetFileNameWithoutExtension($dll) + $already = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { + $_.GetName().Name -eq $asmName + } + if ($already) { continue } + + try { + Add-Type -Path $path -ErrorAction Stop + } + catch { + # Swallow if the runtime already provides this assembly + $recheck = [AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { + $_.GetName().Name -eq $asmName + } + if (-not $recheck) { throw } + } + } +} + +function Read-ParquetFile { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Path + ) + + try { + $table = [Parquet.ParquetReader]::ReadTableFromFileAsync($Path, $null).GetAwaiter().GetResult() + if (-not $table -or $table.Count -eq 0) { return @() } + + $results = [System.Collections.Generic.List[PSCustomObject]]::new() + $colNames = @($table.Schema.Fields | ForEach-Object { $_.Name }) + + for ($rowIndex = 0; $rowIndex -lt $table.Count; $rowIndex++) { + $row = $table[$rowIndex] + if ($row.Values.Length -ne $colNames.Count) { throw 'Parquet row width does not match its schema.' } + $obj = [ordered]@{} + for ($columnIndex = 0; $columnIndex -lt $colNames.Count; $columnIndex++) { + $obj[$colNames[$columnIndex]] = $row.Values[$columnIndex] + } + $results.Add([PSCustomObject]$obj) + } + + return $results + } + catch { + throw "Failed to read Parquet file '$Path'; cost coverage is incomplete. $($_.Exception.Message)" + } +} + +# Resolve a row's subscription GUID. FOCUS exports use SubAccountId, older +# exports use SubscriptionId or x_SubscriptionId, and any of them may hold a +# full resource path rather than a bare GUID. +function Get-FinOpsHubRowSubscriptionId { + param([object]$Row) + + $props = $Row.PSObject.Properties.Name + $subId = if ($props -contains 'SubAccountId' -and $Row.SubAccountId) { $Row.SubAccountId } + elseif ($props -contains 'SubscriptionId' -and $Row.SubscriptionId) { $Row.SubscriptionId } + elseif ($props -contains 'x_SubscriptionId' -and $Row.x_SubscriptionId) { $Row.x_SubscriptionId } + else { '' } + + if ($subId -match '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') { + return $Matches[0].ToLower() + } + return ([string]$subId).ToLower() +} + +function Format-FinOpsByteSize { + param([long]$Bytes) + if ($Bytes -ge 1TB) { return "{0:N1} TB" -f ($Bytes / 1TB) } + if ($Bytes -ge 1GB) { return "{0:N1} GB" -f ($Bytes / 1GB) } + if ($Bytes -ge 1MB) { return "{0:N1} MB" -f ($Bytes / 1MB) } + if ($Bytes -ge 1KB) { return "{0:N1} KB" -f ($Bytes / 1KB) } + return "$Bytes bytes" +} + +function Test-FinOpsHubAccessDenied { + # Separates "storage refused us" from ordinary misses like a month with no + # data, which decides whether the caller reports unreachable or just unknown. + param([string]$Message) + return [bool]($Message -match 'not authorized|AuthorizationFailure|\(403\)|Forbidden|public access is not permitted|no longer allowed|denied') +} + +function Get-FinOpsHubSizeClass { + # Pure classification, deliberately free of I/O so every branch is testable + # without reaching storage. + [CmdletBinding()] + param( + [Parameter()] + [object[]]$Items, + + [Parameter()] + [long]$LargeThresholdBytes = 256MB, + + [Parameter()] + [int]$MaxFiles = 2000, + + # Set when the listing was cut short, which proves "large" on its own. + [Parameter()] + [switch]$Truncated + ) + + $bytes = 0L + $count = 0 + foreach ($item in @($Items)) { + if (-not $item) { continue } + if ($item.IsDirectory) { continue } + $bytes += [long]$item.Length + $count++ + } + + $partial = ($Truncated -or $count -ge $MaxFiles) + $atLeast = if ($partial) { 'at least ' } else { '' } + return @{ + Known = $true + Reachable = $true + Bytes = $bytes + FileCount = $count + IsLarge = ($bytes -ge $LargeThresholdBytes -or $partial) + Display = "$atLeast$(Format-FinOpsByteSize $bytes) across $atLeast$count file(s)" + Issue = $null + } +} + +function Measure-FinOpsHubSize { + # Size probe of the ingestion container, used to decide whether the storage + # reader is a reasonable choice before any data is downloaded. MaxCount bounds + # the listing itself, so a 40 GB hub costs no more to classify than a 40 MB one. + # + # Known = $false means the probe could not run (no permission, no container). + # Callers must treat that as "assume large" - an unknown hub is not a small one. + # Reachable = $false is stronger: storage denied access outright, so the reader + # cannot work at all and the caller should say so rather than warn about speed. + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$StorageAccountName, + + [Parameter()] + [int]$Months = 1, + + # Parquet is columnar and compressed; PSCustomObject rows are neither, so + # in-memory size is a large multiple of what is measured here. This stays + # deliberately conservative rather than modelling that expansion exactly. + [Parameter()] + [long]$LargeThresholdBytes = 256MB, + + # A hub can be large by file count as well as by bytes, and either shape + # makes the reader slow. + [Parameter()] + [int]$MaxFiles = 2000 + ) + + $result = @{ Known = $false; Reachable = $true; Bytes = 0L; FileCount = 0; IsLarge = $true; Display = 'unknown size'; Issue = $null } + + try { $ctx = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount -ErrorAction Stop } + catch { + $result.Reachable = $false + $result.Issue = $_.Exception.Message + return $result + } + + $collected = [System.Collections.Generic.List[object]]::new() + $enumerated = $false + $truncated = $false + $now = Get-Date + + for ($m = 0; $m -lt $Months -and -not $truncated; $m++) { + $d = $now.AddMonths(-$m) + $basePath = "Costs/$($d.ToString('yyyy'))/$($d.ToString('MM'))" + try { + # One over the cap is enough to prove the listing was truncated. + $items = @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'ingestion' -Path $basePath -Recurse -MaxCount ($MaxFiles + 1) -ErrorAction Stop) + $enumerated = $true + foreach ($item in $items) { [void]$collected.Add($item) } + if ($items.Count -gt $MaxFiles) { $truncated = $true } + } + catch { + # A missing month is normal; an auth or network denial is not. + $msg = [string]$_.Exception.Message + if (Test-FinOpsHubAccessDenied -Message $msg) { + $result.Reachable = $false + $result.Issue = $msg + return $result + } + continue + } + } + + if (-not $enumerated) { return $result } + + return Get-FinOpsHubSizeClass -Items $collected.ToArray() -LargeThresholdBytes $LargeThresholdBytes -MaxFiles $MaxFiles -Truncated:$truncated +} + +function ConvertTo-FinOpsHubManifestTime { + param($Value) + + # Manifest times are UTC. ConvertFrom-Json can turn them into local DateTime + # values, so normalize before comparing export runs. + if ($Value -is [datetime]) { + if ($Value.Kind -eq [DateTimeKind]::Local) { return $Value.ToUniversalTime() } + return [datetime]::SpecifyKind($Value, [DateTimeKind]::Utc) + } + $parsed = [datetimeoffset]::MinValue + if ([datetimeoffset]::TryParse([string]$Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$parsed)) { return $parsed.UtcDateTime } + return $null +} + +function Read-FinOpsHubExportManifest { + param($Context, [string]$Path, [string]$TempDir) + + $localFile = Join-Path $TempDir "$([guid]::NewGuid().ToString('N'))-manifest.json" + try { + Get-AzDataLakeGen2ItemContent -Context $Context -FileSystem 'msexports' -Path $Path -Destination $localFile -Force -ErrorAction Stop | Out-Null + $manifest = Get-Content -LiteralPath $localFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop + } + catch { + throw "Hub export manifest '$Path' couldn't be read; cost coverage is incomplete. $($_.Exception.Message)" + } + finally { + Remove-Item -LiteralPath $localFile -Force -ErrorAction SilentlyContinue + } + + # The caller checks counts after it knows the run's scope, so a malformed + # manifest for another subscription doesn't stop the read. + $blobCount = 0L + $rowCount = 0L + $hasRowCount = $manifest.PSObject.Properties.Name -contains 'dataRowCount' + $blobCountValid = $null -eq $manifest.blobCount -or [long]::TryParse([string]$manifest.blobCount, [System.Globalization.NumberStyles]::None, [cultureinfo]::InvariantCulture, [ref]$blobCount) + $rowCountValid = -not $hasRowCount -or $null -eq $manifest.dataRowCount -or [long]::TryParse([string]$manifest.dataRowCount, [System.Globalization.NumberStyles]::None, [cultureinfo]::InvariantCulture, [ref]$rowCount) + $countsValid = $blobCountValid -and $rowCountValid + $type = if ($manifest.exportConfig.type -is [string]) { $manifest.exportConfig.type.Trim() } else { '' } + $run = [pscustomobject]@{ + Type = $type + IsCost = $type -eq 'FocusCost' + Scope = $null + Period = $null + Submitted = $null + CountsValid = $countsValid + BlobCount = $blobCount + # Like ingestion, treat a run without blobs or data rows as empty. + HasRows = $countsValid -and $blobCount -gt 0 -and (-not $hasRowCount -or $rowCount -gt 0) + Parts = @(@($manifest.blobs) | Where-Object { $null -ne $_ } | ForEach-Object { ([string]$_.blobName).TrimStart('/') }) + } + + $exportId = [string]$manifest.exportConfig.resourceId + $scopeEnd = $exportId.IndexOf('/providers/Microsoft.CostManagement/exports/', [System.StringComparison]::OrdinalIgnoreCase) + if ($scopeEnd -gt 0) { $run.Scope = $exportId.Substring(0, $scopeEnd).ToLowerInvariant() } + # The scope is printed, so one with control or format characters can't be trusted. + if ($run.Scope -match '[\p{Cc}\p{Cf}]') { $run.Scope = $null } + $runStart = ConvertTo-FinOpsHubManifestTime $manifest.runInfo.startDate + if ($runStart) { $run.Period = $runStart.ToString('yyyyMM', [cultureinfo]::InvariantCulture) } + $run.Submitted = ConvertTo-FinOpsHubManifestTime $manifest.runInfo.submittedTime + return $run +} + +function Read-FinOpsHubData { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$StorageAccountName, + + [Parameter(Mandatory)] + [string]$ResourceGroupName, + + [Parameter()] + [ValidateRange(1, 36)] + [int]$Months = 1, + + # Restrict returned rows to these subscriptions. A hub holds every + # subscription it ingests, so without this a scoped scan reports on + # subscriptions the user did not select. + [Parameter()] + [string[]]$SubscriptionIds, + + # Larger hubs belong on the Kusto path; this reader is for small datasets. + [Parameter()] + [ValidateRange(1, 1000000)] + [int]$MaxManifests = 2000 + ) + + Write-Host " Connecting to Hub storage: $StorageAccountName" -ForegroundColor DarkGray + + $wanted = @{} + foreach ($subscriptionId in $SubscriptionIds) { + $parsedId = [guid]::Empty + if (-not [guid]::TryParse($subscriptionId, [ref]$parsedId)) { throw 'Invalid subscription ID; refusing an unscoped hub read.' } + $wanted[$parsedId.ToString()] = $true + } + try { + $ctx = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount -ErrorAction Stop + } + catch { + throw "Failed to connect to hub storage; cost coverage is incomplete. $($_.Exception.Message)" + } + + $allData = [System.Collections.Generic.List[PSCustomObject]]::new() + $loadedFormat = $null + $tempDir = New-FinOpsPrivateDirectory -Path (Join-Path (Split-Path (Get-FinOpsParquetCachePath) -Parent) "download-$([guid]::NewGuid().ToString('N'))") -RequireNew + + try { + # -- Strategy 1: Parquet from ingestion (normalized FOCUS) --------- + $now = Get-Date + for ($m = 0; $m -lt $Months; $m++) { + $d = $now.AddMonths(-$m) + $basePath = "Costs/$($d.ToString('yyyy'))/$($d.ToString('MM'))" + $listed = $false + try { + $blobs = @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'ingestion' -Path $basePath -Recurse -ErrorAction Stop | + Where-Object { -not $_.IsDirectory -and $_.Name -like '*.parquet' }) + $listed = $true + + if ($blobs.Count -gt 0) { + # Install parquet reader on first parquet file encountered + if ($allData.Count -eq 0) { + $hasParquet = Install-ParquetReader + if (-not $hasParquet) { + # Name the cause and the change of source: a bare + # "falling back" line reads like a clean scan. + $why = if ($script:FinOpsParquetUnavailableReason) { $script:FinOpsParquetUnavailableReason } else { 'the Parquet reader could not be installed' } + Write-Warning "Reading Hub CSV exports instead of Parquet because $why. Figures come from msexports rather than normalized ingestion." + break + } + } + + Write-Host " Reading ingestion: $basePath ($($blobs.Count) file(s))" -ForegroundColor DarkGray + foreach ($blob in $blobs) { + $localFile = Join-Path $tempDir "$([guid]::NewGuid().ToString('N')).parquet" + try { + Get-AzDataLakeGen2ItemContent -Context $ctx -FileSystem 'ingestion' -Path $blob.Path -Destination $localFile -Force -ErrorAction Stop | Out-Null + $rows = @(Read-ParquetFile -Path $localFile -ErrorAction Stop) + if ($rows -and @($rows).Count -gt 0) { + $loadedFormat = 'Parquet' + foreach ($row in $rows) { $allData.Add($row) } + Write-Host " Loaded $(@($rows).Count) rows from $(Split-Path $blob.Path -Leaf)" -ForegroundColor DarkGray + } + } + finally { + Remove-Item -LiteralPath $localFile -Force -ErrorAction SilentlyContinue + } + } + } + } + catch { + if (-not $listed -and $_.Exception.Message -match 'PathNotFound|FilesystemNotFound|\b404\b') { continue } + throw "Hub ingestion read failed; cost coverage is incomplete. $($_.Exception.Message)" + } + } + + # -- Strategy 2: CSV from msexports (raw FOCUS export) ------------- + if ($allData.Count -eq 0) { + Write-Host " No parquet in ingestion — reading CSV from msexports..." -ForegroundColor DarkGray + + $storedPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $storedSizes = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + foreach ($item in @(Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'msexports' -Recurse -ErrorAction Stop | Where-Object { -not $_.IsDirectory })) { + $storedPath = (([string]$item.Path) -replace '\\', '/').TrimStart('/') + [void]$storedPaths.Add($storedPath) + $storedSizes[$storedPath] = $item.Length + } + + # Every export run writes manifest.json listing its parts, and hub + # ingestion reads only runs that have one. Like ingestion, file each run + # under its manifest's month and keep the latest FOCUS cost run with rows + # for each export scope and month: other scopes hold other costs, earlier + # runs of the same scope repeat them, and ingestion skips empty runs. + $manifestPaths = @($storedPaths | Where-Object { $_ -match '(?:^|/)manifest\.json$' } | Sort-Object) + if ($manifestPaths.Count -gt $MaxManifests) { + throw "Hub storage holds $($manifestPaths.Count) export manifests, more than the $MaxManifests this storage reader checks; cost coverage is incomplete. Use the hub's Kusto database (Azure Data Explorer or Microsoft Fabric) or the Cost Management API instead." + } + if ($manifestPaths.Count -gt 0) { Write-Host " Reading $($manifestPaths.Count) export manifest(s)..." -ForegroundColor DarkGray } + $listedPaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + $periods = @{} + # Manifests that can't be verified, with the month each one could affect. + $unverified = [System.Collections.Generic.List[object]]::new() + $folderMonthPattern = '^(?:.*/)?(\d{6})\d{2}-\d{8}/' + foreach ($manifestPath in $manifestPaths) { + # Export manifests are a few KB, so a much larger file isn't downloaded. + if ($storedSizes[$manifestPath] -gt 1MB) { + $unverified.Add([pscustomobject]@{ Month = [regex]::Match($manifestPath, $folderMonthPattern).Groups[1].Value; Reason = "Hub export manifest '$manifestPath' is larger than 1 MB, so it wasn't read; cost coverage is incomplete." }) + continue + } + try { $exportRun = Read-FinOpsHubExportManifest -Context $ctx -Path $manifestPath -TempDir $tempDir } + catch { + # Only the run folder can date a manifest that can't be read. + $unverified.Add([pscustomobject]@{ Month = [regex]::Match($manifestPath, $folderMonthPattern).Groups[1].Value; Reason = $_.Exception.Message }) + continue + } + foreach ($part in $exportRun.Parts) { [void]$listedPaths.Add($part) } + if ($exportRun.Type -and -not $exportRun.IsCost) { continue } + # Another subscription's export can't hold costs for the selected subscriptions. + $scopeSubscription = [guid]::Empty + if ($wanted.Count -gt 0 -and $exportRun.Scope -match '^/subscriptions/([^/]+)(?:/|$)' -and + [guid]::TryParse($Matches[1], [ref]$scopeSubscription) -and -not $wanted.ContainsKey($scopeSubscription.ToString())) { continue } + if ($exportRun.CountsValid -and -not $exportRun.HasRows) { continue } + $problem = if (-not $exportRun.Type) { "doesn't identify its dataset" } + elseif (-not $exportRun.CountsValid) { 'has invalid blob or row counts' } + elseif (-not $exportRun.Scope -or -not $exportRun.Period) { "doesn't identify its export scope and month" } + if ($problem) { + $month = if ($exportRun.Period) { $exportRun.Period } else { [regex]::Match($manifestPath, $folderMonthPattern).Groups[1].Value } + $unverified.Add([pscustomobject]@{ Month = $month; Reason = "Hub export manifest '$manifestPath' $problem; cost coverage is incomplete." }) + continue + } + if (-not $periods.ContainsKey($exportRun.Period)) { $periods[$exportRun.Period] = @{} } + if (-not $periods[$exportRun.Period].ContainsKey($exportRun.Scope)) { $periods[$exportRun.Period][$exportRun.Scope] = [System.Collections.Generic.List[object]]::new() } + $periods[$exportRun.Period][$exportRun.Scope].Add($exportRun) + } + $unlisted = @($storedPaths | Where-Object { $_ -match '\.csv(?:\.gz)?$' -and -not $listedPaths.Contains($_) }).Count + if ($unlisted -gt 0) { Write-Warning "Skipped $unlisted CSV file(s) in msexports that no readable export manifest lists." } + + # Newest month first. A month without rows, such as a just-started + # current month, falls back to the latest populated month. + $periodsLoaded = 0 + $readParts = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($period in @($periods.Keys | Sort-Object -Descending)) { + if ($periodsLoaded -ge $Months) { break } + # A manifest that can't be verified might hold this month or a newer one. + $blocking = @($unverified | Where-Object { -not $_.Month -or $_.Month -ge $period }) + if ($blocking.Count -gt 0) { throw $blocking[0].Reason } + + $periodRows = 0 + foreach ($scope in @($periods[$period].Keys | Sort-Object)) { + $runs = @($periods[$period][$scope]) + if ($runs.Count -gt 1) { + $runs = @($runs | Sort-Object -Property Submitted -Descending) + if (@($runs | Where-Object { -not $_.Submitted }).Count -gt 0 -or $runs[0].Submitted -eq $runs[1].Submitted) { + throw "Hub CSV exports for $scope in $period contain runs that can't be ordered; cost coverage is incomplete." + } + } + $run = $runs[0] + $parts = @($run.Parts) + $uniqueParts = [System.Collections.Generic.HashSet[string]]::new([string[]]$parts, [System.StringComparer]::Ordinal) + if ($parts.Count -ne $run.BlobCount -or $uniqueParts.Count -ne $parts.Count -or @($parts | Where-Object { [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) { + throw "The latest $period export run for $scope has an invalid part list; cost coverage is incomplete." + } + $missing = @($parts | Where-Object { -not $storedPaths.Contains($_) }).Count + if ($missing -gt 0) { + throw "The latest $period export run for $scope lists $missing part(s) that aren't in msexports. Hub ingestion might have removed them, or the run is still being written; cost coverage is incomplete." + } + if (@($parts | Where-Object { $_ -notmatch '\.csv$' }).Count -gt 0) { + throw "The latest $period export run for $scope isn't uncompressed CSV, so it can't be read here; cost coverage is incomplete." + } + if (@($parts | Where-Object { -not $readParts.Add($_) }).Count -gt 0) { + throw "The latest $period export run for $scope lists a part that another export run also lists, so its costs would be counted twice; cost coverage is incomplete." + } + Write-Host " $period $scope — latest run has $($parts.Count) CSV file(s)" -ForegroundColor DarkGray + + foreach ($part in $parts) { + $localFile = Join-Path $tempDir "$([guid]::NewGuid().ToString('N'))-$(Split-Path $part -Leaf)" + try { + Get-AzDataLakeGen2ItemContent -Context $ctx -FileSystem 'msexports' -Path $part -Destination $localFile -Force -ErrorAction Stop | Out-Null + $rows = Import-Csv -LiteralPath $localFile -ErrorAction Stop + if ($rows -and @($rows).Count -gt 0) { + $loadedFormat = 'CSV' + foreach ($row in $rows) { $allData.Add($row) } + $periodRows += @($rows).Count + } + } + catch { + throw "Hub CSV read failed; cost coverage is incomplete. $($_.Exception.Message)" + } + finally { + Remove-Item -LiteralPath $localFile -Force -ErrorAction SilentlyContinue + } + } + } + + if ($periodRows -gt 0) { + Write-Host " Loaded $periodRows rows from $period" -ForegroundColor DarkGray + $periodsLoaded++ + } + else { + Write-Host " $period had no rows — skipping to next" -ForegroundColor DarkGray + } + } + + if ($unverified.Count -gt 0) { + # What's left is older than every month read, so it matters only if a requested month is still missing. + if ($periodsLoaded -lt $Months) { throw $unverified[0].Reason } + Write-Warning ([regex]::Replace("Ignored $($unverified.Count) export manifest(s) that couldn't be verified and are dated before the months read. $($unverified[0].Reason)", '[\p{Cc}\p{Cf}]', ' ')) + } + if ($periodsLoaded -eq 0) { Write-Host " No cost data found in Hub storage" -ForegroundColor Yellow } + } + } + finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } + + if ($allData.Count -gt 0) { + Write-Host " Total rows from Hub ($loadedFormat): $($allData.Count)" -ForegroundColor Green + } + + if ($wanted.Count -gt 0) { + $before = $allData.Count + $covered = @{} + $selectedRows = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($row in $allData) { + $rowSubscription = Get-FinOpsHubRowSubscriptionId $row + if (-not $rowSubscription) { throw 'A hub row has no subscription ID; cost coverage is incomplete.' } + if ($wanted.ContainsKey($rowSubscription)) { + [void]$selectedRows.Add($row) + $covered[$rowSubscription] = $true + } + } + foreach ($subscriptionId in $wanted.Keys) { + if (-not $covered.ContainsKey($subscriptionId)) { throw "No hub rows for selected subscription '$subscriptionId'; cost coverage is incomplete." } + } + $allData = $selectedRows.ToArray() + Write-Host " Scoped to $($SubscriptionIds.Count) selected subscription(s): $($allData.Count) of $before rows" -ForegroundColor DarkGray + } + + return $allData +} + +function ConvertTo-CostDataFromHub { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$HubData + ) + + # Convert FOCUS-schema Hub data into the same hashtable format + # that Get-CostData returns: @{ subscriptionId = @{ Actual; Forecast; Currency } } + $costMap = @{} + $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData + $costCol = $costSchema.CostColumn + + foreach ($row in $HubData) { + $subId = if ($props -contains 'SubAccountId' -and $row.SubAccountId) { $row.SubAccountId } + elseif ($props -contains 'SubscriptionId' -and $row.SubscriptionId) { $row.SubscriptionId } + elseif ($props -contains 'x_SubscriptionId' -and $row.x_SubscriptionId) { $row.x_SubscriptionId } + else { 'unknown' } + + # FOCUS SubAccountId may be full resource path — extract just the GUID + if ($subId -match '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}') { + $subId = $Matches[0] + } + + # Display name from the FOCUS data so the UI can label by name, not GUID. + $subName = if ($props -contains 'SubAccountName' -and $row.SubAccountName) { [string]$row.SubAccountName } + elseif ($props -contains 'SubscriptionName' -and $row.SubscriptionName) { [string]$row.SubscriptionName } + else { '' } + + $cost = Get-HubCostValue -Row $row -Column $costCol + + $currency = if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $row.BillingCurrency } + elseif ($props -contains 'BillingCurrencyCode' -and $row.BillingCurrencyCode) { $row.BillingCurrencyCode } + else { $costSchema.Currency } + + if (-not $costMap.ContainsKey($subId)) { + $subscriptionPeriod = $costSchema.PeriodsBySubscription[$subId] + $costMap[$subId] = @{ + Actual = 0.0; Forecast = $null; ForecastSource = 'Unavailable'; Currency = $currency; Name = $subName + ActualPeriodStart = $subscriptionPeriod.PeriodStart; ActualPeriodEnd = $subscriptionPeriod.PeriodEnd + ActualPeriod = if ($subscriptionPeriod) { $subscriptionPeriod.Period } else { 'Unknown' } + } + } + $costMap[$subId].Actual += $cost + } + + return $costMap +} + +function ConvertTo-ResourceCostsFromHub { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$HubData + ) + + # Aggregate by resource and return in the same format as Get-ResourceCosts + $resourceMap = @{} + $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData + $costCol = $costSchema.CostColumn + + foreach ($row in $HubData) { + $subName = if ($props -contains 'SubAccountName' -and $row.SubAccountName) { $row.SubAccountName } + elseif ($props -contains 'SubscriptionName' -and $row.SubscriptionName) { $row.SubscriptionName } + elseif ($props -contains 'SubAccountId') { $row.SubAccountId } + else { 'unknown' } + + $rg = if ($props -contains 'x_ResourceGroupName' -and $row.x_ResourceGroupName) { $row.x_ResourceGroupName } + elseif ($props -contains 'ResourceGroup' -and $row.ResourceGroup) { $row.ResourceGroup } + elseif ($props -contains 'ResourceGroupName' -and $row.ResourceGroupName) { $row.ResourceGroupName } + else { 'unknown' } + + $resType = if ($props -contains 'ResourceType' -and $row.ResourceType) { $row.ResourceType } + elseif ($props -contains 'x_ResourceType' -and $row.x_ResourceType) { $row.x_ResourceType } + elseif ($props -contains 'ConsumedService' -and $row.ConsumedService) { $row.ConsumedService } + else { 'unknown' } + + $resId = if ($props -contains 'ResourceId' -and $row.ResourceId) { $row.ResourceId } + elseif ($props -contains 'x_ResourceId' -and $row.x_ResourceId) { $row.x_ResourceId } + else { "$rg/$resType" } + + $cost = Get-HubCostValue -Row $row -Column $costCol + + $currency = if ($props -contains 'BillingCurrency' -and $row.BillingCurrency) { $row.BillingCurrency } + elseif ($props -contains 'BillingCurrencyCode' -and $row.BillingCurrencyCode) { $row.BillingCurrencyCode } + else { $costSchema.Currency } + + # Charges without a resource ID share a fallback path; keep subscriptions separate. + $key = "$(Get-FinOpsHubRowSubscriptionId $row)|$resId" + if (-not $resourceMap.ContainsKey($key)) { + $subscriptionPeriod = $costSchema.PeriodsBySubscription[(Get-FinOpsHubRowSubscriptionId $row)] + $resourceMap[$key] = [PSCustomObject]@{ + Subscription = $subName + ResourceGroup = $rg + ResourceType = $resType + ResourcePath = $resId + Actual = 0.0 + Forecast = $null + Currency = $currency + ActualPeriod = if ($subscriptionPeriod) { $subscriptionPeriod.Period } else { 'Unknown' } + } + } + $resourceMap[$key].Actual += $cost + } + + return @($resourceMap.Values | Sort-Object { $_.Actual } -Descending) +} + +# Helper: pick the first present/non-empty property from a row. +function Get-HubRowValue { + param( + [Parameter(Mandatory)][object]$Row, + [Parameter(Mandatory)][string[]]$Names, + [string[]]$Props + ) + if (-not $Props) { $Props = $Row.PSObject.Properties.Name } + foreach ($n in $Names) { + if ($Props -contains $n -and $null -ne $Row.$n -and "$($Row.$n)".Trim() -ne '') { + return $Row.$n + } + } + return $null +} + +function Resolve-HubCostColumn { + param( + [string[]]$Props, + [ValidateSet('ActualCost', 'AmortizedCost')] + [string]$CostBasis = 'ActualCost' + ) + + $candidates = if ($CostBasis -eq 'AmortizedCost') { @('EffectiveCost') } + else { @('BilledCost', 'CostInBillingCurrency', 'PreTaxCost', 'Cost') } + foreach ($column in $candidates) { + if ($Props -contains $column) { return $column } + } + if ($CostBasis -eq 'AmortizedCost') { + throw 'AmortizedCost is unavailable in the selected Hub data. This scan requires EffectiveCost from a FOCUS export. Billed cost is not substituted. Use a FOCUS export with EffectiveCost or select API for a separate live scan.' + } + throw "No $CostBasis column is available; cost results are incomplete." +} + +function Get-HubCostValue { + param( + [Parameter(Mandatory)][object]$Row, + [string]$Column + ) + if (-not $Column) { throw 'No cost column was selected; cost results are incomplete.' } + $raw = $Row.$Column + $text = ([string]$raw).Trim() + if ($text.Contains(',') -and $text -notmatch '^[+-]?\d{1,3}(,\d{3})+(\.\d+)?([eE][+-]?\d+)?$') { + throw "Invalid numeric grouping in '$Column'; cost results are incomplete." + } + $amount = 0.0 + $styles = [System.Globalization.NumberStyles]::Float -bor [System.Globalization.NumberStyles]::AllowThousands + if ($null -eq $raw -or + -not [double]::TryParse($text, $styles, [System.Globalization.CultureInfo]::InvariantCulture, [ref]$amount) -or + [double]::IsNaN($amount) -or [double]::IsInfinity($amount)) { + throw "Missing or invalid cost in '$Column'; cost results are incomplete." + } + return $amount +} + +function Get-HubCostSchema { + param( + [Parameter(Mandatory)][object[]]$HubData, + [ValidateSet('ActualCost', 'AmortizedCost')] + [string]$CostBasis = 'ActualCost' + ) + + $headers = [System.Collections.Generic.HashSet[string]]::new([string[]]$HubData[0].PSObject.Properties.Name, [System.StringComparer]::OrdinalIgnoreCase) + $costColumn = Resolve-HubCostColumn -Props @($headers) -CostBasis $CostBasis + $currency = $null + $periodStart = $null + $periodEnd = $null + $periodKnown = $true + $periodsBySubscription = @{} + foreach ($row in $HubData) { + if (-not $headers.SetEquals([string[]]$row.PSObject.Properties.Name)) { + throw 'Hub row schemas differ; cost results are incomplete.' + } + $null = Get-HubCostValue -Row $row -Column $costColumn + $rowCurrency = [string](Get-HubRowValue -Row $row -Names @('BillingCurrency', 'BillingCurrencyCode', 'Currency')) + if ([string]::IsNullOrWhiteSpace($rowCurrency)) { + throw 'Billing currency is missing; cost results are incomplete.' + } + $rowCurrency = $rowCurrency.Trim().ToUpperInvariant() + if ($currency -and $currency -ne $rowCurrency) { + throw 'Multiple billing currencies cannot be combined into one cost total.' + } + $currency = $rowCurrency + $rawDate = Get-HubRowValue -Row $row -Names @('ChargePeriodStart', 'Date', 'UsageDate', 'UsageDateTime') + $date = $null + try { + $date = if ($rawDate -is [datetime]) { $rawDate.ToUniversalTime() } + elseif ([string]$rawDate -match '^\d{8}$') { [datetime]::ParseExact([string]$rawDate, 'yyyyMMdd', [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal).ToUniversalTime() } + else { [datetimeoffset]::Parse([string]$rawDate, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal).UtcDateTime } + if ($null -eq $periodStart -or $date -lt $periodStart) { $periodStart = $date } + if ($null -eq $periodEnd -or $date -gt $periodEnd) { $periodEnd = $date } + } + catch { $periodKnown = $false } + $subscriptionId = Get-FinOpsHubRowSubscriptionId $row + if ($subscriptionId) { + if (-not $periodsBySubscription.ContainsKey($subscriptionId)) { + $periodsBySubscription[$subscriptionId] = @{ PeriodStart = $null; PeriodEnd = $null; Known = $true } + } + $subscriptionPeriod = $periodsBySubscription[$subscriptionId] + if ($null -eq $date) { $subscriptionPeriod.Known = $false } + else { + if ($null -eq $subscriptionPeriod.PeriodStart -or $date -lt $subscriptionPeriod.PeriodStart) { $subscriptionPeriod.PeriodStart = $date } + if ($null -eq $subscriptionPeriod.PeriodEnd -or $date -gt $subscriptionPeriod.PeriodEnd) { $subscriptionPeriod.PeriodEnd = $date } + } + } + } + if (-not $periodKnown) { $periodStart = $null; $periodEnd = $null } + foreach ($subscriptionPeriod in $periodsBySubscription.Values) { + if (-not $subscriptionPeriod.Known) { $subscriptionPeriod.PeriodStart = $null; $subscriptionPeriod.PeriodEnd = $null } + $subscriptionPeriod.Period = if ($subscriptionPeriod.Known) { + '{0:yyyy-MM-dd} to {1:yyyy-MM-dd}' -f $subscriptionPeriod.PeriodStart, $subscriptionPeriod.PeriodEnd + } + else { 'Unknown' } + } + return @{ + CostColumn = $costColumn; Currency = $currency; CostBasis = $CostBasis + PeriodStart = $periodStart; PeriodEnd = $periodEnd + PeriodsBySubscription = $periodsBySubscription + Period = if ($null -ne $periodStart -and $null -ne $periodEnd) { '{0:yyyy-MM-dd} to {1:yyyy-MM-dd}' -f $periodStart, $periodEnd } else { 'Unknown' } + } +} + +# Helper: convert a billing unit string (e.g. "1K", "1M", "1,000", +# "100 Tokens") into the number of tokens one unit of quantity represents. +# Azure OpenAI token meters are billed per-1K tokens, so an unqualified +# unit defaults to 1000 (flagged as approximate by the caller). +function Get-TokenUnitMultiplier { + param([string]$Unit) + if (-not $Unit) { return @{ Multiplier = 1000.0; Known = $false } } + $u = $Unit.Trim() + + # Pure number, e.g. "1000" or "1,000,000". + $numOnly = ($u -replace '[,\s]', '') + if ($numOnly -match '^\d+(\.\d+)?$') { return @{ Multiplier = [double]$numOnly; Known = $true } } + + # Scaled, e.g. "1K", "10K", "1M". + $m = [regex]::Match($u, '(?i)([\d,\.]+)?\s*([KM])\b') + if ($m.Success) { + $n = if ($m.Groups[1].Value) { [double]($m.Groups[1].Value -replace ',', '') } else { 1.0 } + $scale = if ($m.Groups[2].Value -match '(?i)M') { 1e6 } else { 1e3 } + return @{ Multiplier = ($n * $scale); Known = $true } + } + + # Plain "tokens" / "count" / "units" — quantity is already raw tokens. + if ($u -match '(?i)\b(token|tokens|count|units|unit)\b') { return @{ Multiplier = 1.0; Known = $true } } + + # Unknown unit — assume the AOAI per-1K convention but flag it. + return @{ Multiplier = 1000.0; Known = $false } +} + +# Helper: strip token-type and qualifier words from an Azure OpenAI meter +# name to derive a model/deployment grouping key. +function Get-AIModelKeyFromMeter { + param([string]$Meter) + if (-not $Meter) { return 'unknown' } + $k = $Meter -replace '(?i)\b(inp|input|prompt|outp|output|generated|completion|cached|cache|regional|global|glbl|reg|data|stored|tokens|token)\b', '' + $k = ($k -replace '\s+', ' ').Trim(' -') + if ([string]::IsNullOrWhiteSpace($k)) { return ([string]$Meter).Trim() } + return $k +} + +function ConvertTo-AIHubAggregates { + [CmdletBinding()] + param( + [Parameter()] + [AllowEmptyCollection()] + [object[]]$HubData + ) + + # Aggregate Azure OpenAI / Cognitive Services spend and billed token + # volume straight from FOCUS export rows. Only cognitiveservices/accounts + # rows are priced (matching the live Cost Management filter). Request + # counts are not billed line items, so cost-per-request is unavailable + # on this path. + $modelTokens = @{} # modelKey -> @{ Prompt; Generated; Total } + $acctTokens = @{} # resourceId(lower) -> @{ Name; Tokens; Requests } + $costByAcct = @{} # resourceId(lower) -> cost + $totalPrompt = 0.0 + $totalGen = 0.0 + $totalTokens = 0.0 + $aiCost = 0.0 + $currency = 'USD' + $tokenRows = 0 + $approximate = $false + + if (-not $HubData -or @($HubData).Count -eq 0) { + return [PSCustomObject]@{ + RowCount = 0; TotalPrompt = 0; TotalGen = 0; TotalTokens = 0 + AICost = 0; Currency = $currency; ModelTokens = $modelTokens + AcctTokens = $acctTokens; CostByAcct = $costByAcct + HasTokens = $false; HasCost = $false; Approximate = $false + } + } + + $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData -CostBasis 'AmortizedCost' + $costCol = $costSchema.CostColumn + $currency = $costSchema.Currency + + foreach ($row in $HubData) { + $resType = Get-HubRowValue -Row $row -Props $props -Names @('ResourceType', 'x_ResourceType', 'ConsumedService') + if (-not $resType -or "$resType".ToLowerInvariant() -notmatch 'cognitiveservices') { continue } + + $rid = Get-HubRowValue -Row $row -Props $props -Names @('ResourceId', 'x_ResourceId') + if (-not $rid) { continue } + $ridKey = "$rid".ToLowerInvariant() + + $name = Get-HubRowValue -Row $row -Props $props -Names @('ResourceName') + if (-not $name) { $name = Split-Path "$rid" -Leaf } + + $cost = Get-HubCostValue -Row $row -Column $costCol + + if (-not $acctTokens.ContainsKey($ridKey)) { + $subscriptionId = if ($ridKey -match '^/subscriptions/([^/]+)/') { $Matches[1] } else { $null } + $acctTokens[$ridKey] = @{ Name = "$name"; SubscriptionId = $subscriptionId; Tokens = 0.0; Requests = 0.0 } + } + if (-not $costByAcct.ContainsKey($ridKey)) { $costByAcct[$ridKey] = 0.0 } + $costByAcct[$ridKey] += $cost + $aiCost += $cost + + # Token attribution from the meter name + billed quantity. + $meter = Get-HubRowValue -Row $row -Props $props -Names @('x_SkuMeterName', 'MeterName', 'SkuMeterName', 'x_SkuDescription') + if (-not $meter -or "$meter" -notmatch '(?i)token') { continue } + + $qty = Get-HubRowValue -Row $row -Props $props -Names @('ConsumedQuantity', 'x_ConsumedQuantity', 'Quantity', 'UsageQuantity') + $qty = if ($null -ne $qty) { [double]$qty } else { 0.0 } + if ($qty -le 0) { continue } + + $unit = Get-HubRowValue -Row $row -Props $props -Names @('ConsumedUnit', 'x_PricingUnitDescription', 'UnitOfMeasure', 'PricingUnit') + $mult = Get-TokenUnitMultiplier -Unit "$unit" + if (-not $mult.Known) { $approximate = $true } + $tokens = $qty * $mult.Multiplier + + $modelName = Get-AIModelKeyFromMeter -Meter "$meter" + $modelKey = "$ridKey|$modelName" + if (-not $modelTokens.ContainsKey($modelKey)) { + $modelTokens[$modelKey] = @{ Deployment = $modelName; Account = "$name"; ResourceId = $ridKey; SubscriptionId = $acctTokens[$ridKey].SubscriptionId; Prompt = 0.0; Generated = 0.0; Total = 0.0 } + } + + if ("$meter" -match '(?i)\b(inp|input|prompt|cached|cache)\b') { + $modelTokens[$modelKey].Prompt += $tokens + $totalPrompt += $tokens + $acctTokens[$ridKey].Tokens += $tokens + } + elseif ("$meter" -match '(?i)\b(outp|output|generated|completion)\b') { + $modelTokens[$modelKey].Generated += $tokens + $totalGen += $tokens + $acctTokens[$ridKey].Tokens += $tokens + } + else { + $acctTokens[$ridKey].Tokens += $tokens + } + $modelTokens[$modelKey].Total += $tokens + $totalTokens += $tokens + $tokenRows++ + } + + return [PSCustomObject]@{ + RowCount = $tokenRows + TotalPrompt = $totalPrompt + TotalGen = $totalGen + TotalTokens = $totalTokens + AICost = $aiCost + Currency = $currency + ModelTokens = $modelTokens + AcctTokens = $acctTokens + CostByAcct = $costByAcct + HasTokens = ($totalTokens -gt 0) + HasCost = ($aiCost -gt 0) + Approximate = $approximate + Period = $costSchema.Period + } +} + +function ConvertTo-TagInventoryFromHub { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$HubData + ) + + # Extract tag inventory from FOCUS cost data Tags JSON column + # Returns same structure as Get-TagInventory + $tagNames = @{} + $totalResources = 0 + $taggedCount = 0 + $untaggedResources = [System.Collections.Generic.List[PSCustomObject]]::new() + $tagReadErrors = [System.Collections.Generic.List[string]]::new() + $unverifiedTagResourceCount = 0 + $seenResources = @{} + $props = $HubData[0].PSObject.Properties.Name + + foreach ($row in $HubData) { + $resId = if ($props -contains 'ResourceId' -and $row.ResourceId) { $row.ResourceId } + elseif ($props -contains 'x_ResourceId' -and $row.x_ResourceId) { $row.x_ResourceId } + else { $null } + + # Deduplicate by resource ID (cost rows repeat per line item) + if (-not $resId -or $seenResources.ContainsKey($resId)) { continue } + $seenResources[$resId] = $true + $totalResources++ + + $resName = if ($props -contains 'ResourceName' -and $row.ResourceName) { $row.ResourceName } else { Split-Path $resId -Leaf } + $resType = if ($props -contains 'ResourceType' -and $row.ResourceType) { $row.ResourceType } + elseif ($props -contains 'x_ResourceType' -and $row.x_ResourceType) { $row.x_ResourceType } + else { 'unknown' } + $rg = if ($props -contains 'x_ResourceGroupName' -and $row.x_ResourceGroupName) { $row.x_ResourceGroupName } + elseif ($props -contains 'ResourceGroup' -and $row.ResourceGroup) { $row.ResourceGroup } + else { 'unknown' } + $sub = if ($props -contains 'SubAccountName' -and $row.SubAccountName) { $row.SubAccountName } + elseif ($props -contains 'SubscriptionName' -and $row.SubscriptionName) { $row.SubscriptionName } + else { 'unknown' } + + # Parse Tags JSON + $tagDict = $null + try { + if ($row.PSObject.Properties.Name -notcontains 'Tags') { throw 'The Tags column is missing.' } + $tagsJson = $row.Tags + if ($null -ne $tagsJson -and $tagsJson -isnot [string]) { throw 'Tags must contain JSON text.' } + if (-not [string]::IsNullOrWhiteSpace($tagsJson)) { + $tagDict = ConvertTo-HashtableFromJson -Json $tagsJson + } + } + catch { + $tagReadErrors.Add("Tags for '$resId': $($_.Exception.Message)") + $unverifiedTagResourceCount++ + continue + } + + if ($tagDict -and $tagDict.Count -gt 0) { + $taggedCount++ + foreach ($kv in $tagDict.GetEnumerator()) { + $tName = $kv.Key + $tVal = if ($kv.Value) { "$($kv.Value)" } else { '(empty)' } + + if (-not $tagNames.ContainsKey($tName)) { + $tagNames[$tName] = @{ + Values = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::Ordinal) + TotalResources = 0 + } + } + $tagNames[$tName].TotalResources++ + + if (-not $tagNames[$tName].Values.ContainsKey($tVal)) { + $tagNames[$tName].Values[$tVal] = @{ ResourceCount = 0; ResourceTypes = @{} } + } + $tagNames[$tName].Values[$tVal].ResourceCount++ + $tagNames[$tName].Values[$tVal].ResourceTypes[$resType] = $true + } + } + else { + if ($untaggedResources.Count -lt 500) { + $untaggedResources.Add([PSCustomObject]@{ + ResourceName = $resName + ResourceType = $resType + ResourceGroup = $rg + Subscription = $sub + Location = '' + }) + } + } + } + + # Convert Values hashes to arrays matching Get-TagInventory format + $tagNamesOut = @{} + foreach ($kv in $tagNames.GetEnumerator()) { + $valArray = @() + foreach ($v in $kv.Value.Values.GetEnumerator()) { + $valArray += [PSCustomObject]@{ + Value = $v.Key + ResourceCount = $v.Value.ResourceCount + ResourceTypes = @($v.Value.ResourceTypes.Keys) + } + } + $tagNamesOut[$kv.Key] = @{ + Values = ($valArray | Sort-Object ResourceCount -Descending) + TotalResources = $kv.Value.TotalResources + } + } + + $untaggedCount = $totalResources - $taggedCount - $unverifiedTagResourceCount + $coverageIncomplete = $tagReadErrors.Count -gt 0 + $coverage = if ($coverageIncomplete) { $null } elseif ($totalResources -gt 0) { [math]::Round(($taggedCount / $totalResources) * 100, 1) } else { 0 } + $note = if ($coverageIncomplete) { "Hub tag inventory coverage is incomplete: tags could not be read for $unverifiedTagResourceCount resource(s). Unreadable tags are not counted as missing." } else { $null } + if ($note) { Write-Warning $note } + + return [PSCustomObject]@{ + TagNames = $tagNamesOut + TagCount = $tagNamesOut.Count + TotalResources = $totalResources + TaggedCount = $taggedCount + UntaggedCount = $untaggedCount + TagCoverage = $coverage + UntaggedResources = @($untaggedResources) + Source = 'Hub' + CoverageIncomplete = $coverageIncomplete + ReadErrors = $tagReadErrors.ToArray() + UnverifiedTagResourceCount = $unverifiedTagResourceCount + Note = $note + } +} + +function ConvertTo-CostByTagFromHub { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [object[]]$HubData, + + [Parameter()] + [hashtable]$ExistingTags + ) + + # Aggregate cost by tag key/value from FOCUS cost data + # Returns same structure as Get-CostByTag + $props = $HubData[0].PSObject.Properties.Name + $costSchema = Get-HubCostSchema -HubData $HubData + $costCol = $costSchema.CostColumn + $costByTag = @{} + $currency = $costSchema.Currency + + $targetTags = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($tagKey in $ExistingTags.Keys) { [void]$targetTags.Add($tagKey) } + $tagRows = [System.Collections.Generic.List[object]]::new() + foreach ($row in $HubData) { + $cost = Get-HubCostValue -Row $row -Column $costCol + $tagsJson = if ($props -contains 'Tags') { $row.Tags } else { $null } + $tagDict = ConvertFrom-ExportTagString -Raw $tagsJson + if (-not $ExistingTags -or $ExistingTags.Count -eq 0) { + foreach ($tagKey in $tagDict.Keys) { [void]$targetTags.Add($tagKey) } + } + [void]$tagRows.Add(@{ Cost = $cost; Tags = $tagDict }) + } + + foreach ($row in $tagRows) { + foreach ($tagKey in $targetTags) { + if (-not $costByTag.ContainsKey($tagKey)) { $costByTag[$tagKey] = [System.Collections.Generic.Dictionary[string, double]]::new([System.StringComparer]::Ordinal) } + $tagVal = if ($row.Tags.ContainsKey($tagKey)) { [string]$row.Tags[$tagKey] } else { '(untagged)' } + if (-not $tagVal -or $tagVal -eq '') { $tagVal = '(empty)' } + + if (-not $costByTag[$tagKey].ContainsKey($tagVal)) { $costByTag[$tagKey][$tagVal] = 0.0 } + $costByTag[$tagKey][$tagVal] += $row.Cost + } + } + + # Convert to output format matching Get-CostByTag + $costByTagOut = @{} + foreach ($kv in $costByTag.GetEnumerator()) { + $costByTagOut[$kv.Key] = @($kv.Value.GetEnumerator() | ForEach-Object { + [PSCustomObject]@{ + TagValue = $_.Key + Cost = [math]::Round($_.Value, 2) + Currency = $currency + } + } | Sort-Object Cost -Descending) + } + + return [PSCustomObject]@{ + TagsQueried = @($costByTagOut.Keys) + CostByTag = $costByTagOut + NoTagsFound = ($costByTagOut.Count -eq 0) + UsedTimeframe = 'Hub export period' + Source = 'Hub' + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-BillingScope.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-BillingScope.ps1 new file mode 100644 index 000000000..a57c5edc4 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-BillingScope.ps1 @@ -0,0 +1,146 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +########################################################################### +# RESOLVE-BILLINGSCOPE.PS1 +# CORRELATE BILLING ACCOUNTS TO THE SCANNED SUBSCRIPTIONS +########################################################################### +# Purpose: Return only the billing accounts that actually own one of the +# scanned subscriptions, so account-scoped scans do not mix in +# commitments and enrollments from unrelated accounts. +# Author: Zac Larsen +# +# Description: +# Enumerating /providers/Microsoft.Billing/billingAccounts returns every +# account the caller can read, which on a multi-enrollment tenant is wider +# than the scan scope. This correlates each candidate through its +# billingSubscriptions collection, the same approach Get-ContractInfo uses. +# +# The subscription-scoped billingProperty/default endpoint is tried first +# because it is a single call per subscription. Note that billingInfo/default +# is NOT a valid resource type and returns 404 on every api-version, so it is +# deliberately not used here. +# +# ── Parameters ────────────────────────────────────────────────── +# BillingAccounts Account objects from the billingAccounts list call +# Subscriptions Scanned subscriptions; each needs an Id property +# +# Returns: PSCustomObject with Accounts (the in-scope subset), Resolved +# (whether correlation produced an answer), and Reason. +# +# Usage: $scope = Get-FinOpsBillingScope -BillingAccounts $accts -Subscriptions $subs +########################################################################### + +function Get-FinOpsBillingScope { + param( + [object[]]$BillingAccounts, + [object[]]$Subscriptions + ) + + $out = [PSCustomObject]@{ + Accounts = @() + Resolved = $false + Reason = $null + CoverageIncomplete = $false + ReadErrors = @() + } + + $accounts = @($BillingAccounts | Where-Object { $_ }) + if ($accounts.Count -eq 0) { + $out.Reason = 'No billing accounts were returned by the billing accounts list.' + return $out + } + + $scanIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($s in @($Subscriptions)) { + if ($s -and $s.Id) { [void]$scanIds.Add([string]$s.Id) } + } + + # Nothing to correlate against, so the caller's own scoping already applies. + if ($scanIds.Count -eq 0) { + $out.Accounts = $accounts + $out.Resolved = $true + $out.Reason = 'No subscription scope supplied; returning all reachable billing accounts.' + return $out + } + + $matched = [System.Collections.Generic.List[object]]::new() + $anyReadable = $false + $readErrors = [Collections.Generic.List[string]]::new() + $matchedNames = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + + # Pass 1: ask each subscription which billing account owns it. + $ownerNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($s in @($Subscriptions)) { + if (-not $s -or -not $s.Id) { continue } + try { + $resp = Invoke-AzRestMethodWithRetry -Path "/subscriptions/$($s.Id)/providers/Microsoft.Billing/billingProperty/default?api-version=2024-04-01" -Method GET + if (-not $resp -or $resp.StatusCode -ne 200) { throw "Subscription billing lookup returned HTTP $($resp.StatusCode)." } + if ([string]::IsNullOrWhiteSpace([string]$resp.Content)) { throw 'Subscription billing lookup returned no content.' } + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $anyReadable = $true + $baId = ($resp.Content | ConvertFrom-Json).properties.billingAccountId + if (-not $baId) { throw 'Subscription billing lookup returned no billing account ID.' } + if ($baId) { + $name = ($baId -replace '(?i).*/billingAccounts/', '').Trim('/') + if ($name) { [void]$ownerNames.Add($name) } + } + } + } + catch { + $readErrors.Add("$($s.Id): $($_.Exception.Message)") + } + } + + foreach ($ba in $accounts) { + $name = if ($ba.PSObject.Properties['Name'] -and $ba.Name) { [string]$ba.Name } else { [string]$ba.name } + if ($name -and $ownerNames.Contains($name) -and $matchedNames.Add($name)) { $matched.Add($ba) } + } + + # Pass 2: walk each account's subscriptions. Covers enrollments where the + # subscription-scoped lookup is not readable but the account is. + if ($matched.Count -eq 0 -or $readErrors.Count -gt 0) { + foreach ($ba in $accounts) { + $name = if ($ba.PSObject.Properties['Name'] -and $ba.Name) { [string]$ba.Name } else { [string]$ba.name } + if (-not $name) { continue } + try { + $resp = Invoke-AzRestMethodWithRetry -Path "/providers/Microsoft.Billing/billingAccounts/$name/billingSubscriptions?api-version=2024-04-01" -Method GET + $membership = Get-FinOpsListResult -FirstResponse $resp -Context "billing membership for $name" + if ($resp -and $resp.StatusCode -eq 200 -and $resp.Content) { + $anyReadable = $true + foreach ($bs in @($membership.value)) { + $id = if ($bs.properties.subscriptionId) { [string]$bs.properties.subscriptionId } else { [string]$bs.name } + if ($id -and $scanIds.Contains($id)) { + if ($matchedNames.Add($name)) { $matched.Add($ba) } + break + } + } + } + } + catch { + $readErrors.Add("$name : $($_.Exception.Message)") + } + } + } + + $out.CoverageIncomplete = $readErrors.Count -gt 0 + $out.ReadErrors = @($readErrors) + if ($out.CoverageIncomplete) { $out.Reason = 'Billing account correlation is incomplete. ' + ($readErrors -join ' ') } + + if ($matched.Count -gt 0) { + $out.Accounts = @($matched) + $out.Resolved = $true + return $out + } + + # Correlation failed. Distinguish "read it, found no link" from "could not read + # it", because the second is a permissions problem the user can act on. + $out.Reason = if ($out.CoverageIncomplete) { $out.Reason } + elseif ($anyReadable) { + 'No reachable billing account owns any of the scanned subscriptions.' + } + else { + 'Could not read billing account membership. Billing Account Reader, or Enterprise Administrator (reader) on an EA, is required to correlate accounts to subscriptions.' + } + return $out +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 new file mode 100644 index 000000000..bd2d006f0 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CostDataSource.ps1 @@ -0,0 +1,549 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by source and is not a declared contract.')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'Accepted for signature parity; the dispatcher passes -TenantId to every scan module.')] +param() + +########################################################################### +# RESOLVE-COSTDATASOURCE.PS1 +# COST DATA SOURCE RESOLVER (EXPORT-FIRST ROUTING) +########################################################################### +# Purpose: Decide whether cost scans should read FinOps Hub / Cost +# Management export data (fast) or the live Cost Management API. +# Date: Created for FinOps Multitool export-first routing +# +# Description: +# Non-interactive detector used by the TUI and the agent skills. +# It inspects the requested scope and returns a structured decision so +# the agent can take the fast path when an export is readable, or set +# expectations (and ask) before falling back to the slow API path. +# +# 1. Detects a FinOps Hub storage account via Resource Graph +# 2. Verifies the hub storage is actually readable, with a blocker reason +# (RBAC / public access disabled / firewall deny) when it is not +# 3. Determines which subscriptions the export covers vs. those requested +# 4. Reports export freshness (latest data date) +# 5. Estimates how long the live API path would take (resource-count based) +# 6. Emits a recommendation: +# UseHub | UseHubPartial | FixAccessThenHub (FinOps Hub fast path) +# UseExport | UseExportPartial (generic CSV export) +# UseApi (live Cost Management API) +# +# ── Parameters ────────────────────────────────────────────── +# RequestedSubscriptionIds Subscription IDs the caller intends to scan +# TenantId Tenant ID (for messaging only) +# +# Prerequisites: +# - Az.ResourceGraph, Az.Storage, Az.Accounts modules +# - Search-AzGraphSafe + RunspacePool helpers loaded +# - Read access to the Hub storage account for the fast path (optional) +########################################################################### + +function Resolve-CostDataSource { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]]$RequestedSubscriptionIds, + + [string]$TenantId + ) + + $requested = @($RequestedSubscriptionIds | Where-Object { $_ } | Select-Object -Unique) + $subCount = $requested.Count + + # -- Default result (no hub found → API is the only option) ----------- + $result = [ordered]@{ + HubFound = $false + Hub = $null + Readable = $false + ReadBlocker = 'None' + ReadBlockerDetail = $null + RemediationHint = $null + CoverageSubs = @() + RequestedSubs = $requested + CoveragePct = $null + CoverageKnown = $false + Freshness = $null + EstimatedApiSeconds = 0 + Recommendation = 'UseApi' + ExportFound = $false + Exports = @() + # Online scalable path: the hub's Azure Data Explorer (Kusto) cluster, + # discovered via Resource Graph. When present, cost scans push + # aggregation into the engine instead of reading rows. + KustoClusterUri = $null + KustoDatabase = $null + HubVersion = $null + Message = $null + } + + # -- Step 1: estimate the API path duration (resource-count based) ----- + # One cheap Graph query: total resources in scope. Cost queries scale + # roughly with subscription count plus a small per-resource component. + $resourceCount = 0 + try { + $countQuery = 'Resources | summarize c = count()' + $countRes = Search-AzGraphSafe -Query $countQuery -Subscription $requested -First 1 + if ($countRes -and $countRes.Data -and @($countRes.Data).Count -gt 0) { + $resourceCount = [int]($countRes.Data[0].c) + } + } + catch { + # Non-fatal — fall back to a subscription-only estimate + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + + # ~10s base per subscription (MG attempt + per-sub fallback + throttle + # backoff) plus ~1s per 500 resources for the resource-cost breakdown. + $result.EstimatedApiSeconds = [int]([math]::Ceiling(($subCount * 10) + ($resourceCount / 500.0))) + + # -- Step 2: detect a FinOps Hub via Resource Graph ------------------- + $hub = $null + try { + $hubQuery = @" +Resources +| where type == 'microsoft.storage/storageaccounts' +| where tags['cm-resource-parent'] contains 'Microsoft.Cloud/hubs' +| project name, resourceGroup, subscriptionId, location +"@ + $hubRes = Search-AzGraphSafe -Query $hubQuery -Subscription $requested -First 5 + if ($hubRes -and $hubRes.Data -and @($hubRes.Data).Count -gt 0) { + $hub = @($hubRes.Data)[0] + } + } + catch { + # Detection failed — treat as no hub + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + + if (-not $hub) { + # No FinOps Hub in scope — fall back to detecting any Cost Management + # export the caller can read (the generic CSV export fast path). + $exp = Resolve-GenericExportSource -RequestedSubscriptionIds $requested + if ($exp.ExportFound) { + $result.ExportFound = $true + $result.Exports = $exp.Exports + $result.CoverageSubs = $exp.CoverageSubs + $result.Freshness = $exp.Freshness + $result.CoveragePct = $exp.CoveragePct + $result.CoverageKnown = ($null -ne $exp.CoveragePct) + $result.Recommendation = $exp.Recommendation + $result.Message = $exp.Message + return [pscustomobject]$result + } + + $apiMin = [math]::Round($result.EstimatedApiSeconds / 60.0, 1) + $extra = if ($exp.Message) { " $($exp.Message)" } else { '' } + $result.Message = "No FinOps Hub found in scope. Cost scans will use the Cost Management API (~$apiMin min for $subCount subscription(s)).$extra" + return [pscustomobject]$result + } + + $result.HubFound = $true + $result.Hub = [ordered]@{ + Name = $hub.name + ResourceGroup = $hub.resourceGroup + SubscriptionId = $hub.subscriptionId + Location = $hub.location + } + + # -- Discover the hub's Azure Data Explorer (Kusto) cluster ----------- + # The scalable ONLINE path. The toolkit deploys the cluster alongside the + # hub storage (same resource group) and tags it ftk-tool == 'FinOps hubs'. + # This is the same discovery the toolkit's own ftk-hubs-connect flow uses. + # Attached here so it flows through every return path below and the + # detect tool can advertise it without a second Resource Graph call. + $cluster = Get-HubKustoCluster -RequestedSubscriptionIds $requested -HubResourceGroup $hub.resourceGroup + if ($cluster -and $cluster.ClusterUri) { + $result.KustoClusterUri = $cluster.ClusterUri + $result.KustoDatabase = 'Hub' + $result.HubVersion = $cluster.HubVersion + } + + # -- Step 3: verify the hub storage is readable ----------------------- + $access = Test-HubStorageAccess -StorageAccountName $hub.name -ResourceGroupName $hub.resourceGroup + $result.Readable = $access.Readable + $result.ReadBlocker = $access.Blocker + $result.ReadBlockerDetail = $access.Detail + $result.RemediationHint = $access.Remediation + + if (-not $access.Readable) { + $apiMin = [math]::Round($result.EstimatedApiSeconds / 60.0, 1) + if ($result.KustoClusterUri) { + # Storage isn't readable, but the hub's Kusto cluster is the + # scalable path anyway - prefer it. Storage access is not required. + $result.Recommendation = 'UseHub' + $result.Message = "FinOps Hub '$($hub.name)' storage is not directly readable ($($access.Detail)), but its Kusto cluster ($($result.KustoClusterUri)) was found. Cost scans query the cluster directly (aggregation pushed into the engine). If you also need storage reads, $($access.Remediation)" + return [pscustomobject]$result + } + $result.Recommendation = 'FixAccessThenHub' + $result.Message = "FinOps Hub '$($hub.name)' found but not readable: $($access.Detail) $($access.Remediation) Otherwise fall back to the Cost Management API (~$apiMin min)." + return [pscustomobject]$result + } + + # -- Step 4: coverage (which subscriptions the export contains) ------- + $coverage = Get-HubCoverage -Context $access.Context + $result.CoverageSubs = $coverage.Subs + $result.Freshness = $coverage.Freshness + + $coveredRequested = @($requested | Where-Object { $coverage.Subs -contains $_ }) + if ($coverage.Subs.Count -eq 0) { + $result.CoveragePct = $null + $result.Recommendation = 'UseHub' + $kustoNote0 = if ($result.KustoClusterUri) { + " A FinOps Hub Kusto cluster was found ($($result.KustoClusterUri)); cost scans query it directly and push aggregation into the engine (scales to large datasets)." + } + else { + ' Cost scans use the FinOps Hub storage reader (small-dataset convenience path). For large hubs, use a Kusto database (Azure Data Explorer / Fabric, or set FINOPS_HUB_KUSTO_URI for a local ftklocal emulator).' + } + $result.Message = "FinOps Hub '$($hub.name)' is readable. Coverage could not be confirmed from export metadata; proceeding with hub data (verify the 'as of' date in results).$kustoNote0" + return [pscustomobject]$result + } + + $result.CoveragePct = [int][math]::Round((($coveredRequested.Count / [double]$subCount) * 100)) + $result.CoverageKnown = $true + + $asOf = if ($coverage.Freshness) { " as of $($coverage.Freshness)" } else { '' } + $kustoNote = if ($result.KustoClusterUri) { + " A FinOps Hub Kusto cluster was found ($($result.KustoClusterUri)); cost scans query it directly and push aggregation into the engine (scales to large datasets)." + } + else { + ' Cost scans use the FinOps Hub storage reader (rows aggregated in PowerShell) - the small-dataset convenience path. For large hubs, use a Kusto database: an Azure Data Explorer / Fabric cluster, or set FINOPS_HUB_KUSTO_URI to a local ftklocal emulator.' + } + if ($coveredRequested.Count -eq $subCount) { + $result.Recommendation = 'UseHub' + $result.Message = "FinOps Hub '$($hub.name)' covers all $subCount requested subscription(s)$asOf.$kustoNote" + } + else { + $result.Recommendation = 'UseHubPartial' + $apiMin = [math]::Round($result.EstimatedApiSeconds / 60.0, 1) + $result.Message = "FinOps Hub '$($hub.name)' covers $($coveredRequested.Count) of $subCount subscription(s)$asOf. Choose: hub-only (partial), full API (~$apiMin min), or hybrid (hub where covered, API for the rest).$kustoNote" + } + + return [pscustomobject]$result +} + +function Get-HubKustoCluster { + # Discover the FinOps Hub's Azure Data Explorer (Kusto) cluster via Resource + # Graph. Mirrors the toolkit's own ftk-hubs-connect query: the cluster is + # tagged ftk-tool == 'FinOps hubs' and (for toolkit deployments) lives in the + # hub's resource group. Returns @{ ClusterUri; HubVersion; ResourceGroup } + # or $null. Read-only; failures degrade to no cluster (storage path is used). + [CmdletBinding()] + param( + [Parameter(Mandatory)][string[]]$RequestedSubscriptionIds, + [string]$HubResourceGroup + ) + try { + $query = @" +resources +| where type =~ 'microsoft.kusto/clusters' +| where tags['ftk-tool'] == 'FinOps hubs' +| extend hubVersion = tostring(tags['ftk-version']) +| project clusterUri = tostring(properties['uri']), hubVersion, resourceGroup, subscriptionId +"@ + $res = Search-AzGraphSafe -Query $query -Subscription $RequestedSubscriptionIds -First 10 + if (-not $res -or -not $res.Data -or @($res.Data).Count -eq 0) { return $null } + + $rows = @($res.Data | Where-Object { $_.clusterUri }) + if ($rows.Count -eq 0) { return $null } + + # Prefer the cluster in the same resource group as the hub storage + # (toolkit co-locates them); otherwise take the first FinOps hub cluster. + $match = $null + if ($HubResourceGroup) { + $match = $rows | Where-Object { $_.resourceGroup -eq $HubResourceGroup } | Select-Object -First 1 + } + if (-not $match) { $match = $rows[0] } + + return @{ + ClusterUri = [string]$match.clusterUri + HubVersion = if ($match.hubVersion) { [string]$match.hubVersion } else { $null } + ResourceGroup = [string]$match.resourceGroup + } + } + catch { + return $null + } +} + +function Test-HubStorageAccess { + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$StorageAccountName, + [Parameter(Mandatory)][string]$ResourceGroupName + ) + + $out = @{ + Readable = $false + Blocker = 'Unknown' + Detail = $null + Remediation = $null + Context = $null + } + + # Ensure the storage module is available before calling its cmdlets so + # a module-load failure is not mistaken for an access problem. + foreach ($m in @('Az.Accounts', 'Az.Storage')) { + if (-not (Get-Module $m)) { Import-Module $m -ErrorAction SilentlyContinue } + } + if (-not (Get-Module 'Az.Storage')) { + $out.Blocker = 'DependencyMissing' + $out.Detail = 'The Az.Storage module is not installed or could not be loaded.' + $out.Remediation = "Install it with: Install-Module Az.Storage -Scope CurrentUser" + return $out + } + + # Read network configuration first so blockers can be named precisely. + $publicAccess = $null + $defaultAction = $null + try { + $acct = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -Name $StorageAccountName -ErrorAction Stop + $publicAccess = $acct.PublicNetworkAccess + $defaultAction = $acct.NetworkRuleSet.DefaultAction + } + catch { + $msg = $_.Exception.Message + if ($msg -match 'could not be loaded|not loaded|Install-Module') { + $out.Blocker = 'DependencyMissing' + $out.Detail = "A required module failed to load: $msg" + $out.Remediation = 'Ensure Az.Storage and Az.Accounts are installed and importable.' + } + elseif ($_.Exception.Response.StatusCode -eq 403 -or $_.Exception.StatusCode -eq 403 -or $msg -match 'AuthorizationFailed|\b403\b|\bForbidden\b') { + $out.Blocker = 'NoRbac' + $out.Detail = "Cannot read the storage account ('$msg')." + $out.Remediation = 'Grant at least Reader on the hub storage account.' + } + elseif ($_.Exception.Response.StatusCode -eq 401 -or $_.Exception.StatusCode -eq 401 -or $msg -match 'AuthenticationFailed|\b401\b|\bUnauthorized\b') { + $out.Blocker = 'AuthenticationFailed' + $out.Detail = "Storage account authentication failed: $msg" + $out.Remediation = 'Verify the current Azure identity and tenant before retrying.' + } + else { + $out.Blocker = 'LookupFailed' + $out.Detail = "Storage account lookup failed: $msg" + $out.Remediation = 'Check the account and resource group, network connectivity, and service availability, then retry. This error does not establish a missing role.' + } + return $out + } + + # Attempt a lightweight data-plane read to confirm true readability. + try { + $ctx = New-AzStorageContext -StorageAccountName $StorageAccountName -UseConnectedAccount -ErrorAction Stop + $null = Get-AzDataLakeGen2ChildItem -Context $ctx -FileSystem 'msexports' -MaxCount 1 -ErrorAction Stop + $out.Readable = $true + $out.Blocker = 'None' + $out.Context = $ctx + return $out + } + catch { + $msg = $_.Exception.Message + } + + # Classify the failure into an actionable blocker. + if ("$publicAccess" -eq 'Disabled') { + $out.Blocker = 'PublicAccessDisabled' + $out.Detail = 'Public network access is disabled on the hub storage account.' + $out.Remediation = 'Re-enable public network access (or run from an allowed private endpoint), then retry.' + } + elseif ($msg -match '403|AuthorizationPermissionMismatch|does not have permission|not authorized') { + $out.Blocker = 'NoRbac' + $out.Detail = 'You lack a data role on the hub storage account.' + $out.Remediation = "Grant 'Storage Blob Data Reader' on the hub storage account, then retry." + } + elseif ("$defaultAction" -eq 'Deny' -or $msg -match '403.*firewall|network rule|not allowed to access') { + $out.Blocker = 'NetworkDenied' + $out.Detail = 'The hub storage firewall denied access from this network.' + $out.Remediation = 'Add your IP to the storage firewall, or run from an allowed network/private endpoint.' + } + else { + $out.Blocker = 'Unknown' + $out.Detail = "Hub storage read failed: $msg" + $out.Remediation = 'Verify access to the hub storage account, then retry.' + } + + return $out +} + +function Get-HubCoverage { + [CmdletBinding()] + param( + [Parameter(Mandatory)]$Context + ) + + $out = @{ + Subs = @() + Freshness = $null + } + + # The Cost Management manifest written per export run records the export + # scope and the data date range. Reading the newest manifest gives both + # coverage and freshness without downloading the cost rows themselves. + try { + $manifests = @(Get-AzDataLakeGen2ChildItem -Context $Context -FileSystem 'msexports' -Recurse -ErrorAction Stop | + Where-Object { -not $_.IsDirectory -and $_.Path -like '*manifest.json' }) + + if ($manifests.Count -eq 0) { return $out } + + # Newest run first (run folders are timestamped in the path). + $manifests = $manifests | Sort-Object Path -Descending + + $subs = [System.Collections.Generic.HashSet[string]]::new() + $latestDate = $null + + # Inspect up to a handful of recent manifests to gather coverage. + foreach ($m in ($manifests | Select-Object -First 10)) { + $tempFile = Join-Path ([System.IO.Path]::GetTempPath()) "ftk-manifest-$([guid]::NewGuid().ToString('N')).json" + try { + Get-AzDataLakeGen2ItemContent -Context $Context -FileSystem 'msexports' -Path $m.Path -Destination $tempFile -Force -ErrorAction Stop | Out-Null + $json = Get-Content -Path $tempFile -Raw | ConvertFrom-Json -ErrorAction Stop + + $scope = $json.exportConfig.resourceId + if (-not $scope) { $scope = $json.runInfo.scope } + if ($scope -match '/subscriptions/([0-9a-f-]{36})') { + [void]$subs.Add($Matches[1]) + } + + $end = $json.dateRange.end + if (-not $end) { $end = $json.runInfo.endDate } + if ($end) { + try { + $d = [datetime]$end + if (-not $latestDate -or $d -gt $latestDate) { $latestDate = $d } + } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + } + } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + finally { + Remove-Item $tempFile -Force -ErrorAction SilentlyContinue + } + } + + $out.Subs = @($subs) + if ($latestDate) { $out.Freshness = $latestDate.ToString('yyyy-MM-dd') } + } + catch { + # Coverage stays empty (unknown) on any failure. + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + + return $out +} + +# -- Generic Cost Management export fallback ------------------------------ +# When no FinOps Hub is present, look for any Cost Management export the +# caller can read (classic or FOCUS, CSV). Picks the newest-run CSV export +# per subscription (deduping overlapping exports so cost is not double +# counted), and reports coverage + freshness so the caller can take the +# export fast path the same way it does for a hub. CSV only — Parquet +# exports are detected and reported but not read in PowerShell. +function Resolve-GenericExportSource { + [CmdletBinding()] + param([Parameter(Mandatory)][string[]]$RequestedSubscriptionIds) + + $out = @{ + ExportFound = $false + Exports = @() + CoverageSubs = @() + CoveragePct = 0 + Freshness = $null + Recommendation = 'UseApi' + Message = $null + } + + # Export module not loaded — nothing to do. + if (-not (Get-Command Find-CostExport -ErrorAction SilentlyContinue)) { return $out } + + $requested = @($RequestedSubscriptionIds | Where-Object { $_ } | Select-Object -Unique) + if ($requested.Count -eq 0) { return $out } + $subCount = $requested.Count + + # Find-CostExport needs subscription objects (Id + Name). The resolver + # only has IDs; names are not needed for detection (the dispatch + # supplies the real sub objects to the converters). + $subObjs = $requested | ForEach-Object { [pscustomobject]@{ Id = $_; Name = $_ } } + + $exports = @() + try { $exports = @(Find-CostExport -Subscriptions $subObjs) } catch { $exports = @() } + + # Storage-first discovery: some exports can't be found via Cost Management + # at all from this tenant - e.g. a hub export defined at a customer's + # management group (in the customer's tenant) and delivered cross-tenant + # via Azure Lighthouse, which only delegates subscription scope. The + # definition is invisible, but the blobs land in a storage account we can + # read. Reconstruct those from the blob layout and merge (deduped). + # + # Always runs (not just when control plane is empty): the common + # cross-tenant case is MIXED - control plane surfaces some subs' exports + # while the MG-scoped hub export stays invisible. The tax is modest + # (~1 ARM call/sub + 1 container-list/storage-acct; blob-listing only on + # accounts that actually have an export-named container). + if (Get-Command Find-CostExportFromStorage -ErrorAction SilentlyContinue) { + try { + $knownKeys = @{} + foreach ($e in $exports) { + if ($e.StorageResourceId -and $e.Container -and $e.Name) { + $knownKeys[("$($e.StorageResourceId)|$($e.Container)|$($e.Name)").ToLowerInvariant()] = $true + } + } + $storageExports = @(Find-CostExportFromStorage -Subscriptions $subObjs -KnownKeys $knownKeys) + if ($storageExports.Count -gt 0) { $exports = @($exports) + $storageExports } + } + catch { + Write-Verbose "Non-fatal: $($_.Exception.Message)" + } + } + + if ($exports.Count -eq 0) { return $out } + + $csv = @($exports | Where-Object { $_.Format -match 'csv' }) + if ($csv.Count -eq 0) { + $out.Message = 'Found Cost Management export(s), but they write Parquet, which is not read in PowerShell. Recreate the export with CSV format to enable the export fast path.' + return $out + } + + # Dedupe by subscription: keep the newest-run CSV export per SubId so two + # exports covering the same subscription do not double-count. + $bestBySub = @{} + foreach ($e in $csv) { + $sid = "$($e.SubId)" + if ([string]::IsNullOrWhiteSpace($sid)) { continue } + $existing = $bestBySub[$sid] + if (-not $existing) { $bestBySub[$sid] = $e; continue } + $a = if ($e.LastRunDate) { [datetime]$e.LastRunDate } else { [datetime]::MinValue } + $b = if ($existing.LastRunDate) { [datetime]$existing.LastRunDate } else { [datetime]::MinValue } + if ($a -gt $b) { $bestBySub[$sid] = $e } + } + $chosen = @($bestBySub.Values) + if ($chosen.Count -eq 0) { $chosen = $csv } + + $coverageSubs = @($chosen | ForEach-Object { "$($_.SubId)" } | Where-Object { $_ } | Select-Object -Unique) + $coveredRequested = @($requested | Where-Object { $coverageSubs -contains $_ }) + + $dates = @($chosen | ForEach-Object { $_.LastRunDate } | Where-Object { $_ } | Sort-Object -Descending) + $freshness = if ($dates.Count -gt 0) { ([datetime]$dates[0]).ToString('yyyy-MM-dd') } else { $null } + + $out.ExportFound = $true + $out.Exports = $chosen + $out.CoverageSubs = $coverageSubs + $out.Freshness = $freshness + $out.CoveragePct = if ($subCount -gt 0) { [int][math]::Round((($coveredRequested.Count / [double]$subCount) * 100)) } else { 0 } + + $asOf = if ($freshness) { " as of $freshness" } else { '' } + $names = (($chosen | ForEach-Object { $_.Name } | Select-Object -Unique) -join ', ') + if ($coveredRequested.Count -ge $subCount) { + $out.Recommendation = 'UseExport' + $out.Message = "Cost Management export(s) [$names] cover all $subCount requested subscription(s)$asOf. Using export data (fast)." + } + else { + $out.Recommendation = 'UseExportPartial' + $out.Message = "Cost Management export(s) [$names] cover $($coveredRequested.Count) of $subCount subscription(s)$asOf. The remaining subscriptions need the live Cost Management API." + } + + return $out +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 new file mode 100644 index 000000000..3d6bcfe43 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Resolve-CurrencyLabel.ps1 @@ -0,0 +1,91 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '', Justification = 'Private helper; the returned shape varies by input and is not a declared contract.')] +param() + +########################################################################### +# RESOLVE-CURRENCYLABEL.PS1 +# MIXED-CURRENCY DETECTION FOR COST AGGREGATION +########################################################################### +# Purpose: Track the currencies seen while summing cost rows and label the +# total honestly when more than one appears. +# Date: Created for FinOps Multitool +# +# Description: +# A tenant can bill different subscriptions in different currencies. Summing +# those rows produces a number that means nothing, and labelling it with +# whichever row happened to come last makes the result look authoritative. +# Callers add each row's currency, then ask for a label: a single currency +# is reported as-is, several are reported as 'Mixed'. +# +# ── Functions ─────────────────────────────────────────────────── +# Add-CurrencySeen Record one row's currency +# Resolve-CurrencyLabel Currency code, or 'Mixed' when several were seen +# Test-CurrencyMixed True when the total spans more than one currency +########################################################################### + +function Add-CurrencySeen { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Seen, + + [Parameter()] + [string]$Currency + ) + if (-not [string]::IsNullOrWhiteSpace($Currency)) { + $Seen[$Currency.Trim().ToUpperInvariant()] = $true + } +} + +function Resolve-CurrencyLabel { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Seen, + + [Parameter()] + [string]$Fallback = 'USD' + ) + $keys = @($Seen.Keys) + if ($keys.Count -eq 0) { return $Fallback } + if ($keys.Count -eq 1) { return [string]$keys[0] } + return 'Mixed' +} + +function Test-CurrencyMixed { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [hashtable]$Seen + ) + return (@($Seen.Keys).Count -gt 1) +} + +function Measure-FinOpsSavingsEstimate { + param([AllowEmptyCollection()][object[]]$Recommendations) + + $currencies = @{} + $total = 0.0 + $count = 0 + foreach ($recommendation in $Recommendations) { + if ($null -eq $recommendation.AnnualSavings) { continue } + $currency = ([string]$recommendation.Currency).Trim().ToUpperInvariant() + if ($currency -notmatch '^[A-Z]{3}$' -or $currency -in @('XXX', 'XTS')) { + return @{ Total = $null; Currency = 'Unknown'; CostIssue = 'Savings currency is missing or invalid; the combined estimate is unavailable.' } + } + Add-CurrencySeen -Seen $currencies -Currency $currency + if (Test-CurrencyMixed -Seen $currencies) { + return @{ Total = $null; Currency = 'Mixed'; CostIssue = 'Savings in different currencies cannot be combined.' } + } + try { $total += Get-HubCostValue -Row $recommendation -Column 'AnnualSavings' } + catch { return @{ Total = $null; Currency = $currency; CostIssue = 'A savings amount is invalid; the combined estimate is unavailable.' } } + $count++ + } + return @{ + Total = if ($count -gt 0) { [math]::Round($total, 2) } else { $null } + Currency = Resolve-CurrencyLabel -Seen $currencies -Fallback 'Unknown' + CostIssue = $null + } +} diff --git a/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 new file mode 100644 index 000000000..d7b2eaa32 --- /dev/null +++ b/src/powershell/Private/FinOpsMultitool/modules/helpers/Search-AzGraphSafe.ps1 @@ -0,0 +1,175 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive console tool; the formatted console output is the user interface.')] +param() + +# Escapes a caller-supplied value for safe use inside a single-quoted KQL +# string literal. KQL uses backslash escapes, so \ and ' must both be escaped +# or a crafted value could terminate the literal and alter query semantics. +function ConvertTo-KqlLiteral { + param([string]$Value) + if ($null -eq $Value) { return '' } + return $Value.Replace('\', '\\').Replace("'", "\'") +} + +function Search-AzGraphSafe { + param( + [Parameter(Mandatory)][string]$Query, + [string[]]$Subscription, + [int]$First = 1000, + [string]$SkipToken, + [int]$TimeoutSeconds = 60, + [int]$MaxRetries = 2, + [switch]$All, + [ValidateRange(1, 10000)] + [int]$MaxPages = 100 + ) + + if (-not $All) { + return Invoke-AzGraphQueryPage -Query $Query -Subscription $Subscription -First $First ` + -SkipToken $SkipToken -TimeoutSeconds $TimeoutSeconds -MaxRetries $MaxRetries + } + + # Resource Graph caps a response at $First rows and hands back a continuation + # token. A caller that totals rows without following that token reports a + # truncated set as a complete one, which reads as "fewer resources" rather + # than as an error. -All follows the token and returns every row. + $rows = [System.Collections.Generic.List[object]]::new() + $token = $SkipToken + $pageCount = 0 + $visitedTokens = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + if ($SkipToken) { [void]$visitedTokens.Add($SkipToken) } + + do { + $usedToken = $token + $page = Invoke-AzGraphQueryPage -Query $Query -Subscription $Subscription -First $First ` + -SkipToken $usedToken -TimeoutSeconds $TimeoutSeconds -MaxRetries $MaxRetries + + if (-not $page) { + throw "Resource Graph query failed after $pageCount page(s); results are incomplete." + } + + if ($page.Data) { $rows.AddRange(@($page.Data)) } + $token = $page.SkipToken + $pageCount++ + + if ($page.Data -and -not $token -and @($page.Data).Count -ge $First) { + throw 'Resource Graph returned a full page without a continuation token; results may be incomplete. Keep id in the query projection or narrow the scope.' + } + + # A token that does not advance would re-request the page we just added. + if ($token -and -not $visitedTokens.Add($token)) { + throw 'Resource Graph returned a repeated continuation token; results are incomplete.' + } + + if ($token -and $pageCount -ge $MaxPages) { + throw "Resource Graph query stopped after $MaxPages pages; results are incomplete." + } + } while ($token) + + return [PSCustomObject]@{ + Data = @($rows) + SkipToken = $null + Count = $rows.Count + } +} + +# A single Resource Graph request with retry and backoff. Kept separate from the +# paging loop so that loop can be exercised without issuing live queries. +function Invoke-AzGraphQueryPage { + param( + [Parameter(Mandatory)][string]$Query, + [string[]]$Subscription, + [int]$First = 1000, + [string]$SkipToken, + [int]$TimeoutSeconds = 60, + [int]$MaxRetries = 2 + ) + + for ($attempt = 0; $attempt -le $MaxRetries; $attempt++) { + $ps = [powershell]::Create() + $ps.RunspacePool = $script:RunspacePool + [void]$ps.AddScript({ + param($q, $s, $f, $st) + $p = @{ Query = $q; Subscription = $s; First = $f; ErrorAction = 'Stop' } + if ($st) { $p['SkipToken'] = $st } + $r = Search-AzGraph @p + $json = if ($r.Data -and $r.Data.Count -gt 0) { + $r.Data | ConvertTo-Json -Depth 20 -Compress + } + else { '[]' } + [PSCustomObject]@{ + JsonData = $json + SkipToken = $r.SkipToken + Count = if ($r.Data) { $r.Data.Count } else { 0 } + } + }).AddArgument($Query).AddArgument($Subscription).AddArgument($First).AddArgument($SkipToken) + + $asyncResult = $ps.BeginInvoke() + Wait-ForRunspace -AsyncResult $asyncResult -TimeoutSeconds $TimeoutSeconds + + $result = $null + $is429 = $false + $isTransient = $false + if ($asyncResult.IsCompleted) { + try { + $raw = $ps.EndInvoke($asyncResult) + $wrapper = if ($raw -and $raw.Count -gt 0) { $raw[0] } else { $null } + if ($wrapper) { + $data = if ($wrapper.JsonData -and $wrapper.JsonData -ne '[]') { + $parsed = $wrapper.JsonData | ConvertFrom-Json + if ($parsed -is [array]) { $parsed } else { @($parsed) } + } + else { @() } + $result = [PSCustomObject]@{ + Data = $data + SkipToken = $wrapper.SkipToken + Count = $wrapper.Count + } + } + if ($ps.Streams.Error.Count -gt 0) { + $errMsg = $ps.Streams.Error[0].Exception.Message + if ($errMsg -match '429|throttl|Too Many Requests') { $is429 = $true; $result = $null } + elseif ($errMsg -match '\b50[0234]\b|ServiceUnavailable|InternalServerError|BadGateway|Gateway Timeout|temporarily unavailable') { $isTransient = $true; $result = $null } + elseif (-not $result) { throw $ps.Streams.Error[0].Exception } + } + } + catch { + if ($_.Exception.Message -match '429|throttl|Too Many Requests') { $is429 = $true } + elseif ($_.Exception.Message -match '\b50[0234]\b|ServiceUnavailable|InternalServerError|BadGateway|Gateway Timeout|temporarily unavailable') { $isTransient = $true } + else { $ps.Dispose(); throw } + } + } + else { + $ps.Stop() + Write-Warning " Resource Graph query timed out after $($TimeoutSeconds)s" + } + + $ps.Dispose() + + if (-not ($is429 -or $isTransient)) { return $result } + if ($attempt -eq $MaxRetries) { return $result } + + # Throttling backs off harder than a transient server error. Both are + # jittered so parallel scans do not retry in lockstep. + $retryAfter = if ($is429) { + Get-JitteredDelay -BaseSeconds ([math]::Min(10 * [math]::Pow(2, $attempt), 30)) + } + else { + Get-JitteredDelay -BaseSeconds ([math]::Min(2 * [math]::Pow(2, $attempt), 15)) + } + $friendly = if ($is429) { + if (Get-Command Get-NextThrottleMessage -ErrorAction SilentlyContinue) { Get-NextThrottleMessage } else { 'Fetching numbers......' } + } + else { + 'Resource Graph is unavailable - retrying...' + } + Write-Host " $friendly" -ForegroundColor Yellow + if (Get-Command Update-ScanStatus -ErrorAction SilentlyContinue) { + Update-ScanStatus $friendly + } + Wait-WithDispatcher -Milliseconds ($retryAfter * 1000) + } + return $null +} diff --git a/src/powershell/Public/Start-FinOpsMultitool.ps1 b/src/powershell/Public/Start-FinOpsMultitool.ps1 new file mode 100644 index 000000000..e79bd8b22 --- /dev/null +++ b/src/powershell/Public/Start-FinOpsMultitool.ps1 @@ -0,0 +1,143 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +<# + .SYNOPSIS + Launches the Azure FinOps Multitool interactive terminal UI. + + .DESCRIPTION + The Start-FinOpsMultitool command launches an interactive terminal UI (TUI) that + scans an Azure tenant for cost optimization, governance, and FinOps insights. The + tool authenticates to Azure, discovers subscriptions, and runs the scan modules you + select - covering cost trends, orphaned resources, idle VMs, tag hygiene, reservation + and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly + alerts, and policy compliance. + + Results are rendered in the terminal and saved automatically on the machine running + the command. Each run gets a private folder with one CSV file per selected scan, + an HTML report, and a text summary. Failed or empty scans have a CSV status record. + + The scan modules are read-only. The TUI requires PowerShell 7 or later on Windows, + macOS, and Linux, the Az modules (Az.Accounts, + Az.ResourceGraph, Az.Storage) and Reader access on the target scope. + + Consoles that cannot drive the arrow-key menus, such as remoting sessions and some + editor terminals, automatically fall back to numbered prompts. Use Accessible to + select numbered prompts in any console, or NonInteractive to run with no prompts at all. + + .PARAMETER SubscriptionId + Optional subscription ID in the current tenant. An unresolved or mismatched + subscription stops the scan without searching other tenants or widening scope. + When omitted, the tool discovers accessible subscriptions in the selected tenant. + + .PARAMETER OutputPath + Optional local parent directory for reports. Each run creates a new timestamped + subfolder and never overwrites earlier reports. The default is FinOpsToolkit/Multitool/Reports + under the current user's LocalApplicationData directory, usually LOCALAPPDATA on Windows. + Git repositories, UNC paths, mapped Windows network drives, symbolic links, and + junctions are rejected. Unix network mounts aren't detected; choose a local filesystem. + Reports contain sensitive cost and resource data; keep custom destinations outside synced folders. + + .PARAMETER Scans + Optional list of scans to run, replacing the default selection. Accepts either the + scan function name, such as Get-OrphanedResources, or its menu label, such as + 'Orphaned Resources'. Use 'All' on its own to select every scan. An unrecognized name is an error. + Explicit null or empty lists, and empty entries, are rejected before the tool starts. + + .PARAMETER DataSource + Optional data source, which skips the data source prompt. Hub reads a configured + Kusto endpoint or a discovered FinOps hub. Export discovers existing CSV/CSV.gz Cost + Management exports without requiring a hub, looking at definitions for the selected + subscriptions, their management-group ancestors, and linked billing accounts, and at + storage accounts in the selected subscriptions. A definition found at a wider scope can + deliver to storage outside the selected subscriptions, and that destination is read. It + reads one chosen export and filters rows to the selected subscriptions. ActualCost or FOCUS + BilledCost is required; Parquet and local-file input aren't supported on this path. + API queries Cost Management directly, and + GraphOnly skips cost-dependent scans and orphan cost enrichment; remaining scans can + still use Azure Monitor, Advisor, policy, and carbon APIs. API and GraphOnly ignore FINOPS_HUB_KUSTO_URI and + don't preload hub data. An explicit Hub selection fails if no hub source is available. + Select API separately to run a live scan. Export also ignores FINOPS_HUB_KUSTO_URI; + a failed export read never switches to live costs. NonInteractive Export requires + exactly one readable discovered candidate. Export mode supports cost totals, resource costs, + cost by tag, and the months present in that export run; separate financial API scans are excluded. + + .PARAMETER NonInteractive + Runs without prompting, for automation and scheduled jobs. Every choice comes from the + parameters or their defaults: all accessible subscriptions in the current tenant unless + SubscriptionId is set, a configured or detected hub or the Cost Management API unless DataSource is + set. Failed automatic hub discovery warns and continues to API in the same scope, + even when every probe fails. Explicit Hub selections never switch to API. + Requires an existing Azure context; authenticate with the intended identity using + Connect-AzAccount before running. Reports are saved automatically even when OutputPath is omitted. + + .PARAMETER Accessible + Uses numbered prompts without clearing the screen or repainting menu rows, even in a + console that supports them. Stays in the signed-in tenant; sign in separately to change tenants. + NonInteractive takes precedence and disables all prompts when both switches are supplied. + + .EXAMPLE + Start-FinOpsMultitool + + Launches the FinOps Multitool TUI. You will be prompted to authenticate and + select the subscriptions and modules to scan. + + .EXAMPLE + Start-FinOpsMultitool -SubscriptionId '00000000-0000-0000-0000-000000000000' + + Launches the TUI scoped to a single subscription. + + .EXAMPLE + Start-FinOpsMultitool -NonInteractive -Scans Get-OrphanedResources, Get-IdleVMs + + Requires an existing authenticated Azure context. Runs two scans without prompting and saves CSV, HTML, and text reports in a new + private run folder under the current user's local application data. + + .LINK + https://aka.ms/ftk/Start-FinOpsMultitool +#> +function Start-FinOpsMultitool { + [CmdletBinding()] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Start-FinOpsMultitool launches a read-only interactive scanner and does not modify system state.')] + [OutputType([void])] + param( + [Parameter()] + [string]$SubscriptionId, + + [Parameter()] + [string]$OutputPath, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string[]]$Scans, + + [Parameter()] + [ValidateSet('Hub', 'Export', 'API', 'GraphOnly')] + [string]$DataSource, + + [Parameter()] + [switch]$NonInteractive, + + [Parameter()] + [switch]$Accessible + ) + + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw "FinOps Multitool requires PowerShell 7 or later. This session is PowerShell $($PSVersionTable.PSVersion). Open PowerShell 7 with 'pwsh', import the module there, and run Start-FinOpsMultitool again. No scan was started." + } + + # Locate the Multitool TUI implementation + $multitoolRoot = Join-Path -Path $PSScriptRoot -ChildPath '../Private/FinOpsMultitool' + $tuiScript = Join-Path -Path $multitoolRoot -ChildPath 'Invoke-FinOpsMultitool.ps1' + + if (-not (Test-Path -Path $tuiScript)) { + Write-Error "FinOps Multitool files not found at '$multitoolRoot'. The module installation may be incomplete." + return + } + + # Dot-source the TUI launcher so Invoke-FinOpsMultitool is defined here, then + # invoke it. The TUI imports its own module set (FinOpsMultitool.psm1) on launch, + # so it stays self-contained and does not leak $script: state into the module. + . $tuiScript + Invoke-FinOpsMultitool @PSBoundParameters +} diff --git a/src/powershell/Tests/Integration/MultitoolPackage.Tests.ps1 b/src/powershell/Tests/Integration/MultitoolPackage.Tests.ps1 new file mode 100644 index 000000000..6e1622a56 --- /dev/null +++ b/src/powershell/Tests/Integration/MultitoolPackage.Tests.ps1 @@ -0,0 +1,129 @@ +Describe 'FinOps multitool packaged integration' -Tag 'MultitoolLocal' { + BeforeAll { + $previousResults = Get-Variable -Name FinOpsResults -Scope Global -ErrorAction SilentlyContinue + $script:HadFinOpsResults = $null -ne $previousResults + $script:PreviousFinOpsResultsValue = $null + if ($script:HadFinOpsResults) { $script:PreviousFinOpsResultsValue = $previousResults.Value } + $script:RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../../..')).Path + $script:BuildRoot = Join-Path $TestDrive 'package-source' + foreach ($relativePath in @('.build', 'src/scripts', 'src/powershell')) { + $null = New-Item -ItemType Directory -Path (Join-Path $script:BuildRoot $relativePath) -Force + } + Copy-Item -LiteralPath (Join-Path $script:RepositoryRoot '.build/BuildHelper') -Destination (Join-Path $script:BuildRoot '.build') -Recurse + Copy-Item -LiteralPath (Join-Path $script:RepositoryRoot '.build/BuildHelper.psm1') -Destination (Join-Path $script:BuildRoot '.build') + Copy-Item -LiteralPath (Join-Path $script:RepositoryRoot 'src/scripts/Get-Version.ps1') -Destination (Join-Path $script:BuildRoot 'src/scripts') + Copy-Item -LiteralPath (Join-Path $script:RepositoryRoot 'package.json') -Destination $script:BuildRoot + foreach ($name in @('FinOpsToolkit.psm1', 'Private', 'Public', 'en-US')) { + Copy-Item -LiteralPath (Join-Path $script:RepositoryRoot "src/powershell/$name") -Destination (Join-Path $script:BuildRoot 'src/powershell') -Recurse + } + Import-Module (Join-Path $script:BuildRoot '.build/BuildHelper.psm1') -Force + Build-PsModule + $version = (& (Join-Path $script:BuildRoot 'src/scripts/Get-Version.ps1')).Split('-')[0] + $script:PackageRoot = Join-Path $script:BuildRoot "release/FinOpsToolkit/$version" + $script:PackageManifest = Join-Path $script:PackageRoot 'FinOpsToolkit.psd1' + Import-Module $script:PackageManifest -Force -ErrorAction Stop + Import-Module (Join-Path $script:PackageRoot 'Private/FinOpsMultitool/FinOpsMultitool.psm1') -Force -Global -ErrorAction Stop + } + + AfterAll { + Remove-Module FinOpsToolkit, FinOpsMultitool, BuildHelper -ErrorAction SilentlyContinue + if ($script:HadFinOpsResults) { + Set-Variable -Name FinOpsResults -Scope Global -Value $script:PreviousFinOpsResultsValue + } + else { Remove-Variable -Name FinOpsResults -Scope Global -ErrorAction SilentlyContinue } + } + + It 'Imports the built manifest and exports the real public launcher' { + $builder = [Management.Automation.Language.Parser]::ParseFile((Join-Path $script:BuildRoot '.build/BuildHelper/Build-PsModule.ps1'), [ref]$null, [ref]$null) + $directoryLiterals = @($builder.FindAll({ + $args[0] -is [Management.Automation.Language.StringConstantExpressionAst] -and + $args[0].Value -match '^src/powershell/(private|public|en-US)$' + }, $true).Value) + $directoryLiterals.Count | Should -Be 3 + foreach ($relativePath in $directoryLiterals) { + $leafName = Split-Path $relativePath -Leaf + $parentPath = Join-Path $script:BuildRoot (Split-Path $relativePath -Parent) + @(Get-ChildItem -LiteralPath $parentPath -Directory | Where-Object { $_.Name -ceq $leafName }).Count | Should -Be 1 + } + $module = Test-ModuleManifest -Path $script:PackageManifest -ErrorAction Stop + $module.ExportedFunctions.Keys | Should -Contain 'Start-FinOpsMultitool' + $command = Get-Command Start-FinOpsMultitool -Module FinOpsToolkit -ErrorAction Stop + $command.ScriptBlock.File | Should -Be (Join-Path $script:PackageRoot 'Public/Start-FinOpsMultitool.ps1') + $module.Path | Should -Be $script:PackageManifest + } + + It 'Packages the nested scanner, Parquet reader, and KPI catalog' { + foreach ($relativePath in @( + 'Private/FinOpsMultitool/FinOpsMultitool.psm1' + 'Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1' + 'Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1' + 'Private/FinOpsMultitool/kpi/kpi-catalog.json' + )) { + Test-Path -LiteralPath (Join-Path $script:PackageRoot $relativePath) -PathType Leaf | Should -BeTrue + } + $catalog = Get-Content -LiteralPath (Join-Path $script:PackageRoot 'Private/FinOpsMultitool/kpi/kpi-catalog.json') -Raw | ConvertFrom-Json + $catalog.kpis.Count | Should -BeGreaterThan 0 + } + + It 'Runs the packaged public launcher and writes CSV, HTML, and text reports without Azure access' { + (Get-Module FinOpsMultitool).Path | Should -Be (Join-Path $script:PackageRoot 'Private/FinOpsMultitool/FinOpsMultitool.psm1') + Mock Import-Module -ModuleName FinOpsToolkit { } + Mock Clear-Host -ModuleName FinOpsToolkit { } + Mock Write-Host -ModuleName FinOpsToolkit { } + Mock Read-Host -ModuleName FinOpsToolkit { throw 'The packaged noninteractive launcher must not prompt.' } + Mock Connect-AzAccount -ModuleName FinOpsToolkit { throw 'The packaged smoke test must not authenticate.' } + Mock Get-AzTenant -ModuleName FinOpsToolkit { throw 'The packaged smoke test must not enumerate tenants.' } + Mock Get-AzContext -ModuleName FinOpsToolkit { + [pscustomobject]@{ Account = @{ Id = 'test@example.test' }; Tenant = @{ Id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } } + } + Mock Get-AzSubscription -ModuleName FinOpsToolkit { + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Synthetic subscription'; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; State = 'Enabled' } + } + Mock Set-AzContext -ModuleName FinOpsToolkit { } + Mock Search-AzGraph -ModuleName FinOpsToolkit { throw 'Explicit API mode must not discover a hub.' } + Mock Resolve-CostMgId -ModuleName FinOpsMultitool { $null } + foreach ($moduleName in @('FinOpsToolkit', 'FinOpsMultitool')) { + Mock Invoke-RestMethod -ModuleName $moduleName { throw 'Unexpected external HTTP request.' } + Mock Invoke-WebRequest -ModuleName $moduleName { throw 'Unexpected external HTTP request.' } + Mock Get-AzAccessToken -ModuleName $moduleName { throw 'The packaged smoke test must not request a token.' } + } + Mock Invoke-AzRestMethod -ModuleName FinOpsMultitool { throw 'Unexpected direct Azure REST request.' } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -notmatch '^/subscriptions/11111111-1111-1111-1111-111111111111/providers/Microsoft\.CostManagement/(query|forecast)\?') { + throw 'Unexpected API path in the packaged smoke test.' + } + $amount = if ($Path -match '/forecast\?') { 150.0 } else { 100.0 } + $content = @{ properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'Currency' }); rows = @(, @($amount, 'EUR')) } } | ConvertTo-Json -Depth 8 + [pscustomobject]@{ StatusCode = 200; Content = $content } + } + + $reportRoot = Join-Path $TestDrive 'packaged reports' + Start-FinOpsMultitool -SubscriptionId '11111111-1111-1111-1111-111111111111' -Scans Get-CostData -DataSource API -OutputPath $reportRoot -NonInteractive -ErrorAction Stop + + $scanResults = Get-Variable -Name FinOpsResults -Scope Global -ValueOnly + $scanResults.ContainsKey('_error_Get-CostData') | Should -BeFalse -Because ($scanResults | ConvertTo-Json -Depth 8 -Compress) + $runs = @(Get-ChildItem -LiteralPath $reportRoot -Directory) + $runs.Count | Should -Be 1 + $csvFiles = @(Get-ChildItem -LiteralPath $runs[0].FullName -Filter '*.csv') + $csvFiles.Count | Should -Be 1 + $rows = @(Import-Csv -LiteralPath $csvFiles[0].FullName) + $rows.Count | Should -Be 1 + $rows[0].SubscriptionId | Should -Be '11111111-1111-1111-1111-111111111111' + $rows[0].Actual | Should -Be '100' + $rows[0].Forecast | Should -Be '150' + $rows[0].Currency | Should -Be 'EUR' + $html = Get-Content -LiteralPath (Join-Path $runs[0].FullName 'FinOpsReport.html') -Raw + $html | Should -Match 'Scans Run
1
' + $html | Should -Match 'EUR' + $summary = Get-Content -LiteralPath (Join-Path $runs[0].FullName 'ScanSummary.txt') -Raw + $summary | Should -Match 'Cost Data' + $summary | Should -Not -Match 'ERROR:' + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 2 -Exactly + Should -Invoke Invoke-AzRestMethod -ModuleName FinOpsMultitool -Times 0 -Exactly + Should -Invoke Connect-AzAccount -ModuleName FinOpsToolkit -Times 0 -Exactly + Should -Invoke Get-AzTenant -ModuleName FinOpsToolkit -Times 0 -Exactly + Should -Invoke Get-AzSubscription -ModuleName FinOpsToolkit -Times 1 -Exactly -ParameterFilter { + $SubscriptionId -eq '11111111-1111-1111-1111-111111111111' -and $TenantId -eq 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + } + } +} \ No newline at end of file diff --git a/src/powershell/Tests/Integration/MultitoolParquet.Tests.ps1 b/src/powershell/Tests/Integration/MultitoolParquet.Tests.ps1 new file mode 100644 index 000000000..452bf43a0 --- /dev/null +++ b/src/powershell/Tests/Integration/MultitoolParquet.Tests.ps1 @@ -0,0 +1,122 @@ +Describe 'FinOps multitool real Parquet integration' -Tag 'MultitoolLocal' { + BeforeAll { + $script:ParquetHelper = (Resolve-Path (Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/modules/helpers/Read-FinOpsHubData.ps1')).Path + $script:ParquetCache = Join-Path $TestDrive 'reader-cache' + $script:ParquetFixture = Join-Path $TestDrive 'synthetic-focus.parquet' + $script:ColdProcessId = $null + + function Invoke-ParquetIntegrationProcess { + param([bool]$ReuseCache) + + $helperPath = $script:ParquetHelper + $cachePath = $script:ParquetCache + $fixturePath = $script:ParquetFixture + $cacheReuseRequested = $ReuseCache + $job = Start-Job -ScriptBlock { + $helperPath = $using:helperPath + $cachePath = $using:cachePath + $fixturePath = $using:fixturePath + $reuseCache = $using:cacheReuseRequested + $ErrorActionPreference = 'Stop' + . $HelperPath + function Get-FinOpsParquetCachePath { return $CachePath } + + $loadedAtStart = @([AppDomain]::CurrentDomain.GetAssemblies() | Where-Object { $_.GetName().Name -eq 'Parquet' }).Count + if ($loadedAtStart -ne 0) { throw 'The integration process must start without Parquet loaded.' } + $privateRoot = Split-Path $CachePath -Parent + $env:NUGET_HTTP_CACHE_PATH = Join-Path $privateRoot 'nuget-http' + $env:NUGET_PACKAGES = Join-Path $privateRoot 'nuget-global' + $env:NUGET_SCRATCH = Join-Path $privateRoot 'nuget-scratch' + $manifestPath = Join-Path $CachePath 'parquet-manifest.json' + $manifestBefore = if ($ReuseCache) { (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash } else { $null } + if (-not $ReuseCache -and (Test-Path -LiteralPath $CachePath)) { throw 'The cold reader cache must not exist.' } + if (-not (Install-ParquetReader)) { throw "Real Parquet installation failed: $script:FinOpsParquetUnavailableReason" } + $manifestAfter = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash + if ($ReuseCache -and $manifestBefore -ne $manifestAfter) { throw 'The cache was reinstalled instead of reused.' } + + if (-not $ReuseCache) { + $metadataFields = [Parquet.Schema.Field[]]@( + [Parquet.Schema.DataField]::new('Region', [string], $false, $false, $null) + [Parquet.Schema.DataField]::new('Service', [string], $false, $false, $null) + ) + $fields = [Parquet.Schema.Field[]]@( + [Parquet.Schema.DataField]::new('ResourceId', [string], $false, $false, $null) + [Parquet.Schema.StructField]::new('Metadata', $metadataFields) + [Parquet.Schema.DataField]::new('BilledCost', [double], $true, $false, $null) + [Parquet.Schema.DataField]::new('BillingCurrency', [string], $false, $false, $null) + ) + $schema = [Parquet.Schema.ParquetSchema]::new($fields) + $stream = [IO.File]::Create($FixturePath) + try { + $writer = [Parquet.ParquetWriter]::CreateAsync($schema, $stream, $null, $false, [Threading.CancellationToken]::None).GetAwaiter().GetResult() + try { + $writer.CompressionMethod = [Parquet.CompressionMethod]::Snappy + foreach ($group in @( + @{ Names = [string[]]@('charge', 'zero'); Costs = [Nullable[double][]]@(12.5, 0) } + @{ Names = [string[]]@('credit', 'missing'); Costs = [Nullable[double][]]@(-2.25, $null) } + )) { + $rowGroup = $writer.CreateRowGroup() + try { + $columns = @( + [Parquet.Data.DataColumn]::new($fields[0], $group.Names) + [Parquet.Data.DataColumn]::new($metadataFields[0], [string[]]@('test-region', 'test-region')) + [Parquet.Data.DataColumn]::new($metadataFields[1], [string[]]@('test-service', 'test-service')) + [Parquet.Data.DataColumn]::new($fields[2], $group.Costs) + [Parquet.Data.DataColumn]::new($fields[3], [string[]]@('EUR', 'EUR')) + ) + foreach ($column in $columns) { + [void]$rowGroup.WriteColumnAsync($column, [Threading.CancellationToken]::None).GetAwaiter().GetResult() + } + } + finally { $rowGroup.Dispose() } + } + } + finally { $writer.Dispose() } + } + finally { $stream.Dispose() } + } + + $rows = @(Read-ParquetFile -Path $FixturePath) + [pscustomobject]@{ + ProcessId = $PID + LoadedAtStart = $loadedAtStart + AssemblyPath = [Parquet.ParquetReader].Assembly.Location + PackageCount = @(Get-VerifiedParquetPackage -PackageDir (Join-Path $CachePath 'packages')).Count + ManifestValid = Test-ParquetManifest -BasePath $CachePath -ManifestPath $manifestPath + ManifestReused = $ReuseCache -and $manifestBefore -eq $manifestAfter + Rows = $rows + } | ConvertTo-Json -Depth 6 -Compress + } + try { + $output = @($job | Receive-Job -Wait -ErrorAction Stop) + if ($job.State -ne 'Completed' -or $output.Count -ne 1) { throw 'The Parquet process did not produce one completed result.' } + return ($output[0] | ConvertFrom-Json -ErrorAction Stop) + } + finally { $job | Remove-Job -Force } + } + } + + It 'Reads real Snappy-compressed data after installation in a fresh process' -ForEach @( + @{ Phase = 'cold'; ReuseCache = $false } + @{ Phase = 'cached'; ReuseCache = $true } + ) { + $result = Invoke-ParquetIntegrationProcess -ReuseCache $ReuseCache + $result.LoadedAtStart | Should -Be 0 + $result.AssemblyPath | Should -Be (Join-Path $script:ParquetCache 'lib/Parquet.dll') + $result.PackageCount | Should -Be 12 + $result.ManifestValid | Should -BeTrue + if ($ReuseCache) { + $result.ManifestReused | Should -BeTrue + $result.ProcessId | Should -Not -Be $script:ColdProcessId + } + else { $script:ColdProcessId = $result.ProcessId } + $result.Rows.Count | Should -Be 4 + $result.Rows[0].PSObject.Properties.Name | Should -Be @('ResourceId', 'Metadata', 'BilledCost', 'BillingCurrency') + $result.Rows.ResourceId | Should -Be @('charge', 'zero', 'credit', 'missing') + $result.Rows.BillingCurrency | Should -Be @('EUR', 'EUR', 'EUR', 'EUR') + $result.Rows[0].BilledCost | Should -Be 12.5 + $result.Rows[1].BilledCost | Should -Be 0 + $result.Rows[2].BilledCost | Should -Be -2.25 + $result.Rows[3].BilledCost | Should -BeNullOrEmpty + } +} \ No newline at end of file diff --git a/src/powershell/Tests/Unit/AgentPlugins.Tests.ps1 b/src/powershell/Tests/Unit/AgentPlugins.Tests.ps1 index 43ac47b22..29fa3a465 100644 --- a/src/powershell/Tests/Unit/AgentPlugins.Tests.ps1 +++ b/src/powershell/Tests/Unit/AgentPlugins.Tests.ps1 @@ -155,6 +155,22 @@ Describe 'Agent plugin components' { $docs.Count | Should -BeGreaterThan 0 $docs.Extension | Select-Object -Unique | Should -Be @('.md') } + + It 'Materializes linked agent skills into real directories' { + $bundle = Join-Path $TestDrive 'agent-plugin-skills' + $skills = Join-Path $bundle 'skills' + New-Item $skills -ItemType Directory -Force | Out-Null + + # Mirrors how git checks the link out where core.symlinks is disabled: + # a plain file holding the relative target rather than a directory. + Set-Content -LiteralPath (Join-Path $skills 'cost-allocation') -Value '../../agent-skills/cost-allocation' -NoNewline + + & (Join-Path $script:RepoRoot 'src/scripts/Build-AgentPlugin.ps1') -DestDir $bundle + + $materialized = Join-Path $skills 'cost-allocation' + (Get-Item $materialized).PSIsContainer | Should -BeTrue + Join-Path $materialized 'SKILL.md' | Should -Exist + } } Describe 'Deprecated azure-cost-management skill' { diff --git a/src/powershell/Tests/Unit/AgentSkillRouting.Tests.ps1 b/src/powershell/Tests/Unit/AgentSkillRouting.Tests.ps1 new file mode 100644 index 000000000..97dd0794f --- /dev/null +++ b/src/powershell/Tests/Unit/AgentSkillRouting.Tests.ps1 @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Agent skill routing' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../../../..')).Path + $script:SkillRoot = Join-Path $script:RepoRoot 'src/templates/agent-skills' + $script:PluginSkillRoot = Join-Path $script:RepoRoot 'src/templates/agent-plugin/skills' + + # A skill only counts as shipped when it has content. An empty directory + # survives on disk but is absent from git, so it reaches nobody. + function Get-ShippedSkill { + param([string]$Root) + if (-not (Test-Path $Root)) { return @() } + Get-ChildItem $Root -Directory -ErrorAction SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName 'SKILL.md') } | + Select-Object -ExpandProperty Name + } + + $script:Shipped = @(Get-ShippedSkill $script:SkillRoot) + @(Get-ShippedSkill $script:PluginSkillRoot) + + $script:SkillDocs = @( + Get-ChildItem $script:SkillRoot -Recurse -Include '*.md' -File -ErrorAction SilentlyContinue + ) + } + + It 'Finds skill documentation to check' { + $script:SkillDocs.Count | Should -BeGreaterThan 0 + $script:Shipped.Count | Should -BeGreaterThan 0 + } + + It 'Does not route to a skill directory that ships no SKILL.md' { + # Backtick-quoted kebab-case names are how the docs reference sibling skills. + $referenced = $script:SkillDocs | + Select-String -Pattern '`([a-z][a-z0-9]*(?:-[a-z0-9]+)+)`' -AllMatches | + ForEach-Object { $_.Matches } | + ForEach-Object { $_.Value.Trim('`') } | + Sort-Object -Unique + + # Only names that look like a skill folder are routing targets; the same + # pattern also matches things like file names and CLI flags. + $candidates = @($referenced | Where-Object { $_ -match '^(azure|finops|cost|unit|anomaly|forecasting|focus|sustainability|power|rate)-' }) + + $dead = @($candidates | Where-Object { $script:Shipped -notcontains $_ }) + $dead | Should -BeNullOrEmpty -Because "every routed skill must ship a SKILL.md (dead: $($dead -join ', '))" + } + + It 'Does not reference the deprecated azure-cost-management skill' { + $hits = @($script:SkillDocs | Select-String -Pattern 'azure-cost-management' -SimpleMatch) + $hits | Should -BeNullOrEmpty -Because 'AgentPlugins.Tests.ps1 asserts that skill no longer ships' + } +} diff --git a/src/powershell/Tests/Unit/AzGraphPagination.Tests.ps1 b/src/powershell/Tests/Unit/AzGraphPagination.Tests.ps1 new file mode 100644 index 000000000..416b8ce8f --- /dev/null +++ b/src/powershell/Tests/Unit/AzGraphPagination.Tests.ps1 @@ -0,0 +1,184 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Resource Graph query pagination' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' + Import-Module $script:MultitoolModule -Force + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + It 'Combines rows from every page when -All is used' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + if (-not $SkipToken) { [PSCustomObject]@{ Data = @('a', 'b'); SkipToken = 'page2'; Count = 2 } } + elseif ($SkipToken -eq 'page2') { [PSCustomObject]@{ Data = @('c'); SkipToken = $null; Count = 1 } } + } + + $result = Search-AzGraphSafe -Query 'resources' -All + + $result.Count | Should -Be 3 + @($result.Data) | Should -Be @('a', 'b', 'c') + $result.SkipToken | Should -BeNullOrEmpty + Should -Invoke Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool -Times 2 -Exactly + } + + It 'Reads only the first page when -All is not used' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + [PSCustomObject]@{ Data = @('a', 'b'); SkipToken = 'page2'; Count = 2 } + } + + $result = Search-AzGraphSafe -Query 'resources' + + @($result.Data).Count | Should -Be 2 + $result.SkipToken | Should -Be 'page2' + Should -Invoke Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool -Times 1 -Exactly + } + + It 'Rejects an incomplete first page when all rows were requested' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { $null } + + { Search-AzGraphSafe -Query 'resources' -All } | Should -Throw '*incomplete*' + } + + It 'Rejects a full page without a continuation token' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + [pscustomobject]@{ Data = @('a', 'b'); SkipToken = $null; Count = 2 } + } + + $returned = [Collections.Generic.List[object]]::new() + { Search-AzGraphSafe -Query 'resources' -First 2 -All | ForEach-Object { $returned.Add($_) } } | Should -Throw '*full page*incomplete*' + + $returned.Count | Should -Be 0 + } + + It 'Retains a resource ID for pagination' -ForEach @( + @{ Scan = 'Get-IdleVMs' } + @{ Scan = 'Get-StorageTierAdvice' } + ) { + Mock Search-AzGraphSafe -ModuleName FinOpsMultitool { + $Query | Should -Match '\|\s*project\s+id\s*,' + throw 'Projection checked before scanning.' + } + + { & $Scan -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) } | Should -Throw '*Projection checked*' + Should -Invoke Search-AzGraphSafe -ModuleName FinOpsMultitool -Times 1 -Exactly + } + + It 'Retains a resource ID in every inventory query' -ForEach @( + @{ Scan = 'Get-AHBOpportunities'; ExpectedQueries = 3 } + @{ Scan = 'Get-LegacyResources'; ExpectedQueries = 5 } + ) { + Mock Search-AzGraphSafe -ModuleName FinOpsMultitool { + $Query | Should -Match '\|\s*project\s+id\s*,' + [pscustomobject]@{ Data = @(); SkipToken = $null; Count = 0 } + } + + & $Scan -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) | Out-Null + Should -Invoke Search-AzGraphSafe -ModuleName FinOpsMultitool -Times $ExpectedQueries -Exactly + } + + It 'Accepts a measured empty page with the smallest page size' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + [pscustomobject]@{ Data = @(); SkipToken = $null; Count = 0 } + } + + $result = Search-AzGraphSafe -Query 'resources' -First 1 -All + + $result.Count | Should -Be 0 + } + + It 'Rejects a failed continuation without emitting partial rows' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + if (-not $SkipToken) { [PSCustomObject]@{ Data = @('a'); SkipToken = 'page2'; Count = 1 } } + else { $null } + } + + $returned = [Collections.Generic.List[object]]::new() + { Search-AzGraphSafe -Query 'resources' -All | ForEach-Object { $returned.Add($_) } } | Should -Throw '*incomplete*' + + $returned.Count | Should -Be 0 + } + + It 'Stops at MaxPages rather than following an endless token chain' { + # Each page hands back a token that differs from the one just used, so + # the cap - not the same-token guard - is what ends the loop. + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + $next = if ($SkipToken) { "$SkipToken+" } else { 'page+' } + [PSCustomObject]@{ Data = @('row'); SkipToken = $next; Count = 1 } + } + + $returned = [Collections.Generic.List[object]]::new() + { Search-AzGraphSafe -Query 'resources' -All -MaxPages 3 | ForEach-Object { $returned.Add($_) } } | Should -Throw '*incomplete*' + + $returned.Count | Should -Be 0 + Should -Invoke Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool -Times 3 -Exactly + } + + It 'Stops when the continuation token does not advance' { + # A token that repeats would re-request the page already collected and + # silently duplicate its rows. + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + [PSCustomObject]@{ Data = @('row'); SkipToken = 'stuck'; Count = 1 } + } + + $returned = [Collections.Generic.List[object]]::new() + { Search-AzGraphSafe -Query 'resources' -All | ForEach-Object { $returned.Add($_) } } | Should -Throw '*continuation token*incomplete*' + + $returned.Count | Should -Be 0 + Should -Invoke Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool -Times 2 -Exactly + } + + It 'Rejects a cyclic continuation chain without returning repeated pages' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + $next = if ($SkipToken -eq 'page2') { 'page3' } else { 'page2' } + [PSCustomObject]@{ Data = @('row'); SkipToken = $next; Count = 1 } + } + + { Search-AzGraphSafe -Query 'resources' -All } | Should -Throw '*continuation token*incomplete*' + + Should -Invoke Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool -Times 3 -Exactly + } + + It 'Keeps incomplete inventory visible through ' -ForEach @( + @{ Scan = 'Get-StorageTierAdvice' } + @{ Scan = 'Get-IdleVMs' } + @{ Scan = 'Get-OrphanedResources' } + @{ Scan = 'Get-LegacyResources' } + @{ Scan = 'Get-AHBOpportunities' } + @{ Scan = 'Get-UnitEconomics' } + @{ Scan = 'Get-AIWorkloadMetrics' } + ) { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + if (-not $SkipToken) { [pscustomobject]@{ Data = @('partial'); SkipToken = 'next'; Count = 1 } } + else { $null } + } + Mock Get-PlainAccessToken -ModuleName FinOpsMultitool { throw 'Credentials must not be requested after inventory fails.' } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw 'Network requests are prohibited in this test.' } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $parameters = @{ Subscriptions = $subscriptions } + if ((Get-Command $Scan).Parameters.ContainsKey('TenantId')) { $parameters.TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } + { & $Scan @parameters } | Should -Throw '*incomplete*' + + Should -Invoke Get-PlainAccessToken -ModuleName FinOpsMultitool -Times 0 -Exactly + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + } + + It 'Starts from a caller-supplied skip token' { + Mock Invoke-AzGraphQueryPage -ModuleName FinOpsMultitool { + [PSCustomObject]@{ Data = @($SkipToken); SkipToken = $null; Count = 1 } + } + + $result = Search-AzGraphSafe -Query 'resources' -All -SkipToken 'resume-here' + + @($result.Data)[0] | Should -Be 'resume-here' + } +} diff --git a/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 new file mode 100644 index 000000000..9b106d093 --- /dev/null +++ b/src/powershell/Tests/Unit/BudgetCoverage.Tests.ps1 @@ -0,0 +1,412 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Budget coverage reporting' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' + Import-Module $script:MultitoolModule -Force + + $script:SubA = '00000000-0000-0000-0000-00000000000a' + $script:SubB = '00000000-0000-0000-0000-00000000000b' + $script:TwoSubs = @( + [PSCustomObject]@{ Id = $script:SubA; Name = 'Sub A' } + [PSCustomObject]@{ Id = $script:SubB; Name = 'Sub B' } + ) + + $script:OneBudget = @{ + value = @( + @{ name = 'monthly-budget'; properties = @{ amount = 100; timeGrain = 'Monthly'; category = 'Cost' } } + ) + } | ConvertTo-Json -Depth 8 + + $script:NoBudgets = '{"value":[]}' + } + + BeforeEach { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw 'Budget tests must provide a synthetic REST response.' } + Mock Invoke-AzRestMethod -ModuleName FinOpsMultitool { throw 'Budget tests must not send Azure requests.' } + Mock Invoke-RestMethod -ModuleName FinOpsMultitool { throw 'Budget tests must not send HTTP requests.' } + Mock Invoke-WebRequest -ModuleName FinOpsMultitool { throw 'Budget tests must not send HTTP requests.' } + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + It 'Blocks an unconfigured call in the test fixture' -ForEach @( + @{ RequestCommand = 'Invoke-AzRestMethodWithRetry'; RequestParameters = @{ Path = '/subscriptions/fixture'; Method = 'GET' }; ExpectedError = '*synthetic REST response*' } + @{ RequestCommand = 'Invoke-AzRestMethod'; RequestParameters = @{ Path = '/subscriptions/fixture'; Method = 'GET' }; ExpectedError = '*must not send Azure requests*' } + @{ RequestCommand = 'Invoke-RestMethod'; RequestParameters = @{ Uri = 'https://example.invalid/fixture'; Method = 'GET' }; ExpectedError = '*must not send HTTP requests*' } + @{ RequestCommand = 'Invoke-WebRequest'; RequestParameters = @{ Uri = 'https://example.invalid/fixture'; Method = 'GET' }; ExpectedError = '*must not send HTTP requests*' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ RequestCommand = $RequestCommand; RequestParameters = $RequestParameters; ExpectedError = $ExpectedError } { + param($RequestCommand, $RequestParameters, $ExpectedError) + $fixtureCommand = $RequestCommand + $fixtureParameters = $RequestParameters + { & $fixtureCommand @fixtureParameters } | Should -Throw $ExpectedError + } + } + + It 'Does not count an unreadable subscription as having no budget' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match $script:SubA) { [PSCustomObject]@{ StatusCode = 200; Content = $script:OneBudget } } + else { [PSCustomObject]@{ StatusCode = 403; Content = '{}' } } + } + + $r = Get-BudgetStatus -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + $r.SubsWithBudget | Should -Be 1 + # The denied subscription is unknown, not budget-free. + $r.SubsWithoutBudget | Should -Be 0 + $r.UnreadableSubs | Should -Be 1 + $r.CoverageIncomplete | Should -BeTrue + $r.ScannedSubs | Should -Be 1 + $r.TotalSubs | Should -Be 2 + } + + It 'Does not count a budget response as verified coverage' -Tag 'MalformedBudgetCoverage' -ForEach @( + @{ RecordCase = 'missing record fields'; Body = '{"value":[{}]}' } + @{ RecordCase = 'null record'; Body = '{"value":[null]}' } + @{ RecordCase = 'missing properties'; Body = '{"value":[{"name":"invalid"}]}' } + @{ RecordCase = 'string properties'; Body = '{"value":[{"name":"invalid","properties":"unreadable"}]}' } + @{ RecordCase = 'array properties'; Body = '{"value":[{"name":"invalid","properties":[]}]}' } + @{ RecordCase = 'empty properties'; Body = '{"value":[{"name":"invalid","properties":{}}]}' } + @{ RecordCase = 'blank name'; Body = '{"value":[{"name":" ","properties":{"amount":100}}]}' } + @{ RecordCase = 'partial list'; Body = '{"value":[{"name":"partial","properties":{"amount":100,"timeGrain":"Monthly","category":"Cost"}},{}]}' } + ) { + $fixtureBody = $Body + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match $script:SubA) { [pscustomobject]@{ StatusCode = 200; Content = $script:OneBudget } } + else { [pscustomobject]@{ StatusCode = 200; Content = $fixtureBody } } + } + + $result = Get-BudgetStatus -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + $result.TotalBudgets | Should -Be 1 + $result.SubsWithBudget | Should -Be 1 + $result.SubsWithoutBudget | Should -Be 0 + $result.UnreadableSubs | Should -Be 1 + $result.ScannedSubs | Should -Be 1 + $result.CoverageIncomplete | Should -BeTrue + $result.BudgetCoverage | Should -BeNullOrEmpty + $result.Note | Should -Match 'could not be queried' + $result.Budgets[0].BudgetName | Should -Be 'monthly-budget' + } + + It 'Suppresses the coverage percentage when a subscription could not be read' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match $script:SubA) { [PSCustomObject]@{ StatusCode = 200; Content = $script:OneBudget } } + else { [PSCustomObject]@{ StatusCode = 500; Content = '{}' } } + } + + $r = Get-BudgetStatus -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + # 1 of 2 would read as 50% measured coverage, which was never measured. + $r.BudgetCoverage | Should -BeNullOrEmpty + $r.Note | Should -Match 'could not be queried' + } + + It 'Treats a thrown query as unreadable rather than budget-free' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match $script:SubA) { [PSCustomObject]@{ StatusCode = 200; Content = $script:OneBudget } } + else { throw 'network blew up' } + } + + $r = Get-BudgetStatus -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + $r.UnreadableSubs | Should -Be 1 + $r.SubsWithoutBudget | Should -Be 0 + $r.CoverageIncomplete | Should -BeTrue + } + + It 'Reports measured coverage when every subscription answered' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match $script:SubA) { [PSCustomObject]@{ StatusCode = 200; Content = $script:OneBudget } } + else { [PSCustomObject]@{ StatusCode = 200; Content = $script:NoBudgets } } + } + + $r = Get-BudgetStatus -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + # An empty 200 is a real answer: that subscription has no budget. + $r.SubsWithBudget | Should -Be 1 + $r.SubsWithoutBudget | Should -Be 1 + $r.UnreadableSubs | Should -Be 0 + $r.CoverageIncomplete | Should -BeFalse + $r.BudgetCoverage | Should -Be 50 + $r.Note | Should -BeNullOrEmpty + } + + Context 'Budget history month coverage' { + + BeforeAll { + $script:HistBudget = @( + [PSCustomObject]@{ + SubscriptionId = $script:SubA + Subscription = 'Sub A' + BudgetName = 'monthly-budget' + Amount = 100 + TimeGrain = 'Monthly' + Category = 'Cost' + Currency = 'USD' + Filter = $null + TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-2); endDate = (Get-Date).ToUniversalTime().Date.AddYears(2) } + } + ) + + $script:Trend2 = [PSCustomObject]@{ + BySubscription = @{ $script:SubA = @(2, 1 | ForEach-Object { + [PSCustomObject]@{ MonthDate = (Get-Date).ToUniversalTime().AddMonths(-$_); Cost = 10; Currency = 'USD' } + }) + } + } + + $script:Trend6 = [PSCustomObject]@{ + BySubscription = @{ $script:SubA = @(6, 5, 4, 3, 2, 1 | ForEach-Object { + [PSCustomObject]@{ MonthDate = (Get-Date).ToUniversalTime().AddMonths(-$_); Cost = 10; Currency = 'USD' } + }) + } + } + } + + It 'Calculates history when the budget API returns ' -ForEach @( + @{ FilterCase = 'no filter property'; FilterJson = $null } + @{ FilterCase = 'an empty filter object'; FilterJson = '{}' } + ) { + $properties = @{ + amount = 100; timeGrain = 'Monthly'; category = 'Cost' + currentSpend = @{ amount = 10; unit = 'USD' } + timePeriod = @{ startDate = '2020-01-01T00:00:00Z'; endDate = '2030-12-31T00:00:00Z' } + } + if ($null -ne $FilterJson) { $properties.filter = $FilterJson | ConvertFrom-Json } + $apiResponse = @{ value = @(@{ name = 'monthly-budget'; properties = $properties }) } | ConvertTo-Json -Depth 10 + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Method -ne 'GET' -or $Path -notlike '*Microsoft.Consumption/budgets*') { throw 'Cached unfiltered costs must not issue a cost query.' } + [pscustomobject]@{ StatusCode = 200; Content = $apiResponse } + } + + $inventory = Get-BudgetStatus -Subscriptions @($script:TwoSubs[0]) + $history = @(Get-BudgetHistory -Budgets $inventory.Budgets -MonthsBack 6 -CostTrend $script:Trend6) + + $history.Count | Should -Be 6 + foreach ($row in $history) { + $row.ActualSpend | Should -Be 10 + $row.PctUsed | Should -Be 10 + $row.Status | Should -Be 'Under' + } + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly + } + + It 'Queries live cost when cached trend is shorter than the requested window' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ StatusCode = 403; Content = '{}' } + } + + { Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 6 -CostTrend $script:Trend2 -WarningAction SilentlyContinue } | + Should -Throw '*403*incomplete*' + + # Without the coverage check the four uncovered months would be + # reported as zero spend and therefore as being under budget. + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly + } + + It 'Reuses cached trend when it covers the requested window' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ StatusCode = 403; Content = '{}' } + } + + $rows = Get-BudgetHistory -Budgets $script:HistBudget -MonthsBack 6 -CostTrend $script:Trend6 -WarningAction SilentlyContinue + + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + @($rows).Count | Should -Be 6 + } + + It 'Includes monthly spend from every page (continuation fails: , filtered: )' -ForEach @( + @{ PageFails = $false; HasFilter = $false } + @{ PageFails = $true; HasFilter = $false } + @{ PageFails = $false; HasFilter = $true } + @{ PageFails = $true; HasFilter = $true } + ) { + $budget = $script:HistBudget[0] | Select-Object * + if ($HasFilter) { $budget.Filter = @{ tags = @{ name = 'Environment'; operator = 'In'; values = @('Prod') } } } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + $isNextPage = $Path -like '*page=2' + if ($PageFails -and $isNextPage) { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $monthsAgo = if ($isNextPage) { -1 } else { -2 } + $amount = if ($isNextPage) { 120.0 } else { 40.0 } + $month = (Get-Date).ToUniversalTime().AddMonths($monthsAgo).ToString('yyyyMM01') + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'BillingMonth' }, @{ name = 'Currency' }) + rows = @(, @($amount, $month, 'USD')) + } + if (-not $isNextPage) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + + if ($PageFails) { + { Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 } | Should -Throw '*incomplete*' + } + else { + $rows = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2) + $rows.Count | Should -Be 2 + ($rows | Measure-Object -Property ActualSpend -Sum).Sum | Should -Be 160 + @($rows | Where-Object Status -EQ 'Over').Count | Should -Be 1 + } + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + $dataset = ($Payload | ConvertFrom-Json).dataset + $filterMatches = if ($HasFilter) { $dataset.filter.tags.name -eq 'Environment' -and $dataset.filter.tags.values[0] -ceq 'Prod' } + else { $null -eq $dataset.filter } + $Path -like '*page=2' -and $Method -eq 'POST' -and $dataset.granularity -eq 'Monthly' -and $filterMatches + } + } + + It 'Queries costs using the same as the budget instead of unfiltered cached totals' -ForEach @( + @{ FilterCase = 'tag filter'; FilterJson = '{"tags":{"name":"Environment","operator":"In","values":["Prod"]}}' } + @{ FilterCase = 'dimension filter'; FilterJson = '{"dimensions":{"name":"ResourceGroupName","operator":"In","values":["analytics"]}}' } + @{ FilterCase = 'combined filter'; FilterJson = '{"and":[{"tags":{"name":"Environment","operator":"In","values":["Prod"]}},{"dimensions":{"name":"ResourceGroupName","operator":"In","values":["analytics"]}}]}' } + ) { + $budget = $script:HistBudget[0] | Select-Object * + $budget.Filter = $FilterJson | ConvertFrom-Json + $capturedQueries = [Collections.Generic.List[object]]::new() + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + $capturedQueries.Add(($Payload | ConvertFrom-Json)) + $rows = @(2, 1 | ForEach-Object { , @(40.0, (Get-Date).ToUniversalTime().AddMonths(-$_).ToString('yyyyMM01'), 'USD') }) + $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'BillingMonth' }, @{ name = 'Currency' }); rows = $rows } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + + $history = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6) + + $history.Count | Should -Be 2 + foreach ($row in $history) { $row.ActualSpend | Should -Be 40; $row.Status | Should -Be 'Under' } + $capturedQueries.Count | Should -Be 1 + ($capturedQueries[0].dataset.filter | ConvertTo-Json -Depth 10 -Compress) | Should -BeExactly $FilterJson + $capturedQueries[0].type | Should -Be 'ActualCost' + $capturedQueries[0].dataset.granularity | Should -Be 'Monthly' + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + $Method -eq 'POST' -and $Path -like "/subscriptions/$($script:SubA)/providers/Microsoft.CostManagement/query*" + } + } + + It 'Caches history by exact filter without mixing distinct tag values or unfiltered totals' { + $budgets = @(foreach ($name in @('Upper', 'Lower', 'Same filter', 'Unfiltered')) { + $budget = $script:HistBudget[0] | Select-Object * + $budget.BudgetName = $name + if ($name -ne 'Unfiltered') { + $budget.Filter = @{ tags = @{ name = 'Environment'; operator = 'In'; values = @($(if ($name -eq 'Lower') { 'prod' } else { 'Prod' })) } } + } + $budget + }) + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + $filter = ($Payload | ConvertFrom-Json).dataset.filter + $amount = if ($filter.tags.values[0] -ceq 'Prod') { 40.0 } else { 60.0 } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'BillingMonth' }, @{ name = 'Currency' }) + rows = @(, @($amount, (Get-Date).ToUniversalTime().AddMonths(-1).ToString('yyyyMM01'), 'USD')) + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + + $history = @(Get-BudgetHistory -Budgets $budgets -MonthsBack 1 -CostTrend $script:Trend6) + + ($history | Where-Object BudgetName -EQ 'Upper').ActualSpend | Should -Be 40 + ($history | Where-Object BudgetName -EQ 'Lower').ActualSpend | Should -Be 60 + ($history | Where-Object BudgetName -EQ 'Same filter').ActualSpend | Should -Be 40 + ($history | Where-Object BudgetName -EQ 'Unfiltered').ActualSpend | Should -Be 10 + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 2 -Exactly + } + + It 'Leaves a unavailable without issuing an unfiltered query' -ForEach @( + @{ FilterCase = 'malformed tag comparison'; FilterJson = '{"tags":{"name":"CostCenter","operator":"In","values":"team"}}' } + @{ FilterCase = 'missing comparison values'; FilterJson = '{"tags":{"name":"CostCenter","operator":"In","values":[]}}' } + @{ FilterCase = 'empty AND'; FilterJson = '{"and":[]}' } + @{ FilterCase = 'empty AND child'; FilterJson = '{"and":[{},{}]}' } + @{ FilterCase = 'unsupported expression'; FilterJson = '{"or":[{"tags":{"name":"CostCenter","operator":"In","values":["team"]}},{"tags":{"name":"CostCenter","operator":"In","values":["other"]}}]}' } + @{ FilterCase = 'unknown filter field'; FilterJson = '{"dimensions":{"name":"ResourceGroupName","operator":"In","values":["analytics"]},"unknown":true}' } + ) { + $budget = $script:HistBudget[0] | Select-Object * + $budget.Filter = $FilterJson | ConvertFrom-Json + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw 'An unrecognized filter must not broaden the request.' } + + $history = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6) + + $history.Count | Should -Be 2 + foreach ($row in $history) { + $row.ActualSpend | Should -BeNullOrEmpty + $row.Status | Should -Be 'Unavailable' + $row.Note | Should -Match 'cannot apply this filter' + } + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + } + + It 'Does not replace a failed filtered query with cached subscription spend' { + $budget = $script:HistBudget[0] | Select-Object * + $budget.Filter = @{ dimensions = @{ name = 'ResourceGroupName'; operator = 'In'; values = @('analytics') } } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { [pscustomobject]@{ StatusCode = 403; Content = '{}' } } + + { Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6 } | Should -Throw '*403*incomplete*' + + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + ($Payload | ConvertFrom-Json).dataset.filter.dimensions.name -eq 'ResourceGroupName' + } + } + + It 'Does not compare subscription history with a budget' -ForEach @( + @{ Case = 'unsupported filter'; Change = 'Filter' } + @{ Case = 'quarterly'; Change = 'Quarter' } + @{ Case = 'usage'; Change = 'Usage' } + @{ Case = 'missing amount'; Change = 'Amount' } + @{ Case = 'unknown currency'; Change = 'Currency' } + @{ Case = 'unknown validity period'; Change = 'Period' } + ) { + $budget = $script:HistBudget[0] | Select-Object * + switch ($Change) { + 'Filter' { $budget.Filter = @{ tags = @{ name = 'CostCenter'; operator = 'NotIn'; values = @('team') } } } + 'Quarter' { $budget.TimeGrain = 'Quarterly' } + 'Usage' { $budget.Category = 'Usage' } + 'Amount' { $budget.Amount = $null } + 'Currency' { $budget.Currency = $null } + 'Period' { $budget.TimePeriod = $null } + } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw 'Unsupported budgets must not query unfiltered history.' } + + $rows = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6) + + $rows.Count | Should -Be 2 + foreach ($row in $rows) { + $row.ActualSpend | Should -BeNullOrEmpty + $row.PctUsed | Should -BeNullOrEmpty + $row.Status | Should -Be 'Unavailable' + $row.Note | Should -Not -BeNullOrEmpty + } + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + } + + It 'Does not present spend before the budget start as being under budget' { + $budget = $script:HistBudget[0] | Select-Object * + $budget.TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddDays(1 - (Get-Date).ToUniversalTime().Day) } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw 'No active historical months.' } + + $rows = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6) + + @($rows | Where-Object Status -EQ 'Unavailable').Count | Should -Be 2 + $rows[0].ActualSpend | Should -BeNullOrEmpty + } + + It 'Rejects a cached currency mismatch instead of relabeling it' { + $budget = $script:HistBudget[0] | Select-Object * + $budget.Currency = 'EUR' + + $rows = @(Get-BudgetHistory -Budgets @($budget) -MonthsBack 2 -CostTrend $script:Trend6) + + @($rows | Where-Object Status -EQ 'Unavailable').Count | Should -Be 2 + $rows[0].Note | Should -Match 'currenc' + $rows[0].ActualSpend | Should -BeNullOrEmpty + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + } + } +} diff --git a/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 b/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 new file mode 100644 index 000000000..ab0e7d5da --- /dev/null +++ b/src/powershell/Tests/Unit/CommitmentUtilizationDedupe.Tests.ps1 @@ -0,0 +1,279 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Commitment utilization de-duplication' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' + Import-Module $script:MultitoolModule -Force + + $script:TwoSubs = @( + [PSCustomObject]@{ Id = '00000000-0000-0000-0000-000000000001'; Name = 'Sub one' } + [PSCustomObject]@{ Id = '00000000-0000-0000-0000-000000000002'; Name = 'Sub two' } + ) + + # Both APIs are billing-scoped, so the scan first resolves the billing + # account that owns the scanned subscriptions. + $script:BillingAccountPayload = @{ + value = @( + @{ id = '/providers/Microsoft.Billing/billingAccounts/TEST-BA' + name = 'TEST-BA' + properties = @{ agreementType = 'EnterpriseAgreement' } + } + ) + } | ConvertTo-Json -Depth 8 + + $script:BillingPropertyPayload = @{ + properties = @{ billingAccountId = '/providers/Microsoft.Billing/billingAccounts/TEST-BA' } + } | ConvertTo-Json -Depth 8 + + # One reservation reported across two usage periods. + $script:ReservationPayload = @{ + value = @( + @{ properties = @{ reservationOrderId = 'order-1'; reservationId = 'res-1'; skuName = 'Standard_D2s_v5'; kind = 'Compute' + avgUtilizationPercentage = 50; minUtilizationPercentage = 40; maxUtilizationPercentage = 60 + reservedHours = 100; usedHours = 50; usageDate = '2026-08-01T00:00:00Z' + } + } + @{ properties = @{ reservationOrderId = 'order-1'; reservationId = 'res-1'; skuName = 'Standard_D2s_v5'; kind = 'Compute' + avgUtilizationPercentage = 90; minUtilizationPercentage = 80; maxUtilizationPercentage = 95 + reservedHours = 100; usedHours = 90; usageDate = '2026-09-01T00:00:00Z' + } + } + ) + } | ConvertTo-Json -Depth 8 + + # Two DIFFERENT savings plans that share one benefit order. + $script:SavingsPlanPayload = @{ + value = @( + @{ properties = @{ benefitType = 'SavingsPlan'; benefitId = 'plan-a'; benefitOrderId = 'order-1' + avgUtilizationPercentage = 70; usageDate = '2026-09-01T00:00:00Z' + } + } + @{ properties = @{ benefitType = 'SavingsPlan'; benefitId = 'plan-b'; benefitOrderId = 'order-1' + avgUtilizationPercentage = 30; usageDate = '2026-09-01T00:00:00Z' + } + } + ) + } | ConvertTo-Json -Depth 8 + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + It 'Counts one reservation when it is reported for several months' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match 'billingAccounts\?') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path -match 'reservationSummaries') { [PSCustomObject]@{ StatusCode = 200; Content = $script:ReservationPayload } } + else { [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } } + } + + $result = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + # Two monthly records describe a single commitment. + $result.RICount | Should -Be 1 + # The newest period wins, so the average is not dragged down by August. + $result.RIAvgUtilization | Should -Be 90 + } + + It 'Queries billing scope rather than subscription scope' { + # Subscription-scoped paths answer 404, so a regression back to them + # would silently report zero commitments. + $script:SeenPaths = [System.Collections.Generic.List[string]]::new() + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match 'billingAccounts\?') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path -match 'reservationSummaries') { + $script:SeenPaths.Add($Path) + [PSCustomObject]@{ StatusCode = 200; Content = $script:ReservationPayload } + } + else { [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } } + } + + $null = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + @($script:SeenPaths).Count | Should -BeGreaterThan 0 + foreach ($p in $script:SeenPaths) { + $p | Should -BeLike '/providers/Microsoft.Billing/billingAccounts/*' + $p | Should -Not -BeLike '/subscriptions/*' + # UsageDate is an Edm.DateTimeOffset; a quoted bound fails the compare. + $p | Should -Not -Match "UsageDate ge '" + } + } + + It 'Keeps distinct savings plans that share one benefit order' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match 'billingAccounts\?') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path -match 'benefitUtilizationSummaries') { [PSCustomObject]@{ StatusCode = 200; Content = $script:SavingsPlanPayload } } + else { [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } } + } + + $result = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + # De-duplicating on benefitOrderId alone would collapse these to 1. + $result.SPCount | Should -Be 2 + $result.SPAvgUtilization | Should -Be 50 + } + + It 'Does not return incomplete utilization after pagination fails' -ForEach @( + @{ Endpoint = 'reservationSummaries'; PayloadName = 'ReservationPayload' } + @{ Endpoint = 'benefitUtilizationSummaries'; PayloadName = 'SavingsPlanPayload' } + ) { + $pagedPayload = (Get-Variable -Name $PayloadName -Scope Script -ValueOnly) | ConvertFrom-Json + $pagedPayload | Add-Member -NotePropertyName nextLink -NotePropertyValue "/providers/Microsoft.Billing/billingAccounts/TEST-BA/$Endpoint`?page=2" + $firstPageContent = $pagedPayload | ConvertTo-Json -Depth 10 + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -like '*page=2') { [PSCustomObject]@{ StatusCode = 503; Content = '{}' } } + elseif ($Path -match 'billingAccounts\?') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [PSCustomObject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path.Contains($Endpoint)) { [PSCustomObject]@{ StatusCode = 200; Content = $firstPageContent } } + else { [PSCustomObject]@{ StatusCode = 200; Content = '{"value":[]}' } } + } + + { Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue } | Should -Throw '*incomplete*' + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'GET' -and [string]::IsNullOrEmpty($Payload) + } + } + + Context 'Commitment metadata and availability' { + It 'Reads fallback pages without treating partial utilization as complete (page failure: )' -Tag 'CommitmentMetadata' -ForEach @( + @{ PageFails = $false } + @{ PageFails = $true } + ) { + $failPage = $PageFails + $reservationPath = '/providers/Microsoft.Capacity/reservationOrders/order-1/reservations/res-1' + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match 'billingAccounts\?') { [pscustomobject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [pscustomobject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path -match '^/providers/Microsoft\.Capacity/reservationOrders\?') { + if ($Path -like '*page=2') { + [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @(@{ name = 'order-1'; properties = @{ displayProvisioningState = 'Succeeded'; billingScopeId = '/subscriptions/not-a-kind'; reservations = @(@{ id = $reservationPath }, @{ id = $reservationPath }) } }) } | ConvertTo-Json -Depth 8) } + } + else { [pscustomobject]@{ StatusCode = 200; Content = '{"value":[],"nextLink":"/providers/Microsoft.Capacity/reservationOrders?api-version=2022-11-01&page=2"}' } } + } + elseif ($Path -like "$reservationPath/providers/*") { + if ($Path -like '*page=2' -and $failPage) { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $older = $Path -like '*page=2' + $content = @{ value = @(@{ properties = @{ avgUtilizationPercentage = $(if ($older) { 20 } else { 90 }); usageDate = $(if ($older) { '2026-08-01' } else { '2026-09-01' }) } }) } + if (-not $older) { $content.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = ($content | ConvertTo-Json -Depth 8) } + } + elseif ($Path -like "$reservationPath`?*") { + [pscustomobject]@{ StatusCode = 200; Content = (@{ id = $reservationPath; sku = @{ name = 'Standard_D2s_v5' }; properties = @{ displayName = 'Example reservation'; reservedResourceType = 'VirtualMachines' } } | ConvertTo-Json -Depth 6) } + } + else { [pscustomobject]@{ StatusCode = 200; Content = '{"value":[]}' } } + } + + $result = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + $result.CoverageIncomplete | Should -BeTrue + $result.RIAvgUtilization | Should -BeNullOrEmpty + if ($PageFails) { $result.RICount | Should -Be 0; $result.UtilizationFailures | Should -Be 1 } + else { + $result.RICount | Should -Be 1 + $result.UnscopedFallback | Should -BeTrue + $result.Reservations[0].AvgUtilization | Should -Be 90 + $result.Reservations[0].SkuName | Should -Be 'Standard_D2s_v5' + $result.Reservations[0].Kind | Should -Be 'VirtualMachines' + } + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { $Path -like '*reservationSummaries*page=2' } + } + + It 'Retains utilization with reservation metadata' -Tag 'CommitmentMetadata' -ForEach @( + @{ MetadataState = 'verified'; StatusCode = 200; Matches = $true } + @{ MetadataState = 'denied'; StatusCode = 403; Matches = $true } + @{ MetadataState = 'mismatched'; StatusCode = 200; Matches = $false } + ) { + $metadataStatus = $StatusCode + $matchingMetadata = $Matches + $reservationPath = '/providers/Microsoft.Capacity/reservationOrders/order-1/reservations/res-1' + $summary = @{ value = @(@{ properties = @{ reservationOrderId = 'order-1'; reservationId = 'res-1'; avgUtilizationPercentage = 90; usageDate = '2026-09-01T00:00:00Z' } }) } | ConvertTo-Json -Depth 8 + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match 'billingAccounts\?') { [pscustomobject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [pscustomobject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path -match 'reservationSummaries') { [pscustomobject]@{ StatusCode = 200; Content = $summary } } + elseif ($Path -like "$reservationPath`?*") { + [pscustomobject]@{ StatusCode = $metadataStatus; Content = (@{ + id = $(if ($matchingMetadata) { $reservationPath } else { "$reservationPath-other" }); sku = @{ name = 'Standard_D2s_v5' } + properties = @{ displayName = 'Example '; reservedResourceType = 'VirtualMachines' } + } | ConvertTo-Json -Depth 6) } + } + else { [pscustomobject]@{ StatusCode = 200; Content = '{"value":[]}' } } + } + + $result = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + $result.RICount | Should -Be 1 + $result.RIAvgUtilization | Should -Be 90 + $result.SPAvgUtilization | Should -BeNullOrEmpty + $result.Reservations[0].ResourceId | Should -Be $reservationPath + $result.Reservations[0].MinUtilization | Should -BeNullOrEmpty + if ($MetadataState -eq 'verified') { + $result.Reservations[0].Name | Should -Be 'Example ' + $result.Reservations[0].SkuName | Should -Be 'Standard_D2s_v5' + $result.Reservations[0].Kind | Should -Be 'VirtualMachines' + $result.MetadataErrors | Should -BeNullOrEmpty + } + else { + $result.Reservations[0].Name | Should -Be 'res-1' + $result.Reservations[0].SkuName | Should -BeNullOrEmpty + $result.Reservations[0].Kind | Should -BeNullOrEmpty + @($result.MetadataErrors).Count | Should -Be 1 + } + } + + It 'Does not invent utilization for ' -Tag 'CommitmentMetadata' -ForEach @( + @{ Scenario = 'no commitments'; ReturnReservation = $false; Utilization = $null } + @{ Scenario = 'missing utilization'; ReturnReservation = $true; Utilization = $null } + @{ Scenario = 'measured zero'; ReturnReservation = $true; Utilization = 0 } + ) { + $includeReservation = $ReturnReservation + $utilizationValue = $Utilization + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -match 'billingAccounts\?') { [pscustomobject]@{ StatusCode = 200; Content = $script:BillingAccountPayload } } + elseif ($Path -match 'billingProperty/default') { [pscustomobject]@{ StatusCode = 200; Content = $script:BillingPropertyPayload } } + elseif ($Path -match 'reservationSummaries' -and $includeReservation) { + [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @(@{ properties = @{ reservationOrderId = 'order-1'; reservationId = 'res-1'; skuName = 'Standard_D2s_v5'; kind = 'Compute'; avgUtilizationPercentage = $utilizationValue; usageDate = '2026-09-01' } }) } | ConvertTo-Json -Depth 8) } + } + else { [pscustomobject]@{ StatusCode = 200; Content = '{"value":[]}' } } + } + + $result = Get-CommitmentUtilization -Subscriptions $script:TwoSubs -WarningAction SilentlyContinue + + $result.SPAvgUtilization | Should -BeNullOrEmpty + if ($null -eq $Utilization) { + $result.RIAvgUtilization | Should -BeNullOrEmpty + $result.UnderutilizedRIs | Should -BeNullOrEmpty + if ($ReturnReservation) { $result.CoverageIncomplete | Should -BeTrue; $result.Reservations[0].AvgUtilization | Should -BeNullOrEmpty } + } + else { $result.RIAvgUtilization | Should -Be 0; $result.UnderutilizedRIs.Count | Should -Be 1 } + } + } + + Context 'Usage date comparison' { + + It 'Treats a newer ISO date as newer' { + Test-UsageDateIsNewer -Candidate '2026-09-01T00:00:00Z' -Existing '2026-08-01T00:00:00Z' | Should -BeTrue + } + + It 'Treats an older ISO date as not newer' { + Test-UsageDateIsNewer -Candidate '2026-07-01T00:00:00Z' -Existing '2026-08-01T00:00:00Z' | Should -BeFalse + } + + It 'Accepts any candidate when nothing was recorded yet' { + Test-UsageDateIsNewer -Candidate '2026-07-01T00:00:00Z' -Existing $null | Should -BeTrue + } + + It 'Keeps the recorded row when the candidate has no date' { + Test-UsageDateIsNewer -Candidate $null -Existing '2026-08-01T00:00:00Z' | Should -BeFalse + } + } +} diff --git a/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 new file mode 100644 index 000000000..2a804a27b --- /dev/null +++ b/src/powershell/Tests/Unit/CostQueryPagination.Tests.ps1 @@ -0,0 +1,1838 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Cost Management query pagination' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' + Import-Module $script:MultitoolModule -Force + + function Get-FakeResponse { + param([int]$StatusCode = 200, [string]$NextLink, [int]$RowCount = 1) + $rows = @(1..$RowCount | ForEach-Object { , @("/subscriptions/x/r$_", 1.0, 'USD') }) + $payload = @{ + properties = @{ + columns = @(@{ name = 'ResourceId' }, @{ name = 'Cost' }, @{ name = 'Currency' }) + rows = $rows + } + } + if ($NextLink) { $payload.properties.nextLink = $NextLink } + [PSCustomObject]@{ StatusCode = $StatusCode; Content = ($payload | ConvertTo-Json -Depth 6) } + } + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + It 'Returns the single page when there is no nextLink' { + $pages = @(Get-CostQueryResponsePage -FirstResponse (Get-FakeResponse)) + $pages.Count | Should -Be 1 + } + + It 'Rejects a failed first response' { + { Get-CostQueryResponsePage -FirstResponse (Get-FakeResponse -StatusCode 403) } | + Should -Throw '*page 1 failed (403)*incomplete*' + } + + It 'Rejects missing response content' -ForEach @( + @{ Content = $null } + @{ Content = '' } + ) { + { Get-CostQueryResponsePage -FirstResponse ([PSCustomObject]@{ StatusCode = 200; Content = $Content }) } | + Should -Throw '*no content*incomplete*' + } + + It 'Rejects a null response' { + { Get-CostQueryResponsePage -FirstResponse $null } | Should -Throw '*no response*incomplete*' + } + + It 'Rejects a payload that is not valid JSON' { + $bad = [PSCustomObject]@{ StatusCode = 200; Content = 'not json at all' } + { Get-CostQueryResponsePage -FirstResponse $bad } | Should -Throw '*invalid JSON*incomplete*' + } + + It 'Preserves a successful empty result' { + $empty = [PSCustomObject]@{ StatusCode = 200; Content = '{"properties":{"columns":[],"rows":[],"nextLink":null}}' } + $pages = @(Get-CostQueryResponsePage -FirstResponse $empty) + $pages.Count | Should -Be 1 + } + + It 'Preserves the row payload so callers can parse it' { + $pages = @(Get-CostQueryResponsePage -FirstResponse (Get-FakeResponse -RowCount 3)) + $parsed = $pages[0].Content | ConvertFrom-Json + @($parsed.properties.rows).Count | Should -Be 3 + } + + It 'Rejects a failed continuation without returning partial pages' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ StatusCode = 503; Content = '{}' } + } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + $returnedPages = [System.Collections.Generic.List[object]]::new() + + { + Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{"type":"ActualCost"}' | + ForEach-Object { [void]$returnedPages.Add($_) } + } | Should -Throw '*incomplete*' + + $returnedPages.Count | Should -Be 0 + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly + } + + It 'Replays the original POST payload and returns both pages exactly once' { + $nextPage = Get-FakeResponse -RowCount 2 + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { $nextPage } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' -RowCount 3 + $payload = '{"type":"ActualCost","timeframe":"MonthToDate"}' + + $pages = @(Get-CostQueryResponsePage -FirstResponse $firstPage -Payload $payload) + + $pages.Count | Should -Be 2 + @((($pages[0].Content | ConvertFrom-Json).properties.rows)).Count | Should -Be 3 + @((($pages[1].Content | ConvertFrom-Json).properties.rows)).Count | Should -Be 2 + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + $Path -eq '/subscriptions/x/q?page=2' -and $Method -eq 'POST' -and + $Payload -eq '{"type":"ActualCost","timeframe":"MonthToDate"}' + } + } + + It 'Combines complete pages into the parsed query result shape without flattening rows' { + $nextPage = Get-FakeResponse -RowCount 2 + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { $nextPage } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' -RowCount 3 + + $result = Get-CostQueryResult -FirstResponse $firstPage -Payload '{}' + + $result.properties.rows.Count | Should -Be 5 + $result.properties.rows[0].Count | Should -Be 3 + $result.properties.columns.name | Should -Be @('ResourceId', 'Cost', 'Currency') + $result.properties.nextLink | Should -BeNullOrEmpty + } + + It 'Rejects an invalid continuation payload ()' -ForEach @( + @{ Case = 'empty'; Content = '' } + @{ Case = 'invalid JSON'; Content = 'not json' } + @{ Case = 'missing query rows'; Content = '{"properties":{"columns":[]}}' } + @{ Case = 'missing query columns'; Content = '{"properties":{"rows":[]}}' } + @{ Case = 'truncated row'; Content = '{"properties":{"columns":[{"name":"ResourceId"},{"name":"Cost"},{"name":"Currency"}],"rows":[["resource",10]]}}' } + ) { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ StatusCode = 200; Content = $Content } + } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + { Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' } | Should -Throw '*incomplete*' + } + + It 'Rejects an unreadable cost before returning any pages ()' -ForEach @( + @{ Case = 'null'; Amount = $null } + @{ Case = 'blank'; Amount = '' } + @{ Case = 'invalid'; Amount = 'not-a-number' } + @{ Case = 'NaN'; Amount = 'NaN' } + @{ Case = 'infinity'; Amount = 'Infinity' } + @{ Case = 'overflow'; Amount = '1e999' } + ) { + $properties = @{ + columns = @(@{ name = 'ResourceId' }, @{ name = 'Cost' }, @{ name = 'Currency' }) + rows = @(, @('/subscriptions/x/r2', $Amount, 'USD')) + } + $badPage = [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { $badPage } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + $returnedPages = [System.Collections.Generic.List[object]]::new() + + { + Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' | + ForEach-Object { [void]$returnedPages.Add($_) } + } | Should -Throw '*cost*incomplete*' + $returnedPages.Count | Should -Be 0 + } + + It 'Preserves genuine zero and negative costs' -ForEach @( + @{ Amount = 0.0 } + @{ Amount = -12.5 } + ) { + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'Currency' }) + rows = @(, @($Amount, 'USD')) + } + $firstPage = [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + + $result = Get-CostQueryResult -FirstResponse $firstPage -Payload '{}' + + $result.properties.rows[0][0] | Should -Be $Amount + } + + It 'Rejects changed column order across pages' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ + StatusCode = 200 + Content = '{"properties":{"columns":[{"name":"Cost"},{"name":"ResourceId"},{"name":"Currency"}],"rows":[[20,"resource","USD"]]}}' + } + } + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + { Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' } | Should -Throw '*columns changed*' + } + + It 'Rejects a repeated continuation link' { + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { $firstPage } + { Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' } | Should -Throw '*link repeated*' + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly + } + + It 'Rejects a truncated chain at the page limit' { + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + { Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' -MaxPages 1 } | Should -Throw '*stopped after 1 pages*' + } + + It 'Rejects an unexpected continuation URL without making a request' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { throw 'Must not send this request' } + $firstPage = Get-FakeResponse -NextLink 'https://example.com/page2' + { Get-CostQueryResponsePage -FirstResponse $firstPage -Payload '{}' } | Should -Throw '*unexpected nextLink*' + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 0 -Exactly + } + + It 'Requires the original payload before following a query continuation' { + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + { Get-CostQueryResponsePage -FirstResponse $firstPage } | Should -Throw '*original POST payload is required*' + } + + Context 'Actual and forecast totals' { + It 'Keeps missing actuals unavailable and preserves forecast currency ()' -ForEach @( + @{ QueryPath = 'PerSubscription' } + @{ QueryPath = 'ManagementGroup' } + @{ QueryPath = 'ManagementGroupFallback' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath } { + param($QueryPath) + $fixturePath = $QueryPath + Mock Resolve-CostMgId { 'test-management-group' } + Mock Invoke-AzRestMethodWithRetry { + $isForecast = $Path -like '*forecast*' + if ($fixturePath -eq 'ManagementGroupFallback' -and $isForecast -and $Path -like '/providers/Microsoft.Management/*') { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $properties = @{ + columns = @(@{ name = 'Currency' }, @{ name = 'SubscriptionId' }, @{ name = 'Cost' }) + rows = @() + } + if ($isForecast) { $properties.rows = @(, @('EUR', '11111111-1111-1111-1111-111111111111', 375.0)) } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Forecast only' }) + + $result = if ($fixturePath -eq 'PerSubscription') { Get-CostDataPerSubscription -Subscriptions $subscriptions } + else { Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + + $entry = $result[$subscriptions[0].Id] + $entry.Actual | Should -BeNullOrEmpty + $entry.Forecast | Should -Be 375 + $entry.Currency | Should -Be 'EUR' + $entry.ForecastSource | Should -Be 'Forecast' + } + } + + It 'Preserves measured actuals and rejects a different forecast currency ()' -ForEach @( + @{ QueryPath = 'PerSubscription'; Amount = 0.0 } + @{ QueryPath = 'ManagementGroup'; Amount = 0.0 } + @{ QueryPath = 'PerSubscription'; Amount = -5.25 } + @{ QueryPath = 'ManagementGroup'; Amount = -5.25 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath; Amount = $Amount } { + param($QueryPath, $Amount) + $fixturePath = $QueryPath + $fixtureAmount = $Amount + $forecastUnit = 'EUR' + Mock Resolve-CostMgId { 'test-management-group' } + Mock Invoke-AzRestMethodWithRetry { + $isForecast = $Path -like '*forecast*' + $unit = if ($isForecast) { $forecastUnit } else { 'EUR' } + $value = if ($isForecast) { 375.0 } else { $fixtureAmount } + $properties = @{ + columns = @(@{ name = 'Currency' }, @{ name = 'SubscriptionId' }, @{ name = 'Cost' }) + rows = @(, @($unit, '11111111-1111-1111-1111-111111111111', $value)) + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result = if ($fixturePath -eq 'PerSubscription') { Get-CostDataPerSubscription -Subscriptions $subscriptions } + else { Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + + $result[$subscriptions[0].Id].Actual | Should -Be $fixtureAmount + $result[$subscriptions[0].Id].Currency | Should -Be 'EUR' + $result[$subscriptions[0].Id].ActualPeriod | Should -Be 'Month to date (UTC query window)' + $forecastUnit = 'USD' + { + if ($fixturePath -eq 'PerSubscription') { Get-CostDataPerSubscription -Subscriptions $subscriptions } + else { Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + } | Should -Throw '*currency*' + } + } + + It 'Retains selected subscriptions absent from both management-group result sets as unavailable' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { 'test-management-group' } + Mock Invoke-AzRestMethodWithRetry { + [pscustomobject]@{ StatusCode = 200; Content = '{"properties":{"columns":[{"name":"Currency"},{"name":"SubscriptionId"},{"name":"Cost"}],"rows":[["EUR","11111111-1111-1111-1111-111111111111",100]]}}' } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Present' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Absent' } + ) + + $result = Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + + $result.Count | Should -Be 2 + $result[$subscriptions[1].Id].Actual | Should -BeNullOrEmpty + $result[$subscriptions[1].Id].Forecast | Should -BeNullOrEmpty + $result[$subscriptions[1].Id].Currency | Should -BeNullOrEmpty + $result[$subscriptions[1].Id].ForecastSource | Should -Be 'Unavailable' + } + } + + It 'Sums complete pages once (, empty first page: )' -ForEach @( + @{ QueryPath = 'PerSubscription'; EmptyFirstPage = $false } + @{ QueryPath = 'ManagementGroup'; EmptyFirstPage = $false } + @{ QueryPath = 'ManagementGroupFallback'; EmptyFirstPage = $false } + @{ QueryPath = 'ManagementGroupContinuationFallback'; EmptyFirstPage = $false } + @{ QueryPath = 'PerSubscription'; EmptyFirstPage = $true } + @{ QueryPath = 'ManagementGroup'; EmptyFirstPage = $true } + @{ QueryPath = 'ManagementGroupFallback'; EmptyFirstPage = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath; EmptyFirstPage = $EmptyFirstPage } { + param($QueryPath, $EmptyFirstPage) + + Mock Resolve-CostMgId { 'test-management-group' } + Mock Invoke-AzRestMethodWithRetry { + $isForecast = $Path -like '*forecast*' + $isNextPage = $Path -like '*page=2' + $failManagementGroup = $QueryPath -eq 'ManagementGroupFallback' -or ($QueryPath -eq 'ManagementGroupContinuationFallback' -and $isNextPage) + if ($failManagementGroup -and $isForecast -and $Path -like '/providers/Microsoft.Management/*') { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $amount = if ($isForecast) { if ($isNextPage) { 100.0 } else { 250.0 } } else { if ($isNextPage) { 25.0 } else { 100.0 } } + $body = @{ + properties = @{ + columns = @(@{ name = 'Currency' }, @{ name = 'SubscriptionId' }, @{ name = 'Cost' }) + rows = @(, @('USD', '11111111-1111-1111-1111-111111111111', $amount)) + } + } + if (-not $isNextPage) { + $body.properties.nextLink = "$Path&page=2" + if ($EmptyFirstPage) { $body.properties.rows = @() } + } + [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + + $result = if ($QueryPath -eq 'PerSubscription') { + Get-CostDataPerSubscription -Subscriptions $subscriptions + } + else { + Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + } + + $entry = $result[$subscriptions[0].Id] + $entry.Actual | Should -Be $(if ($EmptyFirstPage) { 25 } else { 125 }) + $entry.Forecast | Should -Be $(if ($EmptyFirstPage) { 100 } else { 350 }) + $entry.ForecastSource | Should -Be 'Forecast' + $continuationCalls = if ($QueryPath -eq 'ManagementGroupContinuationFallback') { 3 } else { 2 } + Should -Invoke Invoke-AzRestMethodWithRetry -Times $continuationCalls -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and + ($Payload | ConvertFrom-Json).dataset.aggregation.totalCost.function -eq 'Sum' + } + } + } + + It 'Returns no cost map when forecast pagination cannot be completed (, fallback unavailable: )' -ForEach @( + @{ QueryPath = 'PerSubscription'; FallbackUnavailable = $false } + @{ QueryPath = 'ManagementGroup'; FallbackUnavailable = $false } + @{ QueryPath = 'ManagementGroup'; FallbackUnavailable = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath; FallbackUnavailable = $FallbackUnavailable } { + param($QueryPath, $FallbackUnavailable) + + $usePerSubscription = $QueryPath -eq 'PerSubscription' + $failSubscriptionRetry = $FallbackUnavailable + Mock Resolve-CostMgId { 'test-management-group' } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + if ($failSubscriptionRetry -and $Path -like '/subscriptions/*/forecast*') { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $isForecast = $Path -like '*forecast*' + $amount = if ($isForecast) { 250.0 } else { 100.0 } + $body = @{ + properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'SubscriptionId' }, @{ name = 'Currency' }) + rows = @(, @($amount, '11111111-1111-1111-1111-111111111111', 'USD')) + } + } + if ($isForecast) { $body.properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + + $returnedResults = [System.Collections.Generic.List[object]]::new() + { + $result = if ($usePerSubscription) { + Get-CostDataPerSubscription -Subscriptions $subscriptions + } + else { + Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions -WarningAction SilentlyContinue + } + [void]$returnedResults.Add($result) + } | Should -Throw '*incomplete*' + $returnedResults.Count | Should -Be 0 + } + } + + It 'Does not return partial actual cost after a continuation fails' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + [pscustomobject]@{ + StatusCode = 200 + Content = '{"properties":{"columns":[{"name":"Cost"},{"name":"Currency"}],"rows":[[100,"USD"]],"nextLink":"/subscriptions/x/query?page=2"}}' + } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + { Get-CostDataPerSubscription -Subscriptions $subscriptions } | Should -Throw '*incomplete*' + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly -ParameterFilter { $Path -like '*forecast*' } + } + } + } + + Context 'Resource costs' { + It 'Preserves explicit UTC periods and honest resource identities for ' -Tag 'ResourceCostMetadata' -ForEach @( + @{ QueryPath = 'ManagementGroup' } + @{ QueryPath = 'PerSubscription' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath } { + param($QueryPath) + $fixturePath = $QueryPath + $capturedUtc = [datetime]::new(2026, 10, 1, 0, 30, 0, [DateTimeKind]::Utc) + $subId = '11111111-1111-1111-1111-111111111111' + $vmId = "/subscriptions/$subId/resourceGroups/fixture/providers/Microsoft.Compute/virtualMachines/fixture-vm" + $databaseId = "/subscriptions/$subId/resourceGroups/fixture/providers/Microsoft.Sql/servers/fixture-server/databases/fixture-db" + $reservationId = '/providers/Microsoft.Capacity/reservationOrders/fixture-order/reservations/fixture-reservation' + $reservationGroupId = '/providers/Microsoft.Capacity/reservationOrders/fixture-order/reservations/' + $queryBodies = [Collections.Generic.List[object]]::new() + Mock Get-Date { $capturedUtc.ToLocalTime() } + Mock Resolve-CostMgId { if ($fixturePath -eq 'ManagementGroup') { 'fixture-mg' } } + Mock Test-CostMgCoverage { $true } + Mock Write-Host { } + Mock Get-AzContext { throw 'Resource metadata tests must not read Azure context.' } + Mock Invoke-RestMethod { throw 'Resource metadata tests must not send HTTP requests.' } + Mock Invoke-AzRestMethodWithRetry { + if ($Method -ne 'POST' -or $Path -notlike '*Microsoft.CostManagement/query*') { throw 'Unexpected resource-cost request.' } + [void]$queryBodies.Add(($Payload | ConvertFrom-Json)) + $rows = @( + if ($Path -like '*page=2') { + , @(20.0, $databaseId, 'fixture', 'USD') + , @(30.0, $reservationId, '', 'USD') + , @(40.0, $reservationGroupId, '', 'USD') + , @(50.0, '', '', 'USD') + } + else { , @(10.0, $vmId, 'fixture', 'USD') } + ) + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }) + rows = $rows + } + if ($Path -notlike '*page=2') { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @([pscustomobject]@{ Id = $subId; Name = 'Example subscription'; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' }) + $costData = @{ $subId = @{ Actual = 150; Forecast = 150; ForecastSource = 'Forecast'; Currency = 'USD' } } + + $rows = @(Get-ResourceCosts -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -CostData $costData -RestrictToSelected) + + $rows.Count | Should -Be 5 + ($rows | Measure-Object Actual -Sum).Sum | Should -Be 150 + foreach ($row in $rows) { + $row.ActualPeriodStart | Should -Be ([datetime]::new(2026, 10, 1, 0, 0, 0, [DateTimeKind]::Utc)) + $row.ActualPeriodEnd | Should -Be $capturedUtc + $row.ActualPeriodSource | Should -Be 'Query window' + $row.ActualPeriod | Should -Match '2026-10-01.*UTC.*query window' + $row.Currency | Should -Be 'USD' + } + foreach ($body in $queryBodies) { + $body.timeframe | Should -Be 'Custom' + ([datetime]$body.timePeriod.from).ToUniversalTime() | Should -Be ([datetime]::new(2026, 10, 1, 0, 0, 0, [DateTimeKind]::Utc)) + ([datetime]$body.timePeriod.to).ToUniversalTime() | Should -Be $capturedUtc + $body.type | Should -Be 'ActualCost' + } + $vm = $rows | Where-Object ResourcePath -EQ $vmId + $vm.SubscriptionId | Should -Be $subId + $vm.Subscription | Should -Be 'Example subscription' + $vm.ResourceName | Should -Be 'fixture-vm' + $vm.ResourceType | Should -Be 'Virtual Machine' + $database = $rows | Where-Object ResourcePath -EQ $databaseId + $database.ResourceName | Should -Be 'fixture-db' + $database.ResourceType | Should -Be 'SQL Database' + $reservation = $rows | Where-Object ResourcePath -EQ $reservationId + $reservation.ResourceName | Should -Be 'fixture-reservation' + $reservation.ResourceType | Should -Be 'Reservation charge' + $reservationGroup = $rows | Where-Object ResourcePath -EQ $reservationGroupId + $reservationGroup.ResourceName | Should -Be 'Reservation charge (order fixture-order)' + $reservationGroup.ResourceType | Should -Be 'Reservation charge' + $unattributed = $rows | Where-Object { [string]::IsNullOrWhiteSpace($_.ResourcePath) } + $unattributed.ResourceType | Should -Be 'Unattributed charge' + $unattributed.ResourceName | Should -Be 'No resource ID recorded' + foreach ($charge in @($reservation, $reservationGroup, $unattributed)) { + if ($fixturePath -eq 'ManagementGroup') { + $charge.SubscriptionId | Should -BeNullOrEmpty + $charge.Subscription | Should -Be 'Not attributed' + } + else { + $charge.SubscriptionId | Should -Be $subId + $charge.Subscription | Should -Be 'Example subscription' + } + } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 2 -Exactly + Should -Invoke Get-AzContext -Times 0 -Exactly + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + } + } + + It 'Reads every resource page without retaining a failed MG attempt ()' -ForEach @( + @{ QueryPath = 'PerSubscription' } + @{ QueryPath = 'ManagementGroup' } + @{ QueryPath = 'ManagementGroupFallback' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath } { + param($QueryPath) + + Mock Resolve-CostMgId { if ($QueryPath -ne 'PerSubscription') { 'test-management-group' } } + Mock Test-CostMgCoverage { $true } + Mock Get-Date { [datetime]'2026-09-16T12:00:00Z' } + Mock Get-Date { [datetime]'2026-09-01T00:00:00' } -ParameterFilter { $Day -eq 1 } + Mock Invoke-AzRestMethodWithRetry { + $isNextPage = $Path -like '*page=2' + if ($QueryPath -eq 'ManagementGroupFallback' -and $Path -like '/providers/Microsoft.Management/*' -and $isNextPage) { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + if ($Path -like '*forecast*') { + $amount = if ($isNextPage) { 100.0 } else { 400.0 } + $properties = @{ + columns = @(@{ name = 'CostStatus' }, @{ name = 'Cost' }, @{ name = 'Currency' }) + rows = @(, @('Forecast', $amount, 'USD')) + } + } + else { + $amount = if ($isNextPage) { 25.0 } else { 100.0 } + $resourceName = if ($isNextPage) { 'second' } else { 'first' } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }) + rows = @(, @($amount, "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/$resourceName", 'test', 'USD')) + } + } + if (-not $isNextPage) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + + $result = @(Get-ResourceCosts -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -WarningAction SilentlyContinue) + + $result.Count | Should -Be 2 + ($result | Measure-Object -Property Actual -Sum).Sum | Should -Be 125 + if ($QueryPath -ne 'ManagementGroup') { + ($result | Measure-Object -Property Forecast -Sum).Sum | Should -Be 500 + Should -Invoke Invoke-AzRestMethodWithRetry -Times 2 -Exactly -ParameterFilter { + $request = $Payload | ConvertFrom-Json + $Path -like '*forecast*' -and $Method -eq 'POST' -and $request.timePeriod.from -eq '2026-09-01' + } + } + $continuationCalls = switch ($QueryPath) { 'ManagementGroup' { 1 } 'ManagementGroupFallback' { 3 } default { 2 } } + Should -Invoke Invoke-AzRestMethodWithRetry -Times $continuationCalls -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and -not [string]::IsNullOrWhiteSpace($Payload) + } + } + } + + It 'Withholds incomplete per-resource results' -ForEach @( + @{ Operation = 'query' } + @{ Operation = 'forecast' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Operation = $Operation } { + param($Operation) + + $failedOperation = $Operation + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }) + rows = @(, @(100.0, '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/first', 'test', 'USD')) + } + if ($Path.Contains("/$failedOperation`?")) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + if ($Operation -eq 'query') { + { Get-ResourceCosts -Subscriptions $subscriptions } | Should -Throw '*incomplete*' + return + } + + $result = @(Get-ResourceCosts -Subscriptions $subscriptions -WarningVariable forecastWarnings -WarningAction SilentlyContinue) + + $result.Count | Should -Be 1 + $result[0].Actual | Should -Be 100 + $result[0].Forecast | Should -BeNullOrEmpty + $result[0].ForecastSource | Should -Be 'Unavailable' + $result[0].CostIssue | Should -Match 'unavailable.*incomplete' + @($forecastWarnings | Where-Object { "$_" -match 'unavailable.*incomplete' }).Count | Should -Be 1 + } + } + } + + It 'Leaves orphan cost unavailable when its continuation fails' { + InModuleScope FinOpsMultitool { + Mock Search-AzGraphSafe { + $rows = @() + if ($Query.Contains("properties.diskState == 'Unattached'")) { + $rows = @([pscustomobject]@{ + id = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/first' + name = 'first'; resourceGroup = 'test'; subscriptionId = '11111111-1111-1111-1111-111111111111' + location = 'eastus'; diskSizeGb = 128; sku = 'Premium_LRS' + }) + } + [pscustomobject]@{ Data = $rows } + } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $body = @{ + properties = @{ + columns = @(@{ name = 'ResourceId' }, @{ name = 'Cost' }) + rows = @(, @('/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/first', 100.0)) + nextLink = "$Path&page=2" + } + } + [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + + $result = Get-OrphanedResources -Subscriptions $subscriptions + + $result.TotalCount | Should -Be 1 + $result.CostAvailable | Should -BeFalse + $result.CostedCount | Should -Be 0 + $result.MonthlyCost | Should -BeNullOrEmpty + $result.Orphans[0].MonthlyCost | Should -BeNullOrEmpty + $result.CostPeriod | Should -BeNullOrEmpty + $result.CostIssue | Should -BeLike '*incomplete*' + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and ($Payload | ConvertFrom-Json).type -eq 'ActualCost' + } + } + } + + Context 'Parsed query consumers' { + It 'Reconciles scoped AI token totals for ' -Tag 'AIReconciliation' -ForEach @( + @{ Scenario = 'different transaction totals'; SecondTotal = 1200; ExpectedTokens = 2400; ExpectedBasis = 'TokenTransaction'; ExpectedRate = 62.5 } + @{ Scenario = 'per-deployment fallback'; SecondTotal = $null; ExpectedTokens = 2200; ExpectedBasis = 'Prompt + generated'; ExpectedRate = 68.1818 } + @{ Scenario = 'measured zero transaction total'; SecondTotal = 0; ExpectedTokens = 1200; ExpectedBasis = 'TokenTransaction'; ExpectedRate = 83.3333 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ SecondTotal = $SecondTotal; ExpectedTokens = $ExpectedTokens; ExpectedBasis = $ExpectedBasis; ExpectedRate = $ExpectedRate } { + param($SecondTotal, $ExpectedTokens, $ExpectedBasis, $ExpectedRate) + $secondTransactionTotal = $SecondTotal + $accountIds = @( + '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.CognitiveServices/accounts/first' + '/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/fixture/providers/Microsoft.CognitiveServices/accounts/second' + ) + Mock Write-Host { } + Mock Resolve-CostMgId { 'fixture' } + Mock Get-PlainAccessToken { 'synthetic-token' } + Mock Search-AzGraphSafe { + @{ Data = @($accountIds | ForEach-Object { [pscustomobject]@{ id = $_; name = ($_ -split '/')[-1]; type = 'microsoft.cognitiveservices/accounts'; lkind = 'OpenAI'; subscriptionId = ($_ -split '/')[2]; location = 'eastus' } }) } + } + Mock Invoke-WebRequest { + $transactionTotal = if ($Uri -like '*/accounts/second/*') { $secondTransactionTotal } else { 1200 } + $metricValues = @(@{ Name = 'ProcessedPromptTokens'; Total = 800 }, @{ Name = 'GeneratedTokens'; Total = 200 }, @{ Name = 'AzureOpenAIRequests'; Total = 10 }) + if ($null -ne $transactionTotal) { $metricValues += @{ Name = 'TokenTransaction'; Total = $transactionTotal } } + $metrics = @(foreach ($metric in $metricValues) { + @{ name = @{ value = $metric.Name }; timeseries = @(@{ metadatavalues = @(@{ name = @{ value = 'ModelDeploymentName' }; value = 'shared-deployment' }); data = @(@{ total = $metric.Total }) }) } + }) + [pscustomobject]@{ Content = (@{ value = $metrics } | ConvertTo-Json -Depth 10) } + } + Mock Invoke-AzRestMethodWithRetry { + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'Currency' }); rows = @(@(100, $accountIds[0], 'USD'), @(50, $accountIds[1], 'USD')) } } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'First' }, [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Second' }) + + $result = Get-AIWorkloadMetrics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + + $result.RateIssue | Should -BeNullOrEmpty + $result.TotalTokens | Should -Be $ExpectedTokens + ($result.ByAccount | Measure-Object Tokens -Sum).Sum | Should -Be $ExpectedTokens + ($result.ByModel | Measure-Object TotalTokens -Sum).Sum | Should -Be $ExpectedTokens + $result.CostPer1KTokens | Should -Be $ExpectedRate + $result.CostPerRequest | Should -Be 7.5 + $result.ByModel.Count | Should -Be 2 + @($result.ByModel.Deployment | Select-Object -Unique) | Should -Be @('shared-deployment') + @($result.ByModel.ResourceId | Select-Object -Unique).Count | Should -Be 2 + @($result.ByModel.SubscriptionId | Select-Object -Unique).Count | Should -Be 2 + ($result.ByModel | Where-Object Account -EQ 'second').TokenBasis | Should -Be $ExpectedBasis + ($result.ByAccount | Where-Object Name -EQ 'second').TokenBasis | Should -Be $ExpectedBasis + } + } + + It 'Leaves incomplete AI usage unavailable for ' -Tag 'AIReconciliation' -ForEach @( + @{ Scenario = 'empty response' } + @{ Scenario = 'missing samples' } + @{ Scenario = 'missing output and total' } + @{ Scenario = 'missing requests' } + @{ Scenario = 'metric error' } + @{ Scenario = 'invalid sample' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Scenario = $Scenario } { + param($Scenario) + $fixtureScenario = $Scenario + $accountId = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.CognitiveServices/accounts/example' + $queries = [Collections.Generic.List[object]]::new() + Mock Write-Host { } + Mock Get-Date { [datetime]::new(2026, 10, 1, 0, 30, 0, [DateTimeKind]::Utc).ToLocalTime() } + Mock Resolve-CostMgId { 'fixture' } + Mock Get-PlainAccessToken { 'synthetic-token' } + Mock Search-AzGraphSafe { @{ Data = @([pscustomobject]@{ id = $accountId; name = 'example'; type = 'microsoft.cognitiveservices/accounts'; lkind = 'OpenAI'; subscriptionId = '11111111-1111-1111-1111-111111111111' }) } } + Mock Invoke-WebRequest { + $metrics = @(foreach ($metricName in @('ProcessedPromptTokens', 'GeneratedTokens', 'TokenTransaction', 'AzureOpenAIRequests')) { + if ($fixtureScenario -eq 'empty response' -or ($fixtureScenario -eq 'missing output and total' -and $metricName -in @('GeneratedTokens', 'TokenTransaction')) -or ($fixtureScenario -eq 'missing requests' -and $metricName -eq 'AzureOpenAIRequests')) { continue } + $total = if ($fixtureScenario -eq 'missing samples') { $null } elseif ($fixtureScenario -eq 'invalid sample') { -1 } else { 10 } + @{ name = @{ value = $metricName }; errorCode = $(if ($fixtureScenario -eq 'metric error') { 'BadRequest' } else { 'Success' }); timeseries = @(@{ data = @(@{ total = $total }) }) } + }) + [pscustomobject]@{ Content = (@{ value = $metrics } | ConvertTo-Json -Depth 10) } + } + Mock Invoke-AzRestMethodWithRetry { + $queries.Add(($Payload | ConvertFrom-Json)) + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'Currency' }); rows = @(, @(100, $accountId, 'USD')) } } | ConvertTo-Json -Depth 8) } + } + + $result = Get-AIWorkloadMetrics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Example' }) + + $result.MetricFailures | Should -Be 1 + $result.RateIssue | Should -Match 'incomplete' + $result.CostPer1KTokens | Should -BeNullOrEmpty + $result.CostPerRequest | Should -BeNullOrEmpty + $result.ByAccount[0].CostPer1KTokens | Should -BeNullOrEmpty + $result.ByAccount[0].MetricsComplete | Should -BeFalse + if ($fixtureScenario -eq 'missing requests') { + $result.ByAccount[0].Requests | Should -BeNullOrEmpty + $result.TotalRequests | Should -BeNullOrEmpty + $result.HasRequestData | Should -BeFalse + } + else { + $result.ByAccount[0].Tokens | Should -BeNullOrEmpty + $result.TotalTokens | Should -BeNullOrEmpty + $result.HasTokenData | Should -BeFalse + } + $queries[0].timeframe | Should -Be 'Custom' + ([datetime]$queries[0].timePeriod.from).ToUniversalTime() | Should -Be ([datetime]::new(2026, 10, 1, 0, 0, 0, [DateTimeKind]::Utc)) + ([datetime]$queries[0].timePeriod.to).ToUniversalTime() | Should -Be $result.UsagePeriodEndUtc + Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri -like '*timespan=2026-10-01T00:00:00Z/2026-10-01T00:30:00Z*' } + } + } + + It 'Keeps Hub model token rows distinct by account without live requests' -Tag 'AIReconciliation' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { throw 'No live cost calls are allowed.' } + Mock Invoke-WebRequest { throw 'No live metric calls are allowed.' } + $hubRows = @(foreach ($accountName in @('first', 'second')) { + $subscriptionId = if ($accountName -eq 'first') { '11111111-1111-1111-1111-111111111111' } else { '22222222-2222-2222-2222-222222222222' } + [pscustomobject]@{ + SubAccountId = $subscriptionId; ResourceName = $accountName + ResourceId = "/subscriptions/$subscriptionId/resourceGroups/fixture/providers/Microsoft.CognitiveServices/accounts/$accountName" + ResourceType = 'microsoft.cognitiveservices/accounts'; EffectiveCost = 100; BilledCost = 100; BillingCurrency = 'USD' + ChargePeriodStart = '2026-09-01'; ConsumedQuantity = 10; ConsumedUnit = '1K tokens'; x_SkuMeterName = 'gpt-example input tokens' + } + }) + + $result = ConvertTo-AIHubAggregates -HubData $hubRows + + $result.ModelTokens.Count | Should -Be 2 + @($result.ModelTokens.Values.Deployment | Select-Object -Unique).Count | Should -Be 1 + @($result.ModelTokens.Values.ResourceId | Select-Object -Unique).Count | Should -Be 2 + @($result.ModelTokens.Values.SubscriptionId | Select-Object -Unique).Count | Should -Be 2 + ($result.ModelTokens.Values | Measure-Object Total -Sum).Sum | Should -Be $result.TotalTokens + ($result.AcctTokens.Values | Measure-Object Tokens -Sum).Sum | Should -Be $result.TotalTokens + $result.TotalTokens | Should -Be 20000 + $result.Currency | Should -Be 'USD' + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly + Should -Invoke Invoke-WebRequest -Times 0 -Exactly + } + } + + It 'Preserves AI usage without inventing totals for currencies' -ForEach @( + @{ CurrencyCase = 'mixed'; ExpectedAvailable = $false } + @{ CurrencyCase = 'missing column'; ExpectedAvailable = $false } + @{ CurrencyCase = 'blank row'; ExpectedAvailable = $false } + @{ CurrencyCase = 'matching'; ExpectedAvailable = $true } + @{ CurrencyCase = 'matching with failed metrics'; ExpectedAvailable = $true } + @{ CurrencyCase = 'matching with other AI spend'; ExpectedAvailable = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ CurrencyCase = $CurrencyCase; ExpectedAvailable = $ExpectedAvailable } { + param($CurrencyCase, $ExpectedAvailable) + $fixtureCurrencyCase = $CurrencyCase + $accountIds = @( + '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.CognitiveServices/accounts/first' + '/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/test/providers/Microsoft.CognitiveServices/accounts/second' + ) + Mock Resolve-CostMgId { 'fixture' } + Mock Search-AzGraphSafe { + @{ Data = @($accountIds | ForEach-Object { [pscustomobject]@{ id = $_; name = ($_ -split '/')[-1]; type = 'microsoft.cognitiveservices/accounts'; lkind = 'OpenAI'; subscriptionId = ($_ -split '/')[2]; location = 'eastus' } }) } + } + Mock Get-PlainAccessToken { 'synthetic-token' } + Mock Invoke-WebRequest { + if ($fixtureCurrencyCase -eq 'matching with failed metrics' -and $Uri -like '*/accounts/second/*') { throw 'Synthetic metrics HTTP 429.' } + $metrics = @(foreach ($metric in @(@{ Name = 'ProcessedPromptTokens'; Total = 800 }, @{ Name = 'GeneratedTokens'; Total = 200 }, @{ Name = 'TokenTransaction'; Total = 1000 }, @{ Name = 'AzureOpenAIRequests'; Total = 10 })) { + @{ name = @{ value = $metric.Name }; timeseries = @(@{ data = @(@{ total = $metric.Total }) }) } + }) + [pscustomobject]@{ Content = (@{ value = $metrics } | ConvertTo-Json -Depth 10) } + } + Mock Invoke-AzRestMethodWithRetry { + $columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }) + $rows = @(@(100, $accountIds[0]), @(50, $accountIds[1])) + if ($fixtureCurrencyCase -ne 'missing column') { + $columns += @{ name = 'Currency' } + $rows[0] += 'USD' + $rows[1] += $(switch ($fixtureCurrencyCase) { 'mixed' { 'EUR' }; 'blank row' { '' }; default { 'USD' } }) + } + if ($fixtureCurrencyCase -eq 'matching with other AI spend') { $rows += , @(200, ($accountIds[0] -replace '/first$', '/speech'), 'USD') } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ columns = $columns; rows = $rows } } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'First' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Second' } + ) + + $result = Get-AIWorkloadMetrics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + + $metricsFailed = $CurrencyCase -eq 'matching with failed metrics' + $result.TotalTokens | Should -Be $(if ($metricsFailed) { 1000 } else { 2000 }) + $result.TotalRequests | Should -Be $(if ($metricsFailed) { 10 } else { 20 }) + $result.HasData | Should -BeTrue + if ($ExpectedAvailable) { + $result.TotalAICost | Should -Be $(if ($CurrencyCase -eq 'matching with other AI spend') { 350 } else { 150 }) + $result.Currency | Should -Be 'USD' + if ($metricsFailed) { + $result.CostPer1KTokens | Should -BeNullOrEmpty + $result.CostPerRequest | Should -BeNullOrEmpty + $result.RateIssue | Should -Match 'metrics' + $result.MetricFailures | Should -Be 1 + } + else { + $result.CostPer1KTokens | Should -Be 75 + $result.CostPerRequest | Should -Be 7.5 + } + } + else { + $result.TotalAICost | Should -BeNullOrEmpty + $result.CostPer1KTokens | Should -BeNullOrEmpty + $result.CostPerRequest | Should -BeNullOrEmpty + $result.CostIssue | Should -Match 'currenc' + foreach ($account in $result.ByAccount) { + $account.Cost | Should -BeNullOrEmpty + $account.CostPer1KTokens | Should -BeNullOrEmpty + } + $requestKpi = Get-KpiComputedValue -KpiId 'cost-per-api-call' -Data $result + $requestKpi.Value | Should -BeNullOrEmpty + $requestKpi.Display | Should -Match 'Unavailable' + $tokenKpi = Get-KpiComputedValue -KpiId 'token-consumption-metrics' -Data $result + $tokenKpi.Value | Should -Be 2000 + $tokenKpi.Display | Should -Not -Match 'for (Mixed|USD|EUR)' + } + } + } + + It 'Requires complete cost pages (continuation fails: )' -ForEach @( + @{ Scan = 'AI'; PageFails = $false } + @{ Scan = 'AI'; PageFails = $true } + @{ Scan = 'VM'; PageFails = $false } + @{ Scan = 'VM'; PageFails = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Scan = $Scan; PageFails = $PageFails } { + param($Scan, $PageFails) + + $failContinuation = $PageFails + $targetResourceId = if ($Scan -eq 'AI') { + '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.CognitiveServices/accounts/test' + } + else { + '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/virtualMachines/test' + } + Mock Resolve-CostMgId { 'test-management-group' } + Mock Search-AzGraphSafe { + [pscustomobject]@{ Data = @([pscustomobject]@{ id = $targetResourceId; type = 'microsoft.cognitiveservices/accounts'; lkind = 'TextAnalytics' }) } + } + Mock Resolve-VmAssociation { + $associated = [System.Collections.Generic.HashSet[string]]::new() + [void]$associated.Add($targetResourceId) + [pscustomobject]@{ + Id = $targetResourceId; Name = 'test'; SubscriptionId = '11111111-1111-1111-1111-111111111111' + ResourceGroup = 'test'; Associated = $associated + } + } + Mock Invoke-AzRestMethodWithRetry { + $isNextPage = $Path -like '*page=2' + if ($failContinuation -and $isNextPage) { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $amount = if ($isNextPage) { 25.0 } else { 100.0 } + $category = if ($isNextPage) { 'Storage' } else { 'Virtual Machines' } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'MeterCategory' }, @{ name = 'Currency' }, @{ name = 'UsageQuantity' }) + rows = @(, @($amount, $targetResourceId, $category, 'USD', 1.0)) + } + if (-not $isNextPage) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + + if ($Scan -eq 'AI') { + if ($PageFails) { + { Get-AIWorkloadMetrics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } | Should -Throw '*incomplete*' + } + else { + $result = Get-AIWorkloadMetrics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + $result.TotalAICost | Should -Be 125 + } + } + else { + $result = Get-VmCostBreakdown -VmName 'test' -Subscriptions $subscriptions + if ($PageFails) { + $result.HasData | Should -BeFalse + $result.TotalCost | Should -BeNullOrEmpty + $result.Note | Should -BeLike '*incomplete*' + } + else { + $result.HasData | Should -BeTrue + $result.TotalCost | Should -Be 125 + } + } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and ($Payload | ConvertFrom-Json).type -eq 'AmortizedCost' + } + } + } + } + + Context 'Trend scope metadata' { + It 'Retains scoped trends and explicit coverage for ' -Tag 'ScopedCostTrend' -ForEach @( + @{ Scenario = 'complete grouped response'; QueryPath = 'ManagementGroup'; IncludeSecond = $true; EmptySingle = $false; IndividualResult = 'None'; ExpectedData = 2; ExpectedUnverified = 0; ExpectedEmpty = 0; ExpectedCalls = 1; ExpectedTotal = 30 } + @{ Scenario = 'omitted subscription without cost rows'; QueryPath = 'ManagementGroup'; IncludeSecond = $false; EmptySingle = $false; IndividualResult = 'Empty'; ExpectedData = 1; ExpectedUnverified = 0; ExpectedEmpty = 1; ExpectedCalls = 2; ExpectedTotal = 10 } + @{ Scenario = 'omitted subscription with cost rows'; QueryPath = 'ManagementGroup'; IncludeSecond = $false; EmptySingle = $false; IndividualResult = 'Data'; ExpectedData = 2; ExpectedUnverified = 0; ExpectedEmpty = 0; ExpectedCalls = 2; ExpectedTotal = 30 } + @{ Scenario = 'failed individual query'; QueryPath = 'ManagementGroup'; IncludeSecond = $false; EmptySingle = $false; IndividualResult = 'Fail'; ExpectedData = 1; ExpectedUnverified = 1; ExpectedEmpty = 0; ExpectedCalls = 2; ExpectedTotal = 10 } + @{ Scenario = 'omitted subscription in another currency'; QueryPath = 'ManagementGroup'; IncludeSecond = $false; EmptySingle = $false; IndividualResult = 'Currency'; ExpectedData = 1; ExpectedUnverified = 1; ExpectedEmpty = 0; ExpectedCalls = 2; ExpectedTotal = 10 } + @{ Scenario = 'empty subscription response'; QueryPath = 'PerSubscription'; IncludeSecond = $false; EmptySingle = $false; IndividualResult = 'None'; ExpectedData = 1; ExpectedUnverified = 0; ExpectedEmpty = 1; ExpectedCalls = 2; ExpectedTotal = 10 } + @{ Scenario = 'empty single-subscription response'; QueryPath = 'Single'; IncludeSecond = $false; EmptySingle = $true; IndividualResult = 'None'; ExpectedData = 0; ExpectedUnverified = 0; ExpectedEmpty = 1; ExpectedCalls = 1; ExpectedTotal = 0 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath; IncludeSecond = $IncludeSecond; EmptySingle = $EmptySingle; IndividualResult = $IndividualResult; ExpectedData = $ExpectedData; ExpectedUnverified = $ExpectedUnverified; ExpectedEmpty = $ExpectedEmpty; ExpectedCalls = $ExpectedCalls; ExpectedTotal = $ExpectedTotal } { + param($QueryPath, $IncludeSecond, $EmptySingle, $IndividualResult, $ExpectedData, $ExpectedUnverified, $ExpectedEmpty, $ExpectedCalls, $ExpectedTotal) + $fixturePath = $QueryPath + $includeSecondRow = $IncludeSecond + $emptyOnlySubscription = $EmptySingle + $individualResponse = $IndividualResult + $firstId = '11111111-1111-1111-1111-111111111111' + $secondId = '22222222-2222-2222-2222-222222222222' + $outsideId = '99999999-9999-9999-9999-999999999999' + $capturedUtc = [datetime]::new(2026, 10, 1, 0, 30, 0, [DateTimeKind]::Utc) + $queries = [Collections.Generic.List[object]]::new() + Mock Get-Date { $capturedUtc.ToLocalTime() } + Mock Write-Host { } + Mock Resolve-CostMgId { if ($fixturePath -eq 'ManagementGroup') { 'fixture-mg' } } + Mock Get-AzContext { throw 'Trend fixtures must not read Azure context.' } + Mock Invoke-RestMethod { throw 'Trend fixtures must not send HTTP requests.' } + Mock Invoke-AzRestMethodWithRetry { + [void]$queries.Add(($Payload | ConvertFrom-Json)) + $columns = @(@{ name = 'Cost' }, @{ name = 'BillingMonth' }, @{ name = 'Currency' }) + $rows = @() + if ($fixturePath -eq 'ManagementGroup' -and $Path -like '/providers/Microsoft.Management/managementGroups/*') { + $columns += @{ name = 'SubscriptionId' } + $rows = @( + , @(10, '20260901', 'USD', $firstId) + if ($includeSecondRow) { , @(20, '20260901', 'USD', $secondId) } + , @(999, '20260901', 'EUR', $outsideId) + ) + } + elseif ($individualResponse -eq 'Fail' -and $Path -like "*/$secondId/*") { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + elseif ($individualResponse -eq 'Data' -and $Path -like "*/$secondId/*") { + $rows = @(, @(20, '20260901', 'USD')) + } + elseif ($individualResponse -eq 'Currency' -and $Path -like "*/$secondId/*") { + $rows = @(, @(20, '20260901', 'EUR')) + } + elseif (-not $emptyOnlySubscription -and $Path -like "*/$firstId/*") { + $rows = @(, @(10, '20260901', 'USD')) + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ columns = $columns; rows = $rows } } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @([pscustomobject]@{ Id = $firstId; Name = 'Example '; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' }) + if ($fixturePath -ne 'Single') { $subscriptions += [pscustomobject]@{ Id = $secondId; Name = 'Example second'; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } } + + $result = Get-CostTrend -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + + $result.ScopeKind | Should -Be 'Selected subscriptions' + $result.SelectedSubscriptionCount | Should -Be $subscriptions.Count + $result.SubscriptionsWithData | Should -Be $ExpectedData + @($result.UnverifiedSubscriptionIds).Count | Should -Be $ExpectedUnverified + @($result.NoDataSubscriptionIds).Count | Should -Be $ExpectedEmpty + $result.CoverageIncomplete | Should -Be ($ExpectedUnverified -gt 0) + $result.BySubscription.ContainsKey($outsideId) | Should -BeFalse + $result.SubscriptionNames[$firstId] | Should -Be 'Example ' + $result.CostBasis | Should -Be 'ActualCost' + $result.CostPeriodStartUtc | Should -Be ([datetime]::new(2026, 4, 1, 0, 0, 0, [DateTimeKind]::Utc)) + $result.CostPeriodEndUtc | Should -Be $capturedUtc + foreach ($query in $queries) { + ([datetime]$query.timePeriod.from).ToUniversalTime() | Should -Be $result.CostPeriodStartUtc + ([datetime]$query.timePeriod.to).ToUniversalTime() | Should -Be $result.CostPeriodEndUtc + } + if ($fixturePath -eq 'ManagementGroup') { + $queries[0].dataset.filter.dimensions.values | Should -Be @($firstId, $secondId) + Should -Invoke Invoke-AzRestMethodWithRetry -Times $ExpectedCalls -Exactly + $result.QueryScope | Should -Be '/providers/Microsoft.Management/managementGroups/fixture-mg' + $result.Months[0].Cost | Should -Be $ExpectedTotal + $result.Months[0].Currency | Should -Be 'USD' + if ($includeSecondRow) { @($result.IndividuallyQueriedIds).Count | Should -Be 0 } + else { + @($result.IndividuallyQueriedIds) | Should -Be @($secondId) + $queries[1].dataset.grouping | Should -BeNullOrEmpty + $queries[1].dataset.filter | Should -BeNullOrEmpty + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { $Path -like '/subscriptions/22222222-2222-2222-2222-222222222222/*' } + } + } + if ($individualResponse -in @('Fail', 'Currency')) { + @($result.QueryErrors).Count | Should -Be 1 + $result.QueryErrors[0] | Should -Match ([regex]::Escape("Example second [$secondId]")) + $result.Note | Should -Match 'individual results couldn''t be added' + $result.BySubscription[$firstId][0].Cost | Should -Be 10 + $result.BySubscription.ContainsKey($secondId) | Should -BeFalse + if ($individualResponse -eq 'Currency') { $result.QueryErrors[0] | Should -Match 'billed in EUR, but the trend total for that month is in USD' } + } + else { @($result.QueryErrors).Count | Should -Be 0 } + if ($ExpectedUnverified) { + $result.UnverifiedSubscriptionIds | Should -Contain $secondId + $result.Note | Should -Match 'not verified' + } + if ($emptyOnlySubscription) { $result.HasData | Should -BeFalse; $result.Months | Should -BeNullOrEmpty } + Should -Invoke Get-AzContext -Times 0 -Exactly + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + } + } + } + + Context 'Trend currency integrity' { + It 'Handles through without inventing a monetary total' -ForEach @( + @{ QueryPath = 'Fallback'; CurrencyCase = 'mixed'; Valid = $false } + @{ QueryPath = 'ManagementGroup'; CurrencyCase = 'mixed'; Valid = $false } + @{ QueryPath = 'Fallback'; CurrencyCase = 'missing'; Valid = $false } + @{ QueryPath = 'ManagementGroup'; CurrencyCase = 'missing'; Valid = $false } + @{ QueryPath = 'Single'; CurrencyCase = 'missing'; Valid = $false } + @{ QueryPath = 'Single'; CurrencyCase = 'blank'; Valid = $false } + @{ QueryPath = 'Fallback'; CurrencyCase = 'same'; Valid = $true } + @{ QueryPath = 'ManagementGroup'; CurrencyCase = 'same'; Valid = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ QueryPath = $QueryPath; CurrencyCase = $CurrencyCase; Valid = $Valid } { + param($QueryPath, $CurrencyCase, $Valid) + $fixturePath = $QueryPath + $fixtureCurrency = $CurrencyCase + Mock Resolve-CostMgId { if ($fixturePath -eq 'ManagementGroup') { 'fixture' } } + Mock Invoke-AzRestMethodWithRetry { + $firstId = '11111111-1111-1111-1111-111111111111' + $secondId = '22222222-2222-2222-2222-222222222222' + $ids = if ($fixturePath -eq 'ManagementGroup') { @($firstId, $secondId) } elseif ($Path -like "*/$secondId/*") { @($secondId) } else { @($firstId) } + $columns = @(@{ name = 'BillingMonth'; type = 'Number' }, @{ name = 'Cost'; type = 'Number' }, @{ name = 'SubscriptionId'; type = 'String' }) + if ($fixtureCurrency -ne 'missing') { $columns += @{ name = 'Currency'; type = 'String' } } + $rows = @(foreach ($subscriptionId in $ids) { + $row = @([int](Get-Date).AddMonths(-1).ToString('yyyyMM01'), 100, $subscriptionId) + if ($fixtureCurrency -ne 'missing') { $row += $(if ($fixtureCurrency -eq 'blank') { '' } elseif ($fixtureCurrency -eq 'mixed' -and $subscriptionId -eq $secondId) { 'EUR' } else { 'USD' }) } + , $row + }) + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ columns = $columns; rows = $rows } } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'First' }) + if ($fixturePath -ne 'Single') { $subscriptions += [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Second' } } + + if ($Valid) { + $result = Get-CostTrend -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + $result.Months[0].Cost | Should -Be 200 + $result.Months[0].Currency | Should -Be 'USD' + } + else { + { Get-CostTrend -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } | Should -Throw '*currency*' + } + } + } + } + + Context 'Cost-only scan failures' { + It 'Does not return a successful scan after a failed continuation ()' -ForEach @( + @{ Scan = 'Trend' } + @{ Scan = 'SharedAllocation' } + @{ Scan = 'Savings' } + @{ Scan = 'SavingsFallback' } + @{ Scan = 'UnitEconomics' } + ) { + $firstPage = Get-FakeResponse -NextLink '/subscriptions/x/q?page=2' + InModuleScope FinOpsMultitool -Parameters @{ Scan = $Scan; FirstPage = $firstPage } { + param($Scan, $FirstPage) + + $scanName = $Scan + $initialResponse = $FirstPage + Mock Search-AzGraphSafe { [pscustomobject]@{ Data = @() } } + Mock Resolve-CostMgId { if ($scanName -eq 'SavingsFallback') { 'test-management-group' } } + Mock Test-CostMgCoverage { $true } + Mock Get-StorageAccountUsedGb { 0.0 } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + if ($scanName -eq 'SavingsFallback' -and $Path -like '/subscriptions/*') { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $initialResponse + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'test' }) + if ($scanName -eq 'SavingsFallback') { + $subscriptions += [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'second' } + } + { + switch ($scanName) { + 'Trend' { Get-CostTrend -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + 'SharedAllocation' { Get-AllocationCostMaps -SubscriptionIds $subscriptions.Id } + 'Savings' { Get-SavingsRealized -Subscriptions $subscriptions } + 'SavingsFallback' { Get-SavingsRealized -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + 'UnitEconomics' { Get-UnitEconomics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + } + } | Should -Throw '*incomplete*' + + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and -not [string]::IsNullOrWhiteSpace($Payload) + } + } + } + } + + Context 'Required first responses' { + It 'Does not publish totals after a required first response fails ()' -ForEach @( + @{ Scan = 'Trend' } + @{ Scan = 'TrendPartial' } + @{ Scan = 'Forecast' } + @{ Scan = 'Savings' } + @{ Scan = 'BudgetHistory' } + @{ Scan = 'AI' } + @{ Scan = 'UnitEconomics' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Scan = $Scan } { + param($Scan) + + $scanName = $Scan + Mock Resolve-CostMgId { if ($scanName -ne 'TrendPartial') { 'test-management-group' } } + Mock Get-StorageAccountUsedGb { 0.0 } + Mock Search-AzGraphSafe { + $rows = if ($scanName -eq 'AI') { + @([pscustomobject]@{ type = 'microsoft.cognitiveservices/accounts'; lkind = 'TextAnalytics' }) + } + else { @() } + [pscustomobject]@{ Data = $rows } + } + Mock Invoke-AzRestMethodWithRetry { + if ($scanName -eq 'Forecast' -and $Path -notlike '*forecast*') { + return [pscustomobject]@{ + StatusCode = 200 + Content = '{"properties":{"columns":[{"name":"Cost"},{"name":"Currency"}],"rows":[[100,"USD"]]}}' + } + } + if ($scanName -eq 'TrendPartial' -and $Path -like '/subscriptions/11111111-*') { + return [pscustomobject]@{ + StatusCode = 200 + Content = '{"properties":{"columns":[{"name":"Cost","type":"Number"},{"name":"BillingMonth","type":"DateTime"},{"name":"Currency","type":"String"}],"rows":[[100,"2026-08-01","USD"]]}}' + } + } + [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'first' }) + if ($scanName -eq 'TrendPartial') { + $subscriptions += [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'second' } + } + $budgets = @([pscustomobject]@{ + SubscriptionId = $subscriptions[0].Id; Subscription = 'first'; Amount = 1000; BudgetName = 'test'; TimeGrain = 'Monthly' + Category = 'Cost'; Currency = 'USD'; TimePeriod = @{ startDate = (Get-Date).ToUniversalTime().Date.AddYears(-2) } + }) + + { + switch ($scanName) { + { $_ -in 'Trend', 'TrendPartial' } { Get-CostTrend -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + 'Forecast' { Get-CostDataPerSubscription -Subscriptions $subscriptions } + 'Savings' { Get-SavingsRealized -Subscriptions $subscriptions } + 'BudgetHistory' { Get-BudgetHistory -Budgets $budgets -MonthsBack 1 } + 'AI' { Get-AIWorkloadMetrics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + 'UnitEconomics' { Get-UnitEconomics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions } + } + } | Should -Throw '*incomplete*' + } + } + + It 'Requires a successful cost-by-tag batch response (HTTP )' -ForEach @( + @{ StatusCode = 200 } + @{ StatusCode = 503 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ ResponseStatus = $StatusCode } { + param($ResponseStatus) + + Mock Search-AzGraphSafe { [pscustomobject]@{ Data = @() } } + $sessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + $sessionState.Variables.Add([System.Management.Automation.Runspaces.SessionStateVariableEntry]::new('FixtureStatus', $ResponseStatus, 'Mock response status')) + $sessionState.Commands.Add([System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new('Invoke-AzRestMethod', @' +param($Path, $Method, $Payload) +[pscustomobject]@{ + StatusCode = $FixtureStatus + Content = '{"properties":{"columns":[{"name":"Cost"},{"name":"ResourceId"},{"name":"Currency"}],"rows":[[125,"/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/test/providers/Microsoft.Compute/disks/test","USD"]]}}' + Headers = @{} +} +'@)) + $pool = [runspacefactory]::CreateRunspacePool(1, 2, $sessionState, $Host) + $previousPool = $script:RunspacePool + try { + $pool.Open() + $script:RunspacePool = $pool + $subscriptions = @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'first' }) + $arguments = @{ TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Subscriptions = $subscriptions; ExistingTags = @{ CostCenter = @{ TotalResources = 1 } } } + if ($ResponseStatus -eq 503) { + { Get-CostByTag @arguments } | Should -Throw '*503*incomplete*' + } + else { + $result = Get-CostByTag @arguments + ($result.CostByTag.CostCenter | Measure-Object Cost -Sum).Sum | Should -Be 125 + } + } + finally { + $script:RunspacePool = $previousPool + $pool.Dispose() + } + } + } + } + + Context 'Cost-by-tag coverage and currency' { + It 'Handles without presenting invalid whole-scope costs' -ForEach @( + @{ Scenario = 'denied subscription'; ExpectError = $false } + @{ Scenario = 'failed continuation'; ExpectError = $false } + @{ Scenario = 'mixed currencies'; ExpectError = $true } + @{ Scenario = 'mixed currencies reversed'; ExpectError = $true } + @{ Scenario = 'missing currency'; ExpectError = $true } + @{ Scenario = 'matching currencies'; ExpectError = $false } + @{ Scenario = 'failed tag map'; ExpectError = $true } + @{ Scenario = 'empty successes with denied subscription'; ExpectError = $false } + @{ Scenario = 'offsetting charges'; ExpectError = $false } + @{ Scenario = 'empty current month'; ExpectError = $false } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Scenario = $Scenario; ExpectError = $ExpectError } { + param($Scenario, $ExpectError) + $fixtureScenario = $Scenario + Mock Search-AzGraphSafe { + if ($fixtureScenario -eq 'failed tag map') { throw 'Synthetic tag map is incomplete.' } + [pscustomobject]@{ Data = @() } + } + Mock Invoke-AzRestMethodWithRetry { [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $sessionState = [Management.Automation.Runspaces.InitialSessionState]::CreateDefault() + $sessionState.Variables.Add([Management.Automation.Runspaces.SessionStateVariableEntry]::new('FixtureScenario', $Scenario, 'Synthetic response scenario')) + $sessionState.Commands.Add([Management.Automation.Runspaces.SessionStateFunctionEntry]::new('Invoke-AzRestMethod', @' +param($Path, $Method, $Payload) +$subscriptionId = ($Path -split '/')[2] +$second = $subscriptionId -eq '22222222-2222-2222-2222-222222222222' +if ($FixtureScenario -in @('denied subscription', 'empty successes with denied subscription') -and $second) { return [pscustomobject]@{ StatusCode = 403; Content = '{}'; Headers = @{} } } +$columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }) +$amount = if ($second) { 50 } elseif ($subscriptionId -like '11111111-*') { 100 } else { 25 } +$row = @($amount, "/subscriptions/$subscriptionId/resourceGroups/fixture/providers/Microsoft.Compute/disks/fixture") +if ($FixtureScenario -ne 'missing currency') { + $columns += @{ name = 'Currency' } + $row += $(if (($FixtureScenario -eq 'mixed currencies' -and $second) -or ($FixtureScenario -eq 'mixed currencies reversed' -and -not $second)) { 'EUR' } else { 'USD' }) +} +$properties = @{ columns = $columns; rows = @(,$row) } +if ($FixtureScenario -eq 'empty successes with denied subscription' -or ($FixtureScenario -eq 'empty current month' -and ($Payload | ConvertFrom-Json).timeframe -eq 'MonthToDate')) { $properties.rows = @() } +if ($FixtureScenario -eq 'offsetting charges') { $properties.rows += ,@(-$amount, $row[1], 'USD') } +if ($FixtureScenario -eq 'failed continuation' -and $second) { $properties.nextLink = "$Path&page=2" } +[pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8); Headers = @{} } +'@)) + $pool = [runspacefactory]::CreateRunspacePool(1, 2, $sessionState, $Host) + $previousPool = $script:RunspacePool + try { + $pool.Open() + $script:RunspacePool = $pool + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'First' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Second' } + [pscustomobject]@{ Id = '33333333-3333-3333-3333-333333333333'; Name = 'Third' } + ) + $arguments = @{ TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Subscriptions = $subscriptions; ExistingTags = @{ CostCenter = @{ TotalResources = 3 } } } + if ($ExpectError) { + $expectedMessage = if ($Scenario -eq 'failed tag map') { '*tag map*incomplete*' } else { '*currenc*' } + { Get-CostByTag @arguments } | Should -Throw $expectedMessage + } + else { + $result = Get-CostByTag @arguments + $partial = $Scenario -in @('denied subscription', 'failed continuation', 'empty successes with denied subscription') + $expectedTotal = if ($Scenario -in @('empty successes with denied subscription', 'offsetting charges')) { 0 } elseif ($partial) { 125 } else { 175 } + [double]($result.CostByTag.CostCenter | Measure-Object Cost -Sum).Sum | Should -Be $expectedTotal + $result.UsedTimeframe | Should -Be $(if ($Scenario -eq 'empty current month') { 'Custom' } else { 'MonthToDate' }) + $result.CoverageIncomplete | Should -Be $partial + $result.ScannedSubs | Should -Be $(if ($partial) { 2 } else { 3 }) + $result.TotalSubs | Should -Be 3 + if ($partial) { + @($result.FailedSubscriptions).Count | Should -Be 1 + $result.FailedSubscriptions[0].SubscriptionId | Should -Be $subscriptions[1].Id + $result.SuccessfulSubscriptionIds | Should -Contain $subscriptions[2].Id + foreach ($kpiId in @('pct-costs-untagged', 'pct-costs-unallocated', 'tagging-policy-compliant')) { + $kpi = Get-KpiComputedValue -KpiId $kpiId -Data $result + $kpi.Value | Should -BeNullOrEmpty + $kpi.Display | Should -Match 'incomplete' + } + } + } + } + finally { $script:RunspacePool = $previousPool; $pool.Dispose() } + } + } + } + + Context 'Paged billing discovery' { + It 'Retains incomplete membership evidence after a partial match' -Tag 'CommitmentScopeCoverage' -ForEach @( + @{ FailurePath = 'membership page' } + @{ FailurePath = 'subscription lookup' } + @{ FailurePath = 'empty lookup' } + @{ FailurePath = 'whitespace lookup' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ FailurePath = $FailurePath } { + param($FailurePath) + $fixtureFailurePath = $FailurePath + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'First' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Second' } + ) + $accounts = @('unreadable', 'readable') | ForEach-Object { + [pscustomobject]@{ name = $_; id = "/providers/Microsoft.Billing/billingAccounts/$_"; properties = @{ displayName = $_; agreementType = 'EnterpriseAgreement' } } + } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*billingProperty*') { + if ($fixtureFailurePath -ne 'membership page' -and $Path -like '*/22222222-*') { + return [pscustomobject]@{ StatusCode = 200; Content = '{"properties":{"billingAccountId":"/providers/Microsoft.Billing/billingAccounts/readable"}}' } + } + if ($fixtureFailurePath -in @('empty lookup', 'whitespace lookup')) { + return [pscustomobject]@{ StatusCode = 200; Content = $(if ($fixtureFailurePath -eq 'empty lookup') { '' } else { ' ' }) } + } + return [pscustomobject]@{ StatusCode = $(if ($fixtureFailurePath -eq 'subscription lookup') { 503 } else { 403 }); Content = '{}' } + } + if ($Path -like '*billingSubscriptions*') { + if ($Path -like '*/unreadable/*') { + if ($Path -like '*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + return [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @(); nextLink = "$Path&page=2" } | ConvertTo-Json) } + } + return [pscustomobject]@{ StatusCode = 200; Content = '{"value":[{"properties":{"subscriptionId":"22222222-2222-2222-2222-222222222222"}}]}' } + } + if ($Path -match '/billingAccounts\?') { + return [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @($accounts) } | ConvertTo-Json -Depth 7) } + } + if ($Path -match '/readable/.*reservationSummaries\?') { + return [pscustomobject]@{ StatusCode = 200; Content = '{"value":[{"properties":{"reservationId":"fixture-reservation","reservationOrderId":"fixture-order","skuName":"fixture-sku","kind":"Compute","avgUtilizationPercentage":100,"minUtilizationPercentage":100,"maxUtilizationPercentage":100,"usageDate":"2026-09-01"}}]}' } + } + [pscustomobject]@{ StatusCode = 200; Content = '{"value":[]}' } + } + + $scope = Get-FinOpsBillingScope -BillingAccounts $accounts -Subscriptions $subscriptions + $billing = Get-BillingStructure -Subscriptions $subscriptions + $macc = Get-MaccCommitment -Subscriptions $subscriptions + $utilization = Get-CommitmentUtilization -Subscriptions $subscriptions + + $scope.Resolved | Should -BeTrue + $scope.Accounts[0].name | Should -Be 'readable' + $scope.CoverageIncomplete | Should -BeTrue + $expectedReason = if ($FailurePath -in @('empty lookup', 'whitespace lookup')) { 'no content' } else { '503' } + ($scope.ReadErrors -join ' ') | Should -Match $expectedReason + $billing.CoverageIncomplete | Should -BeTrue + $billing.Note | Should -Match $expectedReason + $macc.CoverageIncomplete | Should -BeTrue + $macc.Reason | Should -Match $expectedReason + $macc.Reason | Should -Not -Match 'No MACC commitment found' + $utilization.RICount | Should -Be 1 + $utilization.Reservations[0].AvgUtilization | Should -Be 100 + $utilization.CoverageIncomplete | Should -BeTrue + $utilization.RIAvgUtilization | Should -BeNullOrEmpty + $utilization.SPAvgUtilization | Should -BeNullOrEmpty + $utilization.ScopeResolutionErrors | Should -Not -BeNullOrEmpty + $utilization.Note | Should -Match $expectedReason + } + } + + It 'Correlates a selected subscription from a later billing membership page' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*billingProperty*') { return [pscustomobject]@{ StatusCode = 403; Content = '{}' } } + $second = $Path -like '*page=2' + $body = @{ value = @(@{ properties = @{ subscriptionId = $(if ($second) { '11111111-1111-1111-1111-111111111111' } else { '22222222-2222-2222-2222-222222222222' }) } }) } + if (-not $second) { $body.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 6) } + } + + $result = Get-FinOpsBillingScope -BillingAccounts @([pscustomobject]@{ Name = 'fixture' }) -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111' }) + + $result.Resolved | Should -BeTrue + $result.Accounts[0].Name | Should -Be 'fixture' + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { $Path -like '*page=2' -and $Method -eq 'GET' } + } + } + + It 'Does not invent MACC amounts when the lot currency is absent' { + InModuleScope FinOpsMultitool { + Mock Get-FinOpsBillingScope { @{ Resolved = $true; Accounts = $BillingAccounts } } + Mock Invoke-AzRestMethodWithRetry { + $value = if ($Path -like '*/lots?*') { @(@{ properties = @{ source = 'ConsumptionCommitment'; originalAmount = @{ value = 100 }; closedBalance = @{ value = 25 } } }) } + else { @(@{ name = 'fixture'; properties = @{ displayName = 'Fixture'; agreementType = 'EnterpriseAgreement' } }) } + [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @($value) } | ConvertTo-Json -Depth 8) } + } + + $result = Get-MaccCommitment -Subscriptions @([pscustomobject]@{ Id = 'fixture' }) + + $result.CoverageIncomplete | Should -BeTrue + $result.Commitments[0].Currency | Should -BeNullOrEmpty + $result.Commitments[0].Consumed | Should -BeNullOrEmpty + $result.Commitments[0].Remaining | Should -BeNullOrEmpty + $result.Reason | Should -Match 'currency' + } + } + + It 'Preserves contract probe failures beside subscription inference' { + InModuleScope FinOpsMultitool { + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '/subscriptions/*') { return [pscustomobject]@{ StatusCode = 200; Content = '{"subscriptionPolicies":{"quotaId":"EnterpriseAgreement"}}' } } + [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + + $result = @(Get-ContractInfo -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' })) + + $result[0].AgreementType | Should -Be 'EnterpriseAgreement' + $result[0].CoverageIncomplete | Should -BeTrue + $result[0].ReadErrors | Should -Match '503' + $result[0].Note | Should -Match 'not confirmed' + } + } + + It 'Includes all MACC lots or reports incomplete coverage (failed page: )' -ForEach @( + @{ PageFails = $false } + @{ PageFails = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ PageFails = $PageFails } { + param($PageFails) + $failLots = $PageFails + Mock Get-FinOpsBillingScope { @{ Resolved = $true; Accounts = $BillingAccounts } } + Mock Invoke-AzRestMethodWithRetry { + $second = $Path -like '*page=2' + if ($Path -like '*/lots?*') { + if ($second -and $failLots) { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $amount = if ($second) { 200 } else { 100 } + $body = @{ value = @(@{ properties = @{ source = 'ConsumptionCommitment'; originalAmount = @{ value = $amount }; closedBalance = @{ value = 25 }; billingCurrency = 'EUR'; status = 'Active' } }) } + } + else { + $body = @{ value = @(@{ name = 'fixture'; properties = @{ displayName = 'Fixture'; agreementType = $(if ($second) { 'EnterpriseAgreement' } else { 'MicrosoftOnlineServicesProgram' }) } }) } + } + if (-not $second) { $body.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 8) } + } + + $result = Get-MaccCommitment -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result.CoverageIncomplete | Should -Be $PageFails + if ($PageFails) { + $result.Commitments | Should -BeNullOrEmpty + $result.Reason | Should -Match 'incomplete' + $result.Reason | Should -Not -Match 'No MACC commitment found' + } + else { + $result.Commitments.Count | Should -Be 2 + ($result.Commitments | Measure-Object Commitment -Sum).Sum | Should -Be 300 + ($result.Commitments | Measure-Object Consumed -Sum).Sum | Should -Be 250 + } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 2 -Exactly -ParameterFilter { $Path -like '*page=2' -and $Method -eq 'GET' } + } + } + + It 'Reads every billing hierarchy page and marks a failed section (failed page: )' -ForEach @( + @{ PageFails = $false } + @{ PageFails = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ PageFails = $PageFails } { + param($PageFails) + $failSection = $PageFails + Mock Get-FinOpsBillingScope { @{ Resolved = $true; Accounts = $BillingAccounts } } + Mock Invoke-AzRestMethodWithRetry { + $pageNumber = if ($Path -like '*page=2') { 2 } else { 1 } + if ($failSection -and $Path -like '*invoiceSections*page=2') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $name = "fixture-$pageNumber" + $properties = @{ displayName = $name; name = $name; agreementType = $(if ($pageNumber -eq 1) { 'EnterpriseAgreement' } else { 'MicrosoftCustomerAgreement' }) } + $body = @{ value = @(@{ name = $name; id = (($Path -split '\?')[0] + "/$name"); properties = $properties }) } + if ($pageNumber -eq 1) { $body.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 8) } + } + + $result = Get-BillingStructure -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result.BillingAccounts.Count | Should -Be 2 + $result.BillingProfiles.Count | Should -Be 2 + $result.EADepartments.Count | Should -Be 2 + $result.CostAllocationRules.Count | Should -Be 4 + $result.CoverageIncomplete | Should -Be $PageFails + if ($PageFails) { + $result.InvoiceSections | Should -BeNullOrEmpty + $result.Note | Should -Match 'invoice sections.*incomplete' + } + else { $result.InvoiceSections.Count | Should -Be 4 } + } + } + + It 'Reads the requested billing account on page two (failed page: )' -ForEach @( + @{ PageFails = $false } + @{ PageFails = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ PageFails = $PageFails } { + param($PageFails) + $failPage = $PageFails + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*page=2' -and $failPage) { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $name = if ($Path -like '*page=2') { 'second' } else { 'first' } + $body = @{ value = @(@{ name = $name; properties = @{ displayName = $name; agreementType = 'EnterpriseAgreement' } }) } + if ($name -eq 'first') { $body.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 6) } + } + + $result = Get-BillingAccount -BillingAccountId 'second' -ProbeAccess $false + + $result.CoverageIncomplete | Should -Be $PageFails + if ($PageFails) { $result.Accounts | Should -BeNullOrEmpty; $result.Note | Should -Match 'incomplete' } + else { $result.Accounts[0].Id | Should -Be 'second' } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { $Path -like '*page=2' -and $Method -eq 'GET' } + } + } + + It 'Bounds management-group probes and reserves the tenant root (listed: , readable: )' -Tag 'ManagementGroupProbeLimit' -ForEach @( + @{ RootListed = $false; RootReadable = $true } + @{ RootListed = $true; RootReadable = $true } + @{ RootListed = $false; RootReadable = $false } + @{ RootListed = $true; RootReadable = $false } + ) { + InModuleScope FinOpsMultitool -Parameters @{ RootListed = $RootListed; RootReadable = $RootReadable } { + param($RootListed, $RootReadable) + Reset-CostMgScope + $fixtureRootListed = $RootListed + $tenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + $probedIds = [System.Collections.Generic.List[string]]::new() + Mock Write-Host { } + Mock Write-Warning { } + Mock Get-AzContext { throw 'Probe fixtures must not read an Azure context.' } + Mock Invoke-RestMethod { throw 'Probe fixtures must not make HTTP requests.' } + Mock Invoke-AzRestMethodWithRetry { + if ($Method -eq 'GET') { + if ($Path -like '*page=2') { + $body = @{ value = @(13..100 | ForEach-Object { @{ name = "denied-$_" } }) } + } + else { + $names = @(if ($fixtureRootListed) { @{ name = $tenantId } }) + @(foreach ($number in 1..12) { + @{ name = "denied-$number" } + @{ name = "DENIED-$number" } + }) + $body = @{ value = $names; nextLink = "$Path&page=2" } + } + return [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 5) } + } + $mgId = ($Path -split '/managementGroups/')[1].Split('/')[0] + [void]$probedIds.Add($mgId) + $status = if ($mgId -eq $tenantId -and $RootReadable) { 200 } elseif ($mgId -eq 'denied-1') { 429 } else { 403 } + [pscustomobject]@{ StatusCode = $status; Content = '{}' } + } + try { + $resolved = Resolve-CostMgId -TenantId $tenantId + + $probedIds.Count | Should -Be 25 + $probedIds[-1] | Should -Be $tenantId + @($probedIds | Where-Object { $_ -eq $tenantId }).Count | Should -Be 1 + $probedIds[0] | Should -Be 'denied-1' + $probedIds[23] | Should -Be 'denied-24' + if ($RootReadable) { + $resolved | Should -Be $tenantId + Test-MgCostScope | Should -BeTrue + Resolve-CostMgId -TenantId $tenantId | Should -Be $tenantId + } + else { + $resolved | Should -BeNullOrEmpty + Test-MgCostScope | Should -BeFalse + Resolve-CostMgId -TenantId $tenantId | Should -BeNullOrEmpty + } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 2 -Exactly -ParameterFilter { $Method -eq 'GET' } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 25 -Exactly -ParameterFilter { $Method -eq 'POST' -and $MaxRetries -eq 2 } + Should -Invoke Write-Warning -Times 1 -Exactly -ParameterFilter { $Message -match '25.*tenant root' } + Should -Invoke Get-AzContext -Times 0 -Exactly + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + } + finally { Reset-CostMgScope } + } + } + + It 'Stops at the first usable child and reuses its scope without a limit warning' -Tag 'ManagementGroupProbeLimit' { + InModuleScope FinOpsMultitool { + Reset-CostMgScope + Mock Write-Host { } + Mock Write-Warning { } + Mock Invoke-AzRestMethodWithRetry { + if ($Method -eq 'GET') { + return [pscustomobject]@{ StatusCode = 200; Content = '{"value":[{"name":"denied"},{"name":"readable"},{"name":"unused"}]}' } + } + [pscustomobject]@{ StatusCode = $(if ($Path -like '*/readable/*') { 200 } else { 403 }); Content = '{}' } + } + try { + Resolve-CostMgId -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' | Should -Be 'readable' + Resolve-CostMgId -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' | Should -Be 'readable' + + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { $Method -eq 'GET' } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 2 -Exactly -ParameterFilter { $Method -eq 'POST' -and $MaxRetries -eq 2 } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly -ParameterFilter { $Path -match '/(unused|aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa)/' } + Should -Invoke Write-Warning -Times 0 -Exactly + } + finally { Reset-CostMgScope } + } + } + + It 'Keeps selected-subscription cost coverage after exhausting the probe budget' -Tag 'ManagementGroupProbeLimit' { + InModuleScope FinOpsMultitool { + Reset-CostMgScope + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'First'; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Second'; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } + ) + Mock Write-Host { } + Mock Write-Warning { } + Mock Get-AzContext { throw 'Cost fallback fixtures must not read an Azure context.' } + Mock Invoke-RestMethod { throw 'Cost fallback fixtures must not make HTTP requests.' } + Mock Invoke-AzRestMethodWithRetry { + if ($Method -eq 'GET') { + return [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @(1..100 | ForEach-Object { @{ name = "denied-$_" } }) } | ConvertTo-Json -Depth 5) } + } + if ($Path -like '/providers/Microsoft.Management/managementGroups/*') { + return [pscustomobject]@{ StatusCode = 403; Content = '{}' } + } + if ($Path -notmatch '^/subscriptions/(11111111-1111-1111-1111-111111111111|22222222-2222-2222-2222-222222222222)/') { throw 'Unexpected subscription scope.' } + $amount = if ($Path -like '*forecast*') { 150 } else { 100 } + $responseContent = @{ + properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'Currency' }); rows = @(, @($amount, 'USD')) } + } | ConvertTo-Json -Depth 6 + [pscustomobject]@{ StatusCode = 200; Content = $responseContent } + } + try { + $costs = Get-CostData -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions -RestrictToSelected + + $costs.Count | Should -Be 2 + foreach ($subscription in $subscriptions) { + $costs[$subscription.Id].Actual | Should -Be 100 + $costs[$subscription.Id].Forecast | Should -Be 150 + $costs[$subscription.Id].Currency | Should -Be 'USD' + } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 25 -Exactly -ParameterFilter { $Method -eq 'POST' -and $Path -like '/providers/Microsoft.Management/*' -and $MaxRetries -eq 2 } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 4 -Exactly -ParameterFilter { + $Method -eq 'POST' -and $Path -match '^/subscriptions/(11111111-1111-1111-1111-111111111111|22222222-2222-2222-2222-222222222222)/' + } + Should -Invoke Get-AzContext -Times 0 -Exactly + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + } + finally { Reset-CostMgScope } + } + } + + It 'Does not reuse a management-group cache across tenants' -Tag 'ManagementGroupProbeLimit' -ForEach @( + @{ PreviousState = 'resolved'; FirstStatus = 200 } + @{ PreviousState = 'failed'; FirstStatus = 403 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ FirstStatus = $FirstStatus } { + param($FirstStatus) + Reset-CostMgScope + $fixtureStatus = $FirstStatus + Mock Write-Host { } + Mock Get-AzContext { throw 'Tenant cache fixtures must not read an Azure context.' } + Mock Invoke-RestMethod { throw 'Tenant cache fixtures must not make HTTP requests.' } + Mock Invoke-AzRestMethodWithRetry { + if ($Method -eq 'GET') { return [pscustomobject]@{ StatusCode = 200; Content = '{"value":[]}' } } + $status = if ($Path -like '*/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/*') { $fixtureStatus } else { 200 } + [pscustomobject]@{ StatusCode = $status; Content = '{}' } + } + try { + $null = Resolve-CostMgId -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + + Resolve-CostMgId -TenantId 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' | Should -Be 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' + + Test-MgCostScope | Should -BeTrue + Should -Invoke Invoke-AzRestMethodWithRetry -Times 2 -Exactly -ParameterFilter { $Method -eq 'GET' } + foreach ($expectedTenant in @('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb')) { + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { + $Method -eq 'POST' -and $Path -like "*/$expectedTenant/*" + } + } + Should -Invoke Get-AzContext -Times 0 -Exactly + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + } + finally { Reset-CostMgScope } + } + } + + It 'Finds a readable management group after the first page and first twelve candidates' -Tag 'ManagementGroupProbeLimit' { + InModuleScope FinOpsMultitool { + Reset-CostMgScope + Mock Invoke-AzRestMethodWithRetry { + if ($Method -eq 'GET') { + $body = if ($Path -like '*page=2') { @{ value = @(@{ name = 'readable' }) } } + else { @{ value = @(1..12 | ForEach-Object { @{ name = "denied-$_" } }); nextLink = "$Path&page=2" } } + return [pscustomobject]@{ StatusCode = 200; Content = ($body | ConvertTo-Json -Depth 5) } + } + [pscustomobject]@{ StatusCode = $(if ($Path -like '*/readable/*') { 200 } else { 403 }); Content = '{}' } + } + try { + Resolve-CostMgId -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' | Should -Be 'readable' + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { $Path -like '*page=2' -and $Method -eq 'GET' } + } + finally { Reset-CostMgScope } + } + } + } + + Context 'Savings and unit totals' { + It 'Completes pages and discards failed MG data (fallback: )' -ForEach @( + @{ Scan = 'Savings'; UseFallback = $false } + @{ Scan = 'Savings'; UseFallback = $true } + @{ Scan = 'UnitEconomics'; UseFallback = $false } + @{ Scan = 'UnitEconomics'; UseFallback = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Scan = $Scan; UseFallback = $UseFallback } { + param($Scan, $UseFallback) + + $scanName = $Scan + $failManagementGroup = $UseFallback + Mock Search-AzGraphSafe { [pscustomobject]@{ Data = @() } } + Mock Resolve-CostMgId { 'test-management-group' } + Mock Test-CostMgCoverage { $true } + Mock Get-StorageAccountUsedGb { 0.0 } + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + $isNextPage = $Path -like '*page=2' + if ($failManagementGroup -and $request.type -eq 'AmortizedCost' -and $Path -like '/providers/Microsoft.Management/*' -and $isNextPage) { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $amount = if ($isNextPage) { 25.0 } else { 100.0 } + $dimension = if ($scanName -eq 'UnitEconomics') { 'MeterCategory' } elseif ($request.type -eq 'ActualCost') { 'ChargeType' } else { 'PricingModel' } + $category = switch ($dimension) { + 'MeterCategory' { if ($isNextPage) { 'Storage' } else { 'Virtual Machines' } } + 'ChargeType' { 'UnusedReservation' } + 'PricingModel' { if ($isNextPage) { 'SavingsPlan' } else { 'Reservation' } } + } + $properties = @{ + columns = @(@{ name = 'Cost' }, @{ name = $dimension }, @{ name = 'Currency' }) + rows = @(, @($amount, $category, 'USD')) + } + if (-not $isNextPage) { $properties.nextLink = "$Path&page=2" } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 10) } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'first' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'second' } + ) + + $factor = if ($UseFallback) { 2 } else { 1 } + if ($Scan -eq 'Savings') { + $result = Get-SavingsRealized -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -WarningAction SilentlyContinue + $result.CommittedAmortized | Should -Be (125 * $factor) + $result.RISavingsMonthToDate | Should -Be ([math]::Round(100 * $factor * 0.4 / 0.6, 2)) + $result.SPSavingsMonthToDate | Should -Be ([math]::Round(25 * $factor * 0.25 / 0.75, 2)) + $result.Currency | Should -Be 'USD' + $result.TotalAnnual | Should -BeNullOrEmpty + $waste = @($result.Details | Where-Object Type -EQ 'Waste') + ($waste | Measure-Object -Property Amount -Sum).Sum | Should -Be (125 * $factor) + $continuationCalls = if ($UseFallback) { 6 } else { 2 } + } + else { + $result = Get-UnitEconomics -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -WarningAction SilentlyContinue + $result.ComputeCost | Should -Be (100 * $factor) + $result.StorageCost | Should -Be (25 * $factor) + $result.CostPeriodStartUtc.Kind | Should -Be ([DateTimeKind]::Utc) + $result.CostPeriodEndUtc.Kind | Should -Be ([DateTimeKind]::Utc) + $expectedStart = $result.CostPeriodStartUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + $expectedEnd = $result.CostPeriodEndUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly -ParameterFilter { + $request = $Payload | ConvertFrom-Json + $request.timeframe -ne 'Custom' -or + ([datetime]$request.timePeriod.from).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -ne $expectedStart -or + ([datetime]$request.timePeriod.to).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') -ne $expectedEnd + } + $continuationCalls = if ($UseFallback) { 3 } else { 1 } + } + Should -Invoke Invoke-AzRestMethodWithRetry -Times $continuationCalls -Exactly -ParameterFilter { + $Path -like '*page=2' -and $Method -eq 'POST' -and -not [string]::IsNullOrWhiteSpace($Payload) + } + } + } + } + + Context 'Root-level nextLink' { + # The Consumption and benefit list APIs return nextLink at the root, + # while the Cost Management query API nests it under properties. + BeforeAll { + $script:RootLinkResponse = [PSCustomObject]@{ + StatusCode = 200 + Content = (@{ value = @(1); nextLink = 'https://management.azure.com/next?page=2' } | ConvertTo-Json) + } + } + + It 'Follows it when RootNextLink is requested' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + [PSCustomObject]@{ StatusCode = 200; Content = (@{ value = @(2) } | ConvertTo-Json) } + } + + $pages = @(Get-CostQueryResponsePage -FirstResponse $script:RootLinkResponse -RootNextLink) + + $pages.Count | Should -Be 2 + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 1 -Exactly -ParameterFilter { + $Method -eq 'GET' -and [string]::IsNullOrEmpty($Payload) + } + } + + It 'Rejects a list response without RootNextLink rather than missing its continuation' { + { Get-CostQueryResponsePage -FirstResponse $script:RootLinkResponse } | Should -Throw '*missing query rows or columns*' + } + } + + Context 'nextLink validation' { + # nextLink is service-supplied. A relative or malformed value yields an + # empty PathAndQuery rather than throwing, and a foreign host would be + # rewritten onto the ARM host, so both are rejected. + It 'Accepts ' -ForEach @( + @{ Case = 'an absolute ARM url'; Link = 'https://management.azure.com/subscriptions/x/q?api-version=2023-11-01' } + @{ Case = 'a rooted relative path'; Link = '/subscriptions/x/q?api-version=2023-11-01' } + ) { + Resolve-NextLinkPath -NextLink $Link | Should -Not -BeNullOrEmpty + } + + It 'Rejects ' -ForEach @( + @{ Case = 'a foreign host'; Link = 'https://evil.example.com/steal?a=1' } + @{ Case = 'a non-https scheme'; Link = 'http://management.azure.com/x' } + @{ Case = 'a malformed value'; Link = 'not a url' } + @{ Case = 'a protocol-relative URL'; Link = '//example.com/page2' } + @{ Case = 'a backslash path'; Link = '/\example.com/page2' } + @{ Case = 'an empty value'; Link = '' } + @{ Case = 'a null value'; Link = $null } + ) { + Resolve-NextLinkPath -NextLink $Link | Should -BeNullOrEmpty + } + + It 'Strips the host so the request stays on the ARM endpoint' { + Resolve-NextLinkPath -NextLink 'https://management.azure.com/subscriptions/x/q?a=1' | + Should -Be '/subscriptions/x/q?a=1' + } + } +} diff --git a/src/powershell/Tests/Unit/CurrencyLabel.Tests.ps1 b/src/powershell/Tests/Unit/CurrencyLabel.Tests.ps1 new file mode 100644 index 000000000..c25da0279 --- /dev/null +++ b/src/powershell/Tests/Unit/CurrencyLabel.Tests.ps1 @@ -0,0 +1,123 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Mixed currency detection' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' + Import-Module $script:MultitoolModule -Force + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Advisor savings totals' { + It 'Keeps evidence without combining currencies' -ForEach @( + @{ Scan = 'Get-OptimizationAdvice'; Case = 'matching'; SecondCurrency = 'USD'; Comparable = $true } + @{ Scan = 'Get-OptimizationAdvice'; Case = 'mixed'; SecondCurrency = 'EUR'; Comparable = $false } + @{ Scan = 'Get-OptimizationAdvice'; Case = 'missing'; SecondCurrency = ''; Comparable = $false } + @{ Scan = 'Get-ReservationAdvice'; Case = 'matching'; SecondCurrency = 'USD'; Comparable = $true } + @{ Scan = 'Get-ReservationAdvice'; Case = 'mixed'; SecondCurrency = 'EUR'; Comparable = $false } + @{ Scan = 'Get-ReservationAdvice'; Case = 'missing'; SecondCurrency = ''; Comparable = $false } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Scan = $Scan; SecondCurrency = $SecondCurrency; Comparable = $Comparable } { + param($Scan, $SecondCurrency, $Comparable) + $fixtureCurrency = $SecondCurrency + Mock Search-AzGraphSafe { + @{ Data = @( + [pscustomobject]@{ subscriptionId = '11111111-1111-1111-1111-111111111111'; shortDescriptionProblem = 'Resize VM'; shortDescriptionSolution = 'Use a smaller VM'; impact = 'High'; impactedValue = 'first'; annualSavings = 100; savingsCurrency = 'USD' } + [pscustomobject]@{ subscriptionId = '22222222-2222-2222-2222-222222222222'; shortDescriptionProblem = 'Resize VM'; shortDescriptionSolution = 'Use a smaller VM'; impact = 'High'; impactedValue = 'second'; annualSavings = 50; savingsCurrency = $fixtureCurrency } + ) } + } + Mock Invoke-AzRestMethodWithRetry { [pscustomobject]@{ StatusCode = 200; Content = '{"value":[]}' } } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'First' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Second' } + ) + + $result = & $Scan -Subscriptions $subscriptions + + $recommendations = if ($Scan -eq 'Get-OptimizationAdvice') { $result.Recommendations } else { $result.AdvisorRecommendations } + @($recommendations).Count | Should -Be 2 + $recommendations[0].AnnualSavings | Should -Be 100 + if ($Comparable) { + $result.EstimatedAnnualSavings | Should -Be 150 + $result.Currency | Should -Be 'USD' + if ($Scan -eq 'Get-OptimizationAdvice') { $result.ByCategory[0].TotalSavings | Should -Be 150 } + } + else { + $result.EstimatedAnnualSavings | Should -BeNullOrEmpty + if ($Scan -eq 'Get-OptimizationAdvice') { $result.ByCategory[0].TotalSavings | Should -BeNullOrEmpty } + $result.CostIssue | Should -Match 'currenc' + $result.Summary | Should -Not -Match '\$150|150.*savings' + } + } + } + } + + Context 'Resolve-CurrencyLabel' { + It 'Falls back when nothing was recorded' { + Resolve-CurrencyLabel -Seen @{} | Should -Be 'USD' + } + + It 'Honors an explicit fallback' { + Resolve-CurrencyLabel -Seen @{} -Fallback 'EUR' | Should -Be 'EUR' + } + + It 'Reports the currency when only one was seen' { + $seen = @{} + Add-CurrencySeen -Seen $seen -Currency 'USD' + Resolve-CurrencyLabel -Seen $seen | Should -Be 'USD' + } + + It 'Reports Mixed when several were seen' { + $seen = @{} + Add-CurrencySeen -Seen $seen -Currency 'USD' + Add-CurrencySeen -Seen $seen -Currency 'EUR' + Resolve-CurrencyLabel -Seen $seen | Should -Be 'Mixed' + } + } + + Context 'Add-CurrencySeen' { + It 'Treats casing and padding as the same currency' { + $seen = @{} + Add-CurrencySeen -Seen $seen -Currency 'usd' + Add-CurrencySeen -Seen $seen -Currency 'USD' + Add-CurrencySeen -Seen $seen -Currency ' Usd ' + @($seen.Keys).Count | Should -Be 1 + Test-CurrencyMixed -Seen $seen | Should -BeFalse + } + + It 'Ignores a null or blank currency rather than counting it' { + $seen = @{} + Add-CurrencySeen -Seen $seen -Currency $null + Add-CurrencySeen -Seen $seen -Currency '' + Add-CurrencySeen -Seen $seen -Currency ' ' + @($seen.Keys).Count | Should -Be 0 + } + } + + Context 'Test-CurrencyMixed' { + It 'Is false for ' -ForEach @( + @{ Case = 'no currencies'; Currencies = @() } + @{ Case = 'one currency'; Currencies = @('USD') } + @{ Case = 'one currency repeated'; Currencies = @('USD', 'USD', 'usd') } + ) { + $seen = @{} + foreach ($c in $Currencies) { Add-CurrencySeen -Seen $seen -Currency $c } + Test-CurrencyMixed -Seen $seen | Should -BeFalse + } + + It 'Is true when a tenant bills in more than one currency' { + $seen = @{} + foreach ($c in @('USD', 'EUR', 'GBP')) { Add-CurrencySeen -Seen $seen -Currency $c } + Test-CurrencyMixed -Seen $seen | Should -BeTrue + Resolve-CurrencyLabel -Seen $seen | Should -Be 'Mixed' + } + } +} diff --git a/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 b/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 new file mode 100644 index 000000000..df97c0bff --- /dev/null +++ b/src/powershell/Tests/Unit/FOHubProvider.Tests.ps1 @@ -0,0 +1,673 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'FinOps Hub Kusto provider' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' + Import-Module $script:MultitoolModule -Force + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Storage lookup diagnostics' { + It 'Classifies as without a data-plane probe' -Tag 'DeferredReview' -ForEach @( + @{ Message = 'The request timed out.'; Blocker = 'LookupFailed' } + @{ Message = 'ResourceNotFound HTTP 404'; Blocker = 'LookupFailed' } + @{ Message = 'AuthorizationFailed HTTP 403'; Blocker = 'NoRbac' } + @{ Message = 'AuthenticationFailed HTTP 401'; Blocker = 'AuthenticationFailed' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Message = $Message; Blocker = $Blocker } { + param($Message, $Blocker) + $lookupMessage = $Message + Mock Get-AzStorageAccount { throw $lookupMessage } + Mock New-AzStorageContext { throw 'A data-plane probe must not run after a failed lookup.' } + + $result = Test-HubStorageAccess -StorageAccountName 'examplestorage' -ResourceGroupName 'example-group' + + $result.Readable | Should -BeFalse + $result.Blocker | Should -Be $Blocker + $result.Detail | Should -Match ([regex]::Escape($Message)) + if ($Blocker -ne 'NoRbac') { $result.Remediation | Should -Not -Match '^Grant' } + Should -Invoke New-AzStorageContext -Times 0 -Exactly + } + } + } + + Context 'Unknown Hub coverage' { + It 'Does not turn unreadable subscription metadata into complete coverage' { + InModuleScope FinOpsMultitool { + Mock Search-AzGraphSafe { + if ($Query -like '*microsoft.storage/storageaccounts*') { + return @{ Data = @([pscustomobject]@{ name = 'fixture'; resourceGroup = 'fixture'; subscriptionId = '11111111-1111-1111-1111-111111111111'; location = 'eastus' }) } + } + @{ Data = @([pscustomobject]@{ c = 0 }) } + } + Mock Get-HubKustoCluster { $null } + Mock Test-HubStorageAccess { @{ Readable = $true; Context = [pscustomobject]@{ Synthetic = $true } } } + Mock Get-HubCoverage { @{ Subs = @(); Freshness = $null } } + + $result = Resolve-CostDataSource -RequestedSubscriptionIds @('11111111-1111-1111-1111-111111111111') + + $result.HubFound | Should -BeTrue + $result.CoveragePct | Should -BeNullOrEmpty + $result.CoverageKnown | Should -BeFalse + $result.Message | Should -Match 'could not be confirmed' + } + } + } + + Context 'Direct provider transport' { + It 'Maps named columns, null cells, and numeric credits while preserving the request contract' -Tag 'ProviderTransport' { + InModuleScope FinOpsMultitool { + Mock Invoke-RestMethod { + '{"Tables":[{"TableName":"PrimaryResult","Columns":[{"ColumnName":"Currency"},{"ColumnName":"Actual"},{"ColumnName":"Label"}],"Rows":[["USD",0,null],["USD",-12.5,"credit"]]},{"TableName":"QueryStatus","Columns":[{"ColumnName":"Severity"},{"ColumnName":"StatusDescription"}],"Rows":[[4,"Query completed successfully"]]}]}' | ConvertFrom-Json + } + + $result = Invoke-FOHubKustoQuery -ClusterUri 'https://fixture.eastus.kusto.windows.net/' -Database 'Fixture Hub' -Query 'print Actual=0' -AccessToken 'synthetic-token' -TimeoutSec 17 + + $result.Ok | Should -BeTrue + $result.RowCount | Should -Be 2 + $result.Rows[0].Actual | Should -Be 0 + $result.Rows[0].Label | Should -BeNullOrEmpty + $result.Rows[1].Actual | Should -Be -12.5 + $result.Rows[1].Currency | Should -Be 'USD' + Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $request = $Body | ConvertFrom-Json + $Uri -eq 'https://fixture.eastus.kusto.windows.net/v1/rest/query' -and + $Method -eq 'Post' -and $TimeoutSec -eq 17 -and $MaximumRedirection -eq 0 -and + $Headers.Authorization -eq 'Bearer synthetic-token' -and + $request.db -eq 'Fixture Hub' -and $request.csl -eq 'print Actual=0' + } + } + } + + It 'Keeps a valid zero-row table as successful empty data' -Tag 'ProviderTransport' { + InModuleScope FinOpsMultitool { + Mock Invoke-RestMethod { '{"Tables":[{"TableName":"PrimaryResult","Columns":[{"ColumnName":"Actual"}],"Rows":[]}]}' | ConvertFrom-Json } + + $result = Invoke-FOHubKustoQuery -ClusterUri 'http://localhost:8082' -Query 'print Actual=0 | take 0' + + $result.Ok | Should -BeTrue + $result.RowCount | Should -Be 0 + @($result.Rows).Count | Should -Be 0 + $result.Error | Should -BeNullOrEmpty + } + } + + It 'Uses v1 status with omitted metadata column ' -Tag 'ProviderTransport', 'ProviderReviewFollowup' -ForEach @( + @{ Severity = 4; ExpectedSuccess = $true; OmitColumn = $null; FailurePattern = 'partial query failure' } + @{ Severity = 2; ExpectedSuccess = $false; OmitColumn = $null; FailurePattern = 'partial query failure' } + @{ Severity = 1; ExpectedSuccess = $false; OmitColumn = $null; FailurePattern = 'partial query failure' } + @{ Severity = 4; ExpectedSuccess = $false; OmitColumn = 'Name'; FailurePattern = 'incomplete table-of-contents schema' } + @{ Severity = 4; ExpectedSuccess = $false; OmitColumn = 'Kind'; FailurePattern = 'incomplete table-of-contents schema' } + @{ Severity = 4; ExpectedSuccess = $false; OmitColumn = 'Ordinal'; FailurePattern = 'incomplete table-of-contents schema' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Severity = $Severity; ExpectedSuccess = $ExpectedSuccess; OmitColumn = $OmitColumn; FailurePattern = $FailurePattern } { + param($Severity, $ExpectedSuccess, $OmitColumn, $FailurePattern) + $fixtureResponse = @' +{"Tables":[ + {"TableName":"Table_0","Columns":[{"ColumnName":"Actual"}],"Rows":[[10]]}, + {"TableName":"Table_1","Columns":[{"ColumnName":"Value"}],"Rows":[["{}"]]}, + {"TableName":"Table_2","Columns":[{"ColumnName":"Severity"},{"ColumnName":"StatusDescription"}],"Rows":[[4,"Fixture query status"]]}, + {"TableName":"Table_3","Columns":[{"ColumnName":"Ordinal"},{"ColumnName":"Kind"},{"ColumnName":"Name"},{"ColumnName":"Id"},{"ColumnName":"PrettyName"}],"Rows":[[0,"QueryResult","PrimaryResult","a",""],[1,"QueryProperties","@ExtendedProperties","b",""],[2,"QueryStatus","QueryStatus","c",""]]} +]} +'@ | ConvertFrom-Json + $fixtureResponse.Tables[2].Rows[0][0] = $Severity + if ($OmitColumn) { + $contents = $fixtureResponse.Tables[3] + $removedIndex = [array]::IndexOf(@($contents.Columns.ColumnName), $OmitColumn) + $keptIndexes = @(0..($contents.Columns.Count - 1) | Where-Object { $_ -ne $removedIndex }) + $contents.Columns = @($contents.Columns[$keptIndexes]) + $contents.Rows = @(foreach ($row in $contents.Rows) { , @($row[$keptIndexes]) }) + } + Mock Invoke-RestMethod { $fixtureResponse } + + $result = Invoke-FOHubKustoQuery -ClusterUri 'http://localhost:8082' -Query 'print Actual=10' + + $result.Ok | Should -Be $ExpectedSuccess + if ($ExpectedSuccess) { + $result.RowCount | Should -Be 1 + $result.Rows[0].Actual | Should -Be 10 + } + else { + $result.RowCount | Should -Be 0 + @($result.Rows).Count | Should -Be 0 + $result.Error | Should -Match $FailurePattern + } + } + } + + It 'Rejects without returning partial rows' -Tag 'ProviderTransport' -ForEach @( + @{ Case = 'null response'; Response = 'null' } + @{ Case = 'missing tables'; Response = '{}' } + @{ Case = 'non-array tables'; Response = '{"Tables":{}}' } + @{ Case = 'null table'; Response = '{"Tables":[null]}' } + @{ Case = 'missing columns'; Response = '{"Tables":[{"Rows":[[10]]}]}' } + @{ Case = 'missing rows'; Response = '{"Tables":[{"Columns":[{"ColumnName":"Actual"}]}]}' } + @{ Case = 'blank column name'; Response = '{"Tables":[{"Columns":[{"ColumnName":" "}],"Rows":[[10]]}]}' } + @{ Case = 'case-colliding columns'; Response = '{"Tables":[{"Columns":[{"ColumnName":"Cost"},{"ColumnName":"cost"}],"Rows":[[10,20]]}]}' } + @{ Case = 'array-valued column name'; Response = '{"Tables":[{"Columns":[{"ColumnName":["Actual","Currency"]}],"Rows":[[10,"USD"]]}]}' } + @{ Case = 'empty-array column name'; Response = '{"Tables":[{"Columns":[{"ColumnName":[]}],"Rows":[[]]}]}' } + @{ Case = 'invalid status reference'; Response = '{"Tables":[{"TableName":"Table_0","Columns":[{"ColumnName":"Actual"}],"Rows":[[10]]},{"TableName":"Table_1","Columns":[{"ColumnName":"Ordinal"},{"ColumnName":"Kind"},{"ColumnName":"Name"}],"Rows":[[9,"QueryStatus","QueryStatus"]]}]}' } + @{ Case = 'non-scalar table name'; Response = '{"Tables":[{"TableName":["Table_0"],"Columns":[{"ColumnName":"Actual"}],"Rows":[[10]]}]}' } + @{ Case = 'non-scalar status kind'; Response = '{"Tables":[{"TableName":"Table_0","Columns":[{"ColumnName":"Actual"}],"Rows":[[10]]},{"TableName":"Table_1","Columns":[{"ColumnName":"Ordinal"},{"ColumnName":"Kind"},{"ColumnName":"Name"}],"Rows":[[0,["QueryStatus","Ignored"],"QueryStatus"]]}]}' } + @{ Case = 'short row after valid data'; Response = '{"Tables":[{"Columns":[{"ColumnName":"Actual"},{"ColumnName":"Currency"}],"Rows":[[10,"USD"],[20]]}]}' } + @{ Case = 'long row'; Response = '{"Tables":[{"Columns":[{"ColumnName":"Actual"}],"Rows":[[10,20]]}]}' } + @{ Case = 'scalar row'; Response = '{"Tables":[{"Columns":[{"ColumnName":"Actual"}],"Rows":[10]}]}' } + @{ Case = 'partial query failure'; Response = '{"Tables":[{"TableName":"PrimaryResult","Columns":[{"ColumnName":"Actual"}],"Rows":[[10]]},{"TableName":"QueryStatus","Columns":[{"ColumnName":"Severity"},{"ColumnName":"StatusDescription"}],"Rows":[[2,"Partial query failure"]]}]}' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Response = $Response } { + param($Response) + $fixtureResponse = $Response | ConvertFrom-Json + Mock Invoke-RestMethod { $fixtureResponse } + + $result = Invoke-FOHubKustoQuery -ClusterUri 'http://localhost:8082' -Query 'print Actual=10' + + $result.Ok | Should -BeFalse + $result.RowCount | Should -Be 0 + @($result.Rows).Count | Should -Be 0 + $result.Error | Should -Not -BeNullOrEmpty + } + } + + It 'Returns a transport error without success-shaped data' -Tag 'ProviderTransport' { + InModuleScope FinOpsMultitool { + Mock Invoke-RestMethod { throw 'Synthetic transport failure.' } + + $result = Invoke-FOHubKustoQuery -ClusterUri 'http://localhost:8082' -Query 'print Actual=10' + + $result.Ok | Should -BeFalse + $result.RowCount | Should -Be 0 + @($result.Rows).Count | Should -Be 0 + $result.Error | Should -Match 'Synthetic transport failure' + } + } + + It 'Reads PowerShell 7 Kusto error details from ' -Tag 'ProviderTransport' -ForEach @( + @{ Field = '@message' } + @{ Field = 'message' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Field = $Field } { + param($Field) + $fixtureError = [Management.Automation.ErrorRecord]::new([InvalidOperationException]::new('HTTP 400 Bad Request'), 'FixtureHttpError', [Management.Automation.ErrorCategory]::InvalidOperation, $null) + $fixtureError.ErrorDetails = [Management.Automation.ErrorDetails]::new((@{ error = @{ $Field = 'Synthetic Kusto query could not resolve Costs.' } } | ConvertTo-Json)) + Mock Invoke-RestMethod { throw $fixtureError } + + $result = Invoke-FOHubKustoQuery -ClusterUri 'http://localhost:8082' -Query 'Costs | take 1' + + $result.Ok | Should -BeFalse + $result.Error | Should -Be 'Kusto query failed: Synthetic Kusto query could not resolve Costs.' + @($result.Rows).Count | Should -Be 0 + } + } + + It 'Stops before transport when provider token acquisition fails' -Tag 'ProviderTransport' { + InModuleScope FinOpsMultitool { + Mock Get-PlainAccessToken { throw 'Synthetic authentication failure.' } + Mock Invoke-FOHubKustoQuery { throw 'Transport must not run after authentication fails.' } + + $result = Invoke-FOHubProviderQuery -Provider @{ ClusterUri = 'https://fixture.eastus.kusto.windows.net'; Database = 'Hub'; UseAuth = $true } -Query 'print Actual=10' + + $result.Ok | Should -BeFalse + $result.RowCount | Should -Be 0 + @($result.Rows).Count | Should -Be 0 + $result.Error | Should -Match 'Could not acquire a Kusto token.*Synthetic authentication failure' + Should -Invoke Invoke-FOHubKustoQuery -Times 0 -Exactly + } + } + + It 'Rejects an unusable provider token for before transport' -Tag 'ProviderTransport' -ForEach @( + @{ Case = 'null'; Token = $null } + @{ Case = 'empty'; Token = '' } + @{ Case = 'whitespace'; Token = ' ' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Token = $Token } { + param($Token) + $fixtureToken = $Token + Mock Get-PlainAccessToken { $fixtureToken } + Mock Invoke-FOHubKustoQuery { throw 'An authenticated provider must not send an anonymous request.' } + + $result = Invoke-FOHubProviderQuery -Provider @{ ClusterUri = 'https://fixture.eastus.kusto.windows.net'; Database = 'Hub'; UseAuth = $true } -Query 'print Actual=10' + + $result.Ok | Should -BeFalse + @($result.Rows).Count | Should -Be 0 + $result.Error | Should -Match 'token' + Should -Invoke Invoke-FOHubKustoQuery -Times 0 -Exactly + } + } + + It 'Makes shared token acquisition fail for ' -Tag 'ProviderTransport' -ForEach @( + @{ Case = 'nonterminating authentication error'; Nonterminating = $true } + @{ Case = 'empty token result'; Nonterminating = $false } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Nonterminating = $Nonterminating } { + param($Nonterminating) + $fixtureNonterminating = $Nonterminating + Mock Get-AzAccessToken { + if ($fixtureNonterminating) { + Write-Error 'Synthetic authentication failure.' + return [pscustomobject]@{ Token = 'must-not-be-returned' } + } + [pscustomobject]@{ Token = $null } + } + + { Get-PlainAccessToken -ResourceUrl 'https://fixture.eastus.kusto.windows.net' } | Should -Throw + Should -Invoke Get-AzAccessToken -Times 1 -Exactly -ParameterFilter { $ErrorAction -eq 'Stop' } + } + } + + It 'Retains valid plain and secure token values' -Tag 'ProviderTransport' { + InModuleScope FinOpsMultitool { + Mock Get-AzAccessToken { [pscustomobject]@{ Token = 'synthetic-token' } } + Get-PlainAccessToken -ResourceUrl 'https://fixture.eastus.kusto.windows.net' | Should -Be 'synthetic-token' + $fixtureSecureToken = [securestring]::new() + try { + foreach ($character in 'synthetic-token'.ToCharArray()) { $fixtureSecureToken.AppendChar($character) } + $fixtureSecureToken.MakeReadOnly() + Mock Get-AzAccessToken { [pscustomobject]@{ Token = $fixtureSecureToken } } + Get-PlainAccessToken -ResourceUrl 'https://fixture.eastus.kusto.windows.net' | Should -Be 'synthetic-token' + } + finally { $fixtureSecureToken.Dispose() } + } + } + + It 'Builds literal tag filters for quotes and trailing backslashes' -Tag 'ProviderTransport' { + InModuleScope FinOpsMultitool { + Mock Invoke-FOHubKustoQuery { + @{ Ok = $true; Rows = @( + [pscustomobject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 1; _SourceRows = 1; _MissingSubscriptions = 0 } + [pscustomobject]@{ TagKey = '*TOTAL*'; TagValue = '*TOTAL*'; Cost = 10; Currency = 'USD' } + ) } + } + + $null = Get-FOHubCostByTag -Provider @{ ClusterUri = 'http://localhost:8082'; Database = 'Hub'; UseAuth = $false } -TagKeys @('owner"label', 'path\', 'both\"tail') + + Should -Invoke Invoke-FOHubKustoQuery -Times 1 -Exactly -ParameterFilter { + $Query.Contains('| where k in~ ("owner\"label", "path\\", "both\\\"tail")') + } + } + } + + It 'Normalizes scope GUIDs and rejects an invalid mixed scope before transport' -Tag 'ProviderTransport' { + InModuleScope FinOpsMultitool { + Mock Invoke-FOHubKustoQuery { throw 'Invalid scope must not reach transport.' } + + Get-FOHubScopeClause -SubscriptionIds @('AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA') | Should -Be '| where SubAccountId has_any (dynamic(["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"]))' + { Get-FOHubCostSummary -Provider @{ ClusterUri = 'http://localhost:8082'; Database = 'Hub'; UseAuth = $false } -SubscriptionIds @('11111111-1111-1111-1111-111111111111', 'invalid"scope') } | Should -Throw '*Refusing to drop the scope filter*' + Should -Invoke Invoke-FOHubKustoQuery -Times 0 -Exactly + } + } + + It 'Rejects the invalid explicit override before discovery or authentication' -Tag 'ProviderTransport' -ForEach @( + @{ Endpoint = 'not a URI' } + @{ Endpoint = 'http://example.test' } + @{ Endpoint = 'https://fixture.eastus.kusto.windows.net/?unexpected=true' } + @{ Endpoint = 'https://user:password@example.test' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Endpoint = $Endpoint } { + param($Endpoint) + $previousUri = $env:FINOPS_HUB_KUSTO_URI + try { + $env:FINOPS_HUB_KUSTO_URI = $Endpoint + Mock Search-AzGraphSafe { throw 'Invalid override must not trigger discovery.' } + Mock Get-PlainAccessToken { throw 'Invalid override must not acquire a token.' } + + { Resolve-FOHubProvider } | Should -Throw + + Should -Invoke Search-AzGraphSafe -Times 0 -Exactly + Should -Invoke Get-PlainAccessToken -Times 0 -Exactly + } + finally { $env:FINOPS_HUB_KUSTO_URI = $previousUri } + } + } + } + + Context 'Endpoint security' { + It 'Rejects unsafe endpoint before requesting a token or sending a request' -ForEach @( + @{ Endpoint = 'http://example.test' } + @{ Endpoint = 'http://127.0.0.1:8082' } + @{ Endpoint = 'https://user:password@example.test' } + @{ Endpoint = 'http://localhost:8082@example.test' } + @{ Endpoint = 'https://example.test/#fragment' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Endpoint = $Endpoint } { + param($Endpoint) + Mock Get-AzAccessToken { throw 'Token acquisition must not occur.' } + Mock Invoke-RestMethod { throw 'Network I/O must not occur.' } + + { Get-PlainAccessToken -ResourceUrl $Endpoint } | Should -Throw + { Invoke-StorageBlobRest -Uri $Endpoint -StorageToken 'synthetic' } | Should -Throw + { Get-StorageBlobBytes -Uri $Endpoint -StorageToken 'synthetic' } | Should -Throw + (Invoke-FOHubKustoQuery -ClusterUri $Endpoint -Query 'print synthetic=1' -AccessToken 'synthetic').Ok | Should -BeFalse + (Invoke-FOHubProviderQuery -Provider @{ ClusterUri = $Endpoint; UseAuth = $true; Database = 'Hub' } -Query 'print synthetic=1').Ok | Should -BeFalse + Should -Invoke Get-AzAccessToken -Times 0 -Exactly + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + } + } + + It 'Allows token-free loopback Kusto requests without following redirects' { + InModuleScope FinOpsMultitool { + Mock Invoke-RestMethod { @{ Tables = @() } } + + (Invoke-FOHubKustoQuery -ClusterUri 'http://127.0.0.1:8082' -Query 'print synthetic=1').Ok | Should -BeTrue + + Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $MaximumRedirection -eq 0 -and -not $Headers.ContainsKey('Authorization') + } + } + } + + It 'Accepts HTTPS storage endpoints without following redirects' { + InModuleScope FinOpsMultitool { + Mock Invoke-RestMethod { 'synthetic response' } + Invoke-StorageBlobRest -Uri 'https://fixture.blob.core.windows.net/container?comp=list' -StorageToken 'synthetic' | Should -Be 'synthetic response' + Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { $MaximumRedirection -eq 0 } + } + } + } + + Context 'Resolve-FOHubProvider - explicit override' { + AfterEach { + Remove-Item Env:FINOPS_HUB_KUSTO_URI -ErrorAction SilentlyContinue + Remove-Item Env:FINOPS_HUB_KUSTO_DB -ErrorAction SilentlyContinue + } + + It 'Resolves a localhost override to KustoLocal with no auth' { + $env:FINOPS_HUB_KUSTO_URI = 'http://localhost:8082' + $p = Resolve-FOHubProvider + $p.Found | Should -BeTrue + $p.Mode | Should -Be 'KustoLocal' + $p.UseAuth | Should -BeFalse + $p.Database | Should -Be 'Hub' + $p.Source | Should -Be 'EnvOverride' + } + + It 'Resolves a remote cluster override to Kusto with auth' { + $env:FINOPS_HUB_KUSTO_URI = 'https://myhub.eastus.kusto.windows.net' + $p = Resolve-FOHubProvider + $p.Found | Should -BeTrue + $p.Mode | Should -Be 'Kusto' + $p.UseAuth | Should -BeTrue + } + + It 'Honors a custom database name' { + $env:FINOPS_HUB_KUSTO_URI = 'http://localhost:8082' + $env:FINOPS_HUB_KUSTO_DB = 'CustomHub' + $p = Resolve-FOHubProvider + $p.Database | Should -Be 'CustomHub' + } + } + + Context 'Resolve-FOHubProvider - discovery and none' { + It 'Distinguishes discovery from an empty successful lookup' -Tag 'ProviderDiscovery' -ForEach @( + @{ Outcome = 'exception'; ExpectedWarnings = 1 } + @{ Outcome = 'null response'; ExpectedWarnings = 1 } + @{ Outcome = 'empty success'; ExpectedWarnings = 0 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Outcome = $Outcome; ExpectedWarnings = $ExpectedWarnings } { + param($Outcome, $ExpectedWarnings) + $previousUri = $env:FINOPS_HUB_KUSTO_URI + try { + $env:FINOPS_HUB_KUSTO_URI = $null + $fixtureOutcome = $Outcome + Mock Search-AzGraphSafe { + if ($fixtureOutcome -eq 'exception') { throw "Denied$([char]27)[2J$([char]0x202e)`r`nquery" } + if ($fixtureOutcome -eq 'null response') { return $null } + @{ Data = @() } + } + + $result = Resolve-FOHubProvider -Subscriptions @('11111111-1111-1111-1111-111111111111') -WarningAction SilentlyContinue -WarningVariable warnings + + $result.Found | Should -BeFalse + $result.Mode | Should -Be 'None' + @($warnings).Count | Should -Be $ExpectedWarnings + if ($ExpectedWarnings) { + ($warnings -join '') | Should -Match 'Kusto.*could not be verified' + ($warnings -join '') | Should -Not -Match '[\p{Cc}\p{Cf}]' + } + Should -Invoke Search-AzGraphSafe -Times 1 -Exactly -ParameterFilter { + @($Subscription).Count -eq 1 -and $Subscription[0] -eq '11111111-1111-1111-1111-111111111111' -and $First -eq 1 + } + } + finally { $env:FINOPS_HUB_KUSTO_URI = $previousUri } + } + } + + It 'Uses a cluster already discovered on the decision object' { + $decision = [PSCustomObject]@{ KustoClusterUri = 'https://disc.westus.kusto.windows.net'; KustoDatabase = 'Hub'; HubVersion = '0.10' } + $p = Resolve-FOHubProvider -Decision $decision + $p.Found | Should -BeTrue + $p.Mode | Should -Be 'Kusto' + $p.ClusterUri | Should -Be 'https://disc.westus.kusto.windows.net' + $p.Source | Should -Be 'Discovered' + } + + It 'Returns None when no override and discovery finds nothing' { + InModuleScope FinOpsMultitool { + Mock Search-AzGraphSafe { @{ Data = @() } } + $p = Resolve-FOHubProvider -Subscriptions @('00000000-0000-0000-0000-000000000000') + $p.Found | Should -BeFalse + $p.Mode | Should -Be 'None' + } + } + + It 'Discovers a cluster directly when no decision is passed (TUI path)' { + InModuleScope FinOpsMultitool { + Mock Search-AzGraphSafe { + @{ Data = @( + [PSCustomObject]@{ clusterUri = 'https://tui.eastus.kusto.windows.net'; hubVersion = '0.11'; resourceGroup = 'rg-hub'; subscriptionId = '1' } + ) } + } + $p = Resolve-FOHubProvider -Subscriptions @('1') + $p.Found | Should -BeTrue + $p.Mode | Should -Be 'Kusto' + $p.UseAuth | Should -BeTrue + $p.ClusterUri | Should -Be 'https://tui.eastus.kusto.windows.net' + $p.Source | Should -Be 'Discovered' + } + } + } + + Context 'Get-HubKustoCluster - online discovery' { + It 'Discovers a FinOps hub Kusto cluster via Resource Graph' { + InModuleScope FinOpsMultitool { + Mock Search-AzGraphSafe { + @{ Data = @( + [PSCustomObject]@{ clusterUri = 'https://ftk.eastus.kusto.windows.net'; hubVersion = '0.11'; resourceGroup = 'rg-hub'; subscriptionId = '11111111-1111-1111-1111-111111111111' } + ) } + } + $c = Get-HubKustoCluster -RequestedSubscriptionIds @('11111111-1111-1111-1111-111111111111') + $c | Should -Not -BeNullOrEmpty + $c.ClusterUri | Should -Be 'https://ftk.eastus.kusto.windows.net' + $c.HubVersion | Should -Be '0.11' + } + } + + It 'Prefers the cluster in the hub resource group' { + InModuleScope FinOpsMultitool { + Mock Search-AzGraphSafe { + @{ Data = @( + [PSCustomObject]@{ clusterUri = 'https://other.eastus.kusto.windows.net'; hubVersion = '0.11'; resourceGroup = 'rg-other'; subscriptionId = '1' } + [PSCustomObject]@{ clusterUri = 'https://mine.eastus.kusto.windows.net'; hubVersion = '0.11'; resourceGroup = 'rg-hub'; subscriptionId = '1' } + ) } + } + $c = Get-HubKustoCluster -RequestedSubscriptionIds @('1') -HubResourceGroup 'rg-hub' + $c.ClusterUri | Should -Be 'https://mine.eastus.kusto.windows.net' + } + } + + It 'Returns null when no cluster is found' { + InModuleScope FinOpsMultitool { + Mock Search-AzGraphSafe { @{ Data = @() } } + Get-HubKustoCluster -RequestedSubscriptionIds @('1') | Should -BeNullOrEmpty + } + } + } + + Context 'Get-FOHubCostSummary shape' { + It 'Produces a per-subscription cost map matching the converter shape' { + InModuleScope FinOpsMultitool { + Mock Invoke-FOHubKustoQuery { + @{ + Ok = $true + RowCount = 2 + Error = $null + Rows = @( + [PSCustomObject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 1; _SourceRows = 2; _MissingSubscriptions = 0 } + [PSCustomObject]@{ _sub = 'aaaaaaaa-1111-2222-3333-444444444444'; Actual = 123.456; Currency = 'USD' } + [PSCustomObject]@{ _sub = 'bbbbbbbb-1111-2222-3333-444444444444'; Actual = 10.0; Currency = 'USD' } + ) + } + } + $prov = @{ Found = $true; Mode = 'KustoLocal'; ClusterUri = 'http://localhost:8082'; Database = 'Hub'; UseAuth = $false } + $map = Get-FOHubCostSummary -Provider $prov + + $map | Should -BeOfType ([hashtable]) + $map.Keys.Count | Should -Be 2 + $map['aaaaaaaa-1111-2222-3333-444444444444'].Actual | Should -Be 123.46 + $map['aaaaaaaa-1111-2222-3333-444444444444'].Forecast | Should -BeNullOrEmpty + $map['aaaaaaaa-1111-2222-3333-444444444444'].Currency | Should -Be 'USD' + $map['bbbbbbbb-1111-2222-3333-444444444444'].Currency | Should -Be 'USD' + Should -Invoke Invoke-FOHubKustoQuery -Times 1 -Exactly -ParameterFilter { + $Query.Contains('todouble(BilledCost)') -and -not $Query.Contains('EffectiveCost') -and + $Query.Contains('isnull(_cost) or not(isfinite(_cost))') + } + } + } + + It 'Surfaces a query error instead of throwing' { + InModuleScope FinOpsMultitool { + Mock Invoke-FOHubKustoQuery { @{ Ok = $false; Rows = @(); RowCount = 0; Error = 'boom' } } + $prov = @{ Found = $true; Mode = 'KustoLocal'; ClusterUri = 'http://localhost:8082'; Database = 'Hub'; UseAuth = $false } + $r = Get-FOHubCostSummary -Provider $prov + $r.Error | Should -Be 'boom' + } + } + } + + Context 'Get-FOHubResourceCosts shape' { + It 'Produces sorted resource cost objects with the converter properties' { + InModuleScope FinOpsMultitool { + Mock Invoke-FOHubKustoQuery { + @{ + Ok = $true + RowCount = 2 + Error = $null + Rows = @( + [PSCustomObject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 1; _SourceRows = 2; _MissingSubscriptions = 0 } + [PSCustomObject]@{ Subscription = 'Sub A'; ResourceGroup = 'rg1'; ResourceType = 'Microsoft.Compute/virtualMachines'; ResourcePath = '/subscriptions/x/rg1/vm1'; Actual = 50.0; Currency = 'USD' } + [PSCustomObject]@{ Subscription = 'Sub A'; ResourceGroup = 'rg2'; ResourceType = 'Microsoft.Storage/storageAccounts'; ResourcePath = '/subscriptions/x/rg2/sa1'; Actual = 200.0; Currency = 'USD' } + ) + } + } + $prov = @{ Found = $true; Mode = 'KustoLocal'; ClusterUri = 'http://localhost:8082'; Database = 'Hub'; UseAuth = $false } + $rows = Get-FOHubResourceCosts -Provider $prov + + @($rows).Count | Should -Be 2 + $rows[0].PSObject.Properties.Name | Should -Contain 'ResourcePath' + $rows[0].PSObject.Properties.Name | Should -Contain 'Forecast' + $rows[0].Actual | Should -Be 200.0 + $rows[0].Forecast | Should -BeNullOrEmpty + } + } + } + + Context 'Get-FOHubCostByTag shape' { + It 'Preserves tag value case and negative untagged credits' { + InModuleScope FinOpsMultitool { + Mock Invoke-FOHubKustoQuery { + @{ Ok = $true; Rows = @( + [pscustomobject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 1; _SourceRows = 3; _MissingSubscriptions = 0 } + [pscustomobject]@{ TagKey = '*TOTAL*'; Cost = 100; Currency = 'USD' } + [pscustomobject]@{ TagKey = 'env'; TagValue = 'Prod'; Cost = 50; Currency = 'USD' } + [pscustomobject]@{ TagKey = 'env'; TagValue = 'prod'; Cost = 70; Currency = 'USD' } + ) } + } + $provider = @{ UseAuth = $false; ClusterUri = 'http://localhost:8082'; Database = 'Hub' } + + $result = Get-FOHubCostByTag -Provider $provider -TagKeys @('env') + + @($result.CostByTag.env).Count | Should -Be 3 + ($result.CostByTag.env | Where-Object { $_.TagValue -ceq 'Prod' }).Cost | Should -Be 50 + ($result.CostByTag.env | Where-Object { $_.TagValue -ceq 'prod' }).Cost | Should -Be 70 + ($result.CostByTag.env | Where-Object TagValue -EQ '(untagged)').Cost | Should -Be -20 + ($result.CostByTag.env | Measure-Object Cost -Sum).Sum | Should -Be 100 + } + } + + It 'Validates coverage even when no tags are discovered' { + InModuleScope FinOpsMultitool { + Mock Invoke-FOHubKustoQuery { + if ($Query.Contains('_CostValidation')) { + return @{ Ok = $true; Rows = @([pscustomobject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 0; _SourceRows = 0; _MissingSubscriptions = 1 }) } + } + @{ Ok = $true; Rows = @() } + } + $provider = @{ UseAuth = $false; ClusterUri = 'http://localhost:8082'; Database = 'Hub' } + + $result = Get-FOHubCostByTag -Provider $provider -SubscriptionIds @('44444444-4444-4444-4444-444444444444') + + $result.Error | Should -BeLike '*validation failed*' + } + } + + It 'Derives (untagged) cost per key from the TOTAL sentinel' { + InModuleScope FinOpsMultitool { + Mock Invoke-FOHubKustoQuery { + @{ + Ok = $true + RowCount = 3 + Error = $null + Rows = @( + [PSCustomObject]@{ _CostValidation = $true; _InvalidCosts = 0; _CurrencyCount = 1; _SourceRows = 3; _MissingSubscriptions = 0 } + [PSCustomObject]@{ TagKey = '*TOTAL*'; TagValue = '*TOTAL*'; Cost = 1000.0; Currency = 'USD' } + [PSCustomObject]@{ TagKey = 'env'; TagValue = 'prod'; Cost = 600.0; Currency = 'USD' } + [PSCustomObject]@{ TagKey = 'env'; TagValue = 'dev'; Cost = 300.0; Currency = 'USD' } + ) + } + } + $prov = @{ Found = $true; Mode = 'KustoLocal'; ClusterUri = 'http://localhost:8082'; Database = 'Hub'; UseAuth = $false } + $result = Get-FOHubCostByTag -Provider $prov -TagKeys @('env') + + $result.NoTagsFound | Should -BeFalse + $result.TagsQueried | Should -Contain 'env' + $envEntries = $result.CostByTag['env'] + ($envEntries | Where-Object { $_.TagValue -eq 'prod' }).Cost | Should -Be 600.0 + ($envEntries | Where-Object { $_.TagValue -eq 'dev' }).Cost | Should -Be 300.0 + ($envEntries | Where-Object { $_.TagValue -eq '(untagged)' }).Cost | Should -Be 100.0 + # Sorted descending: prod (600) first. + $envEntries[0].TagValue | Should -Be 'prod' + } + } + } + + It 'Rejects failed whole-scope cost validation ()' -ForEach @( + @{ Case = 'unknown charge date'; InvalidCosts = 1; CurrencyCount = 1; MissingSubscriptions = 0 } + @{ Case = 'invalid amount outside top results'; InvalidCosts = 1; CurrencyCount = 1; MissingSubscriptions = 0 } + @{ Case = 'mixed currencies'; InvalidCosts = 0; CurrencyCount = 2; MissingSubscriptions = 0 } + @{ Case = 'missing selected subscription'; InvalidCosts = 0; CurrencyCount = 1; MissingSubscriptions = 1 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ InvalidCosts = $InvalidCosts; CurrencyCount = $CurrencyCount; MissingSubscriptions = $MissingSubscriptions } { + param($InvalidCosts, $CurrencyCount, $MissingSubscriptions) + $validationRow = [pscustomobject]@{ _CostValidation = $true; _InvalidCosts = $InvalidCosts; _CurrencyCount = $CurrencyCount; _SourceRows = 10; _MissingSubscriptions = $MissingSubscriptions } + Mock Invoke-FOHubKustoQuery { + @{ Ok = $true; Rows = @($validationRow, [pscustomobject]@{ Actual = 100; Currency = 'USD'; _sub = 'aaaaaaaa-1111-2222-3333-444444444444' }) } + } + $provider = @{ UseAuth = $false; ClusterUri = 'http://localhost:8082'; Database = 'Hub' } + (Get-FOHubCostSummary -Provider $provider).Error | Should -BeLike '*validation failed*' + (Get-FOHubResourceCosts -Provider $provider -Top 1).Error | Should -BeLike '*validation failed*' + (Get-FOHubCostByTag -Provider $provider -TagKeys @('env')).Error | Should -BeLike '*validation failed*' + Should -Invoke Invoke-FOHubKustoQuery -Times 3 -Exactly -ParameterFilter { + $Query.Contains('where isnull(ChargePeriodStart) or') -and + $Query.Contains('or isnull(ChargePeriodStart))') + } + } + } +} diff --git a/src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 b/src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 new file mode 100644 index 000000000..9d56a1b05 --- /dev/null +++ b/src/powershell/Tests/Unit/HubSizeProbe.Tests.ps1 @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'FinOps Hub size probe' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:MultitoolModule = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1' + Import-Module $script:MultitoolModule -Force + + function Get-TestItem { + param([string]$Name, [long]$Length, [bool]$IsDirectory = $false) + [PSCustomObject]@{ Name = $Name; Length = $Length; IsDirectory = $IsDirectory } + } + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Format-FinOpsByteSize' { + It 'Formats as ' -ForEach @( + @{ Bytes = 512; Expected = '512 bytes' } + @{ Bytes = 2048; Expected = '2.0 KB' } + @{ Bytes = 3355443; Expected = '3.2 MB' } + @{ Bytes = 268435456; Expected = '256.0 MB' } + @{ Bytes = 44989782425; Expected = '41.9 GB' } + ) { + Format-FinOpsByteSize -Bytes $Bytes | Should -Be $Expected + } + } + + Context 'Test-FinOpsHubAccessDenied' { + It 'Treats as denied' -ForEach @( + @{ Case = 'AuthorizationFailure'; Message = 'This request is not authorized to perform this operation.' } + @{ Case = 'explicit 403'; Message = 'Status: 403 (Forbidden)' } + @{ Case = 'public access disabled'; Message = 'Public access is not permitted on this storage account.' } + ) { + Test-FinOpsHubAccessDenied -Message $Message | Should -BeTrue + } + + It 'Does not treat an ordinary miss as denied' { + Test-FinOpsHubAccessDenied -Message 'PathNotFound: the specified path does not exist' | Should -BeFalse + Test-FinOpsHubAccessDenied -Message 'No such host is known.' | Should -BeFalse + } + } + + Context 'Get-FinOpsHubSizeClass' { + It 'Classifies a small hub as not large' { + $r = Get-FinOpsHubSizeClass -Items @( + (Get-TestItem -Name 'a.parquet' -Length 1MB) + (Get-TestItem -Name 'b.parquet' -Length 2MB) + ) + $r.Known | Should -BeTrue + $r.IsLarge | Should -BeFalse + $r.FileCount | Should -Be 2 + $r.Bytes | Should -Be 3MB + $r.Display | Should -Be '3.0 MB across 2 file(s)' + } + + It 'Classifies a hub over the byte threshold as large' { + $r = Get-FinOpsHubSizeClass -Items @(Get-TestItem -Name 'big.parquet' -Length 512MB) + $r.IsLarge | Should -BeTrue + } + + It 'Classifies a hub at the file cap as large even when small in bytes' { + $items = 1..50 | ForEach-Object { Get-TestItem -Name "f$_.parquet" -Length 1KB } + $r = Get-FinOpsHubSizeClass -Items $items -MaxFiles 50 + $r.IsLarge | Should -BeTrue + $r.Display | Should -Match 'at least' + } + + It 'Marks a truncated listing as large regardless of measured size' { + $r = Get-FinOpsHubSizeClass -Items @(Get-TestItem -Name 'a.parquet' -Length 1KB) -Truncated + $r.IsLarge | Should -BeTrue + $r.Display | Should -Match 'at least' + } + + It 'Excludes directory entries from the size and count' { + $r = Get-FinOpsHubSizeClass -Items @( + (Get-TestItem -Name 'folder' -Length 9999 -IsDirectory $true) + (Get-TestItem -Name 'a.parquet' -Length 1MB) + ) + $r.FileCount | Should -Be 1 + $r.Bytes | Should -Be 1MB + } + + It 'Handles an empty listing without reporting it large' { + $r = Get-FinOpsHubSizeClass -Items @() + $r.FileCount | Should -Be 0 + $r.IsLarge | Should -BeFalse + } + + It 'Uses the exact threshold boundary' { + (Get-FinOpsHubSizeClass -Items @(Get-TestItem -Name 'a' -Length 256MB)).IsLarge | Should -BeTrue + (Get-FinOpsHubSizeClass -Items @(Get-TestItem -Name 'a' -Length ((256MB) - 1))).IsLarge | Should -BeFalse + } + } +} diff --git a/src/powershell/Tests/Unit/MultitoolResultIntegrity.Tests.ps1 b/src/powershell/Tests/Unit/MultitoolResultIntegrity.Tests.ps1 new file mode 100644 index 000000000..9e5607e4a --- /dev/null +++ b/src/powershell/Tests/Unit/MultitoolResultIntegrity.Tests.ps1 @@ -0,0 +1,1002 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'Multitool result scope, currency, and coverage' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/FinOpsMultitool.psm1') -Force + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + BeforeEach { + Mock Write-Host -ModuleName FinOpsMultitool { } + Mock Write-Progress -ModuleName FinOpsMultitool { } + Mock Get-AzContext -ModuleName FinOpsMultitool { throw 'Result fixtures must not read an Azure context.' } + Mock Invoke-RestMethod -ModuleName FinOpsMultitool { throw 'Result fixtures must not send HTTP requests.' } + Mock Invoke-WebRequest -ModuleName FinOpsMultitool { throw 'Result fixtures must not send HTTP requests.' } + InModuleScope FinOpsMultitool { Reset-CostMgScope } + } + + Context 'Selected subscription scope' { + It 'Limits management-group savings to the selected subscriptions' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { 'fixture-group' } + Mock Search-AzGraphSafe { + if ($Query -match 'resourcecontainers') { + return @{ Data = @( + [pscustomobject]@{ subscriptionId = '11111111-1111-1111-1111-111111111111'; ancestors = @(@{ name = 'fixture-group' }) } + [pscustomobject]@{ subscriptionId = '22222222-2222-2222-2222-222222222222'; ancestors = @(@{ name = 'fixture-group' }) } + ) } + } + @{ Data = @() } + } + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + $properties = if ($request.type -eq 'ActualCost') { + @{ columns = @(@{ name = 'Cost' }, @{ name = 'SubscriptionId' }, @{ name = 'ChargeType' }, @{ name = 'Currency' }); rows = @() } + } + else { + @{ columns = @(@{ name = 'Cost' }, @{ name = 'SubscriptionId' }, @{ name = 'PricingModel' }, @{ name = 'Currency' }); rows = @( + @(100.0, '11111111-1111-1111-1111-111111111111', 'Reservation', 'USD'), + @(0.0, '22222222-2222-2222-2222-222222222222', 'Reservation', 'USD'), + @(900.0, '33333333-3333-3333-3333-333333333333', 'Reservation', 'USD') + ) } + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Selected A' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Selected B' } + ) + + $result = Get-SavingsRealized -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + + $result.RISavingsMonthToDate | Should -Be 66.67 + @($result.Details | Where-Object Subscription -EQ '33333333-3333-3333-3333-333333333333').Count | Should -Be 0 + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { + $request = $Payload | ConvertFrom-Json + $Path -like '/providers/Microsoft.Management/*' -and $request.type -eq 'ActualCost' -and $request.dataset.filter.dimensions.name -eq 'SubscriptionId' -and + $request.dataset.filter.dimensions.operator -eq 'In' -and + (@($request.dataset.filter.dimensions.values | Sort-Object) -join ',') -eq '11111111-1111-1111-1111-111111111111,22222222-2222-2222-2222-222222222222' + } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { + $request = $Payload | ConvertFrom-Json + $clauses = @($request.dataset.filter.and) + $Path -like '/providers/Microsoft.Management/*' -and $request.type -eq 'AmortizedCost' -and -not $request.dataset.filter.or -and $clauses.Count -eq 2 -and + @($clauses | Where-Object { $_.dimensions.name -eq 'ChargeType' -and $_.dimensions.operator -eq 'In' -and (@($_.dimensions.values) -join ',') -eq 'Usage' }).Count -eq 1 -and + @($clauses | Where-Object { + $_.dimensions.name -eq 'SubscriptionId' -and $_.dimensions.operator -eq 'In' -and + (@($_.dimensions.values | Sort-Object) -join ',') -eq '11111111-1111-1111-1111-111111111111,22222222-2222-2222-2222-222222222222' + }).Count -eq 1 + } + } + } + + It 'Queries savings per subscription when the management group misses a selected subscription' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { 'fixture-group' } + Mock Search-AzGraphSafe { + if ($Query -match 'resourcecontainers') { + return @{ Data = @([pscustomobject]@{ subscriptionId = '11111111-1111-1111-1111-111111111111'; ancestors = @(@{ name = 'fixture-group' }) }) } + } + @{ Data = @() } + } + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + $amount = if ($Path -like '/subscriptions/11111111-*') { 100.0 } else { 900.0 } + $properties = if ($request.type -eq 'ActualCost') { + @{ columns = @(@{ name = 'Cost' }, @{ name = 'ChargeType' }, @{ name = 'Currency' }); rows = @() } + } + else { + @{ columns = @(@{ name = 'Cost' }, @{ name = 'PricingModel' }, @{ name = 'Currency' }); rows = @(, @($amount, 'Reservation', 'USD')) } + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Selected A' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Selected B' } + ) + + $result = Get-SavingsRealized -Subscriptions $subscriptions -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + + $result.RISavingsMonthToDate | Should -Be 666.67 + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly -ParameterFilter { $Path -like '/providers/Microsoft.Management/*' } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 4 -Exactly -ParameterFilter { $Path -like '/subscriptions/*' } + } + } + + It 'Computes unit costs from every selected subscription when the management group is partial' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { 'child-group' } + Mock Get-VmSizeCapability { [pscustomobject]@{ VCpu = 1; MemGb = 4 } } + Mock Search-AzGraphSafe { + if ($Query -match 'resourcecontainers') { + return @{ Data = @([pscustomobject]@{ subscriptionId = '11111111-1111-1111-1111-111111111111'; ancestors = @(@{ name = 'child-group' }) }) } + } + if ($Query -match 'virtualmachines') { + return @{ Data = @([pscustomobject]@{ cnt = 2; vmSize = 'FixtureSize'; loc = 'eastus'; subId = '11111111-1111-1111-1111-111111111111' }) } + } + @{ Data = @() } + } + Mock Invoke-AzRestMethodWithRetry { + $amount = if ($Path -like '/subscriptions/11111111-*') { 100.0 } else { 900.0 } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'MeterCategory' }, @{ name = 'Currency' }); rows = @(, @($amount, 'Virtual Machines', 'USD')) } } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'B' } + ) + + $result = Get-UnitEconomics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions + + $result.CostScope | Should -Be 'per-sub' + $result.ComputeCost | Should -Be 1000 + $result.CostPerVCpu | Should -Be 500 + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly -ParameterFilter { $Path -like '/providers/Microsoft.Management/*' } + } + } + + It 'Keeps unattributed hub charges with their own subscription' { + InModuleScope FinOpsMultitool { + $rows = @( + [pscustomobject]@{ SubAccountId = '/subscriptions/11111111-1111-1111-1111-111111111111'; SubAccountName = 'A'; ResourceId = ''; ResourceType = 'unattributed'; x_ResourceGroupName = ''; BilledCost = 100; BillingCurrency = 'USD'; ChargePeriodStart = '2026-09-01'; ChargePeriodEnd = '2026-09-02' } + [pscustomobject]@{ SubAccountId = '/subscriptions/22222222-2222-2222-2222-222222222222'; SubAccountName = 'B'; ResourceId = ''; ResourceType = 'unattributed'; x_ResourceGroupName = ''; BilledCost = 900; BillingCurrency = 'USD'; ChargePeriodStart = '2026-09-01'; ChargePeriodEnd = '2026-09-02' } + ) + + $result = @(ConvertTo-ResourceCostsFromHub -HubData $rows) + + $result.Count | Should -Be 2 + ($result | Where-Object Subscription -EQ 'A').Actual | Should -Be 100 + ($result | Where-Object Subscription -EQ 'B').Actual | Should -Be 900 + } + } + + It 'Confirms billing account ownership for ' -ForEach @( + @{ Scenario = 'an unrelated single account'; Owner = '99999999-9999-9999-9999-999999999999'; Confirmed = $false } + @{ Scenario = 'the account that owns the subscription'; Owner = '11111111-1111-1111-1111-111111111111'; Confirmed = $true } + @{ Scenario = 'unreadable membership'; Owner = $null; Confirmed = $false } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Owner = $Owner; Confirmed = $Confirmed } { + param($Owner, $Confirmed) + $fixtureOwner = $Owner + Mock Invoke-AzRestMethodWithRetry { + if ($Path -like '*/billingSubscriptions*' -and -not $fixtureOwner) { return [pscustomobject]@{ StatusCode = 403; Content = '{"error":{"code":"AuthorizationFailed"}}' } } + $response = if ($Path -like '*/billingSubscriptions*') { + @{ value = @(@{ name = $fixtureOwner; properties = @{ subscriptionId = $fixtureOwner } }) } + } + elseif ($Path -like '/subscriptions/*') { + # Documented Subscriptions - Get shape: subscriptionPolicies is top level. + @{ subscriptionId = '11111111-1111-1111-1111-111111111111'; subscriptionPolicies = @{ quotaId = 'EnterpriseAgreement_2014-09-01' } } + } + else { + @{ value = @(@{ name = 'single-account'; properties = @{ displayName = 'Single account'; agreementType = 'EnterpriseAgreement'; accountStatus = 'Active' } }) } + } + [pscustomobject]@{ StatusCode = 200; Content = ($response | ConvertTo-Json -Depth 8) } + } + + $result = @(Get-ContractInfo -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Selected subscription' }) -WarningAction SilentlyContinue) + + $result[0].AgreementType | Should -Be 'EnterpriseAgreement' + $result[0].CoverageIncomplete | Should -Be (-not $Confirmed) + if ($Confirmed) { $result[0].AccountId | Should -Be 'single-account' } + else { + $result[0].AccountId | Should -Not -Be 'single-account' + $result[0].Note | Should -Match 'not confirmed' + } + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { $Path -like '*/billingSubscriptions*' } + } + } + + It 'Queries resource costs per subscription when the management group misses a selected subscription' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { 'child-group' } + Mock Search-AzGraphSafe { + if ($Query -match 'resourcecontainers') { + return @{ Data = @([pscustomobject]@{ subscriptionId = '11111111-1111-1111-1111-111111111111'; ancestors = @(@{ name = 'child-group' }) }) } + } + @{ Data = @() } + } + Mock Invoke-AzRestMethodWithRetry { + $subscription = if ($Path -like '/subscriptions/11111111-*') { '11111111-1111-1111-1111-111111111111' } else { '22222222-2222-2222-2222-222222222222' } + $properties = if ($Path -match '/forecast\?') { + @{ columns = @(@{ name = 'Cost' }, @{ name = 'Currency' }); rows = @(, @(100.0, 'USD')) } + } + else { + @{ columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }); rows = @(, @(100.0, "/subscriptions/$subscription/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk", 'fixture', 'USD')) } + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'B' } + ) + + $result = @(Get-ResourceCosts -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions $subscriptions -RestrictToSelected) + + @($result.SubscriptionId | Sort-Object) | Should -Be @('11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222') + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly -ParameterFilter { $Path -like '/providers/Microsoft.Management/*' } + } + } + } + + Context 'Currencies and forecasts' { + It 'Keeps resource forecasts consistent for ' -ForEach @( + @{ Scenario = 'a forecast in another currency'; ActualAmount = 100; ActualCurrency = 'EUR'; ForecastCurrency = 'USD'; ForecastAmount = 200; ExpectedForecast = $null; ExpectedSource = 'Unavailable'; CostData = $null; ForecastCalls = 1 } + @{ Scenario = 'a flat forecast'; ActualAmount = 100; ActualCurrency = 'USD'; ForecastCurrency = 'USD'; ForecastAmount = 100; ExpectedForecast = 100; ExpectedSource = 'Forecast'; CostData = $null; ForecastCalls = 1 } + @{ Scenario = 'a forecast for spend not yet incurred'; ActualAmount = 0; ActualCurrency = 'USD'; ForecastCurrency = 'USD'; ForecastAmount = 100; ExpectedForecast = $null; ExpectedSource = 'Unavailable'; CostData = $null; ForecastCalls = 1 } + @{ Scenario = 'a zero forecast without spend'; ActualAmount = 0; ActualCurrency = 'USD'; ForecastCurrency = 'USD'; ForecastAmount = 0; ExpectedForecast = 0; ExpectedSource = 'Linear projection'; CostData = $null; ForecastCalls = 1 } + @{ Scenario = 'cost data in another currency'; ActualAmount = 100; ActualCurrency = 'EUR'; ForecastCurrency = 'USD'; ForecastAmount = 200; ExpectedForecast = $null; ExpectedSource = 'Unavailable'; CostData = @{ Actual = 100; Forecast = 150; ForecastSource = 'Forecast'; Currency = 'USD' }; ForecastCalls = 0 } + @{ Scenario = 'cost data in the same currency'; ActualAmount = 100; ActualCurrency = 'EUR'; ForecastCurrency = 'EUR'; ForecastAmount = 200; ExpectedForecast = 150; ExpectedSource = 'Forecast'; CostData = @{ Actual = 100; Forecast = 150; ForecastSource = 'Forecast'; Currency = ' eur ' }; ForecastCalls = 0 } + @{ Scenario = 'cost data without currencies'; ActualAmount = 100; ActualCurrency = ''; ForecastCurrency = 'USD'; ForecastAmount = 200; ExpectedForecast = $null; ExpectedSource = 'Unavailable'; CostData = @{ Actual = 100; Forecast = 200; ForecastSource = 'Forecast'; Currency = '' }; ForecastCalls = 0 } + @{ Scenario = 'cost data forecasting spend not yet incurred'; ActualAmount = 0; ActualCurrency = 'USD'; ForecastCurrency = 'USD'; ForecastAmount = 100; ExpectedForecast = $null; ExpectedSource = 'Unavailable'; CostData = @{ Actual = 0; Forecast = 100; ForecastSource = 'Forecast'; Currency = 'USD' }; ForecastCalls = 0 } + @{ Scenario = 'cost data without a verified forecast'; ActualAmount = 100; ActualCurrency = 'USD'; ForecastCurrency = 'USD'; ForecastAmount = 180; ExpectedForecast = 180; ExpectedSource = 'Forecast'; CostData = @{ Actual = 100; Forecast = 100; ForecastSource = 'Actual'; Currency = 'USD' }; ForecastCalls = 1 } + @{ Scenario = 'unavailable cost data'; ActualAmount = 100; ActualCurrency = 'USD'; ForecastCurrency = 'USD'; ForecastAmount = 180; ExpectedForecast = 180; ExpectedSource = 'Forecast'; CostData = @{ Actual = $null; Forecast = $null; ForecastSource = 'Unavailable'; Currency = $null }; ForecastCalls = 1 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ ActualAmount = $ActualAmount; ActualCurrency = $ActualCurrency; ForecastCurrency = $ForecastCurrency; ForecastAmount = $ForecastAmount; ExpectedForecast = $ExpectedForecast; ExpectedSource = $ExpectedSource; CostData = $CostData; ForecastCalls = $ForecastCalls } { + param($ActualAmount, $ActualCurrency, $ForecastCurrency, $ForecastAmount, $ExpectedForecast, $ExpectedSource, $CostData, $ForecastCalls) + $fixtureActualAmount = [double]$ActualAmount + $fixtureActualCurrency = $ActualCurrency + $fixtureForecastCurrency = $ForecastCurrency + $fixtureForecastAmount = $ForecastAmount + Mock Resolve-CostMgId { $null } + Mock Get-Date { + if ($Year) { return [datetime]::new($Year, $Month, $Day, 0, 0, 0, [DateTimeKind]::Utc) } + [datetime]::new(2026, 9, 15, 0, 0, 0, [DateTimeKind]::Utc) + } + Mock Invoke-AzRestMethodWithRetry { + $properties = if ($Path -match '/forecast\?') { + @{ columns = @(@{ name = 'Cost' }, @{ name = 'Currency' }); rows = @(, @($fixtureForecastAmount, $fixtureForecastCurrency)) } + } + else { + @{ columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }); rows = @(, @($fixtureActualAmount, '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk', 'fixture', $fixtureActualCurrency)) } + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + + $costDataMap = if ($CostData) { @{ '11111111-1111-1111-1111-111111111111' = $CostData } } else { $null } + $result = @(Get-ResourceCosts -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' }) -CostData $costDataMap) + + $result.Count | Should -Be 1 + $result[0].Actual | Should -Be $ActualAmount + $result[0].Currency | Should -Be $ActualCurrency + $result[0].Forecast | Should -Be $ExpectedForecast + $result[0].ForecastSource | Should -Be $ExpectedSource + Should -Invoke Invoke-AzRestMethodWithRetry -Times $ForecastCalls -Exactly -ParameterFilter { $Path -match '/forecast\?' } + } + } + + It 'Keeps every subscription''s resource actuals when one forecast request fails' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { $null } + Mock Get-Date { + if ($Year) { return [datetime]::new($Year, $Month, $Day, 0, 0, 0, [DateTimeKind]::Utc) } + [datetime]::new(2026, 9, 15, 0, 0, 0, [DateTimeKind]::Utc) + } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -match '/forecast\?') { return [pscustomobject]@{ StatusCode = 429; Content = '{}' } } + $subscription = [regex]::Match($Path, '^/subscriptions/([^/]+)/').Groups[1].Value + $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }); rows = @(, @(100.0, "/subscriptions/$subscription/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk", 'fixture', 'USD')) } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'B' } + ) + $costData = @{ + '11111111-1111-1111-1111-111111111111' = @{ Actual = 100; Forecast = 150; ForecastSource = 'Forecast'; Currency = 'USD' } + '22222222-2222-2222-2222-222222222222' = @{ Actual = 100; Forecast = 100; ForecastSource = 'Actual'; Currency = 'USD' } + } + + $result = @(Get-ResourceCosts -Subscriptions $subscriptions -CostData $costData -WarningVariable forecastWarnings -WarningAction SilentlyContinue) + + $result.Count | Should -Be 2 + $verified = $result | Where-Object SubscriptionId -EQ '11111111-1111-1111-1111-111111111111' + $verified.Actual | Should -Be 100 + $verified.Forecast | Should -Be 150 + $verified.ForecastSource | Should -Be 'Forecast' + $verified.PSObject.Properties.Name | Should -Not -Contain 'CostIssue' + $failed = $result | Where-Object SubscriptionId -EQ '22222222-2222-2222-2222-222222222222' + $failed.Actual | Should -Be 100 + $failed.Forecast | Should -BeNullOrEmpty + $failed.ForecastSource | Should -Be 'Unavailable' + $failed.CostIssue | Should -Match 'for B are unavailable.*429' + @($forecastWarnings | Where-Object { "$_" -match 'for B are unavailable.*429' }).Count | Should -Be 1 + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { $Path -match '/forecast\?' } + } + } + + It 'Keeps every per-subscription resource cost row, including unattributed charges and IDs that differ only by case' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { $null } + Mock Get-Date { + if ($Year) { return [datetime]::new($Year, $Month, $Day, 0, 0, 0, [DateTimeKind]::Utc) } + [datetime]::new(2026, 9, 15, 0, 0, 0, [DateTimeKind]::Utc) + } + Mock Invoke-AzRestMethodWithRetry { + $disk = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/Fixture/providers/Microsoft.Compute/disks/disk' + $rows = @( + @(100.0, '', 'rg-a', 'USD'), + @(25.0, '', 'rg-b', 'USD'), + @(10.0, $disk, 'Fixture', 'USD'), + @(5.0, $disk.ToLowerInvariant(), 'fixture', 'USD') + ) + $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }); rows = $rows } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $costData = @{ '11111111-1111-1111-1111-111111111111' = @{ Actual = 140; Forecast = 280; ForecastSource = 'Forecast'; Currency = 'USD' } } + + $result = @(Get-ResourceCosts -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' }) -CostData $costData) + + $result.Count | Should -Be 4 + ($result | Measure-Object Actual -Sum).Sum | Should -Be 140 + ($result | Measure-Object Forecast -Sum).Sum | Should -Be 280 + @($result | Where-Object { -not $_.ResourcePath }).Count | Should -Be 2 + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly -ParameterFilter { $Path -match '/forecast\?' } + } + } + + It 'Labels month-to-date projections when more than 50 subscriptions skip the forecast request' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { $null } + Mock Get-Date { + if ($Year) { return [datetime]::new($Year, $Month, $Day, 0, 0, 0, [DateTimeKind]::Utc) } + [datetime]::new(2026, 9, 15, 0, 0, 0, [DateTimeKind]::Utc) + } + Mock Invoke-AzRestMethodWithRetry { + $subscription = [regex]::Match($Path, '^/subscriptions/([^/]+)/').Groups[1].Value + $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }); rows = @(, @(100.0, "/subscriptions/$subscription/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk", 'fixture', 'USD')) } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + $subscriptions = @(1..51 | ForEach-Object { [pscustomobject]@{ Id = ('{0:D8}-1111-1111-1111-111111111111' -f $_); Name = "Subscription $_" } }) + $costData = @{ $subscriptions[0].Id = @{ Actual = $null; Forecast = $null; ForecastSource = 'Unavailable'; Currency = $null } } + + $result = @(Get-ResourceCosts -Subscriptions $subscriptions -CostData $costData) + + $result.Count | Should -Be 51 + @($result | Where-Object { $_.Forecast -ne 200 -or $_.ForecastSource -ne 'Linear projection' }).Count | Should -Be 0 + Should -Invoke Invoke-AzRestMethodWithRetry -Times 0 -Exactly -ParameterFilter { $Path -match '/forecast\?' } + } + } + + It 'Applies cost data forecasts to management-group resource costs for ' -ForEach @( + @{ Scenario = 'a verified forecast'; ActualAmount = 100; CostData = @{ Actual = 100; Forecast = 150; ForecastSource = 'Forecast'; Currency = 'USD' }; ExpectedForecast = 150; ExpectedSource = 'Forecast' } + @{ Scenario = 'a forecast for spend not yet incurred'; ActualAmount = 0; CostData = @{ Actual = 0; Forecast = 100; ForecastSource = 'Forecast'; Currency = 'USD' }; ExpectedForecast = $null; ExpectedSource = 'Unavailable' } + @{ Scenario = 'cost data without a verified forecast'; ActualAmount = 100; CostData = @{ Actual = 100; Forecast = 100; ForecastSource = 'Actual'; Currency = 'USD' }; ExpectedForecast = 200; ExpectedSource = 'Linear projection' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ ActualAmount = $ActualAmount; CostData = $CostData; ExpectedForecast = $ExpectedForecast; ExpectedSource = $ExpectedSource } { + param($ActualAmount, $CostData, $ExpectedForecast, $ExpectedSource) + $fixtureActualAmount = [double]$ActualAmount + Mock Resolve-CostMgId { 'fixture-group' } + Mock Test-CostMgCoverage { $true } + Mock Get-Date { + if ($Year) { return [datetime]::new($Year, $Month, $Day, 0, 0, 0, [DateTimeKind]::Utc) } + [datetime]::new(2026, 9, 15, 0, 0, 0, [DateTimeKind]::Utc) + } + Mock Invoke-AzRestMethodWithRetry { + $properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'ResourceGroupName' }, @{ name = 'Currency' }); rows = @(, @($fixtureActualAmount, '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk', 'fixture', 'USD')) } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = $properties } | ConvertTo-Json -Depth 8) } + } + + $result = @(Get-ResourceCosts -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' }) -CostData @{ '11111111-1111-1111-1111-111111111111' = $CostData }) + + $result.Count | Should -Be 1 + $result[0].Forecast | Should -Be $ExpectedForecast + $result[0].ForecastSource | Should -Be $ExpectedSource + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly + Should -Invoke Invoke-AzRestMethodWithRetry -Times 1 -Exactly -ParameterFilter { $Path -like '/providers/Microsoft.Management/*' } + } + } + + It 'Totals orphaned-resource costs only within one billing currency ()' -ForEach @( + @{ Scenario = 'mixed currencies'; SecondCurrency = 'USD'; ExpectedTotal = $null; ExpectedCurrency = $null } + @{ Scenario = 'one currency'; SecondCurrency = 'EUR'; ExpectedTotal = 300; ExpectedCurrency = 'EUR' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ SecondCurrency = $SecondCurrency; ExpectedTotal = $ExpectedTotal; ExpectedCurrency = $ExpectedCurrency } { + param($SecondCurrency, $ExpectedTotal, $ExpectedCurrency) + $fixtureSecondCurrency = $SecondCurrency + Mock Search-AzGraphSafe { + if ($Query -match "type = 'Orphaned Disk'") { + return @{ Data = @( + [pscustomobject]@{ id = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk-a'; subscriptionId = '11111111-1111-1111-1111-111111111111'; name = 'disk-a'; resourceGroup = 'fixture'; location = 'eastus'; diskSizeGb = 100; sku = 'Standard_LRS' } + [pscustomobject]@{ id = '/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk-b'; subscriptionId = '22222222-2222-2222-2222-222222222222'; name = 'disk-b'; resourceGroup = 'fixture'; location = 'eastus'; diskSizeGb = 100; sku = 'Standard_LRS' } + ) } + } + @{ Data = @() } + } + Mock Invoke-AzRestMethodWithRetry { + $first = $Path -like '/subscriptions/11111111-*' + $subscription = if ($first) { '11111111-1111-1111-1111-111111111111' } else { '22222222-2222-2222-2222-222222222222' } + $disk = if ($first) { 'disk-a' } else { 'disk-b' } + $currency = if ($first) { 'EUR' } else { $fixtureSecondCurrency } + $amount = if ($first) { 100 } else { 200 } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'Currency' }); rows = @(, @($amount, "/subscriptions/$subscription/resourceGroups/fixture/providers/Microsoft.Compute/disks/$disk", $currency)) } } | ConvertTo-Json -Depth 8) } + } + + $result = Get-OrphanedResources -Subscriptions @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'B' } + ) + + $result.TotalCount | Should -Be 2 + $result.MonthlyCost | Should -Be $ExpectedTotal + $result.Currency | Should -Be $ExpectedCurrency + ($result.Orphans | Where-Object ResourceName -EQ 'disk-b').Currency | Should -Be $SecondCurrency + ($result.Orphans | Where-Object ResourceName -EQ 'disk-b').MonthlyCost | Should -Be 200 + if ($null -eq $ExpectedTotal) { $result.CostIssue | Should -Match 'multiple billing currencies' } + else { $result.CostIssue | Should -BeNullOrEmpty } + } + } + } + + Context 'Tag values and allocation' { + It 'Keeps case-distinct tag values and counts as an allocation tag' -ForEach @( + @{ TagName = 'CostCenter' } + @{ TagName = 'ApplicationName' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ TagName = $TagName } { + param($TagName) + $fixtureTagName = $TagName + $firstId = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk-a' + $secondId = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk-b' + Mock Search-AzGraphSafe { + if ($Query -match '^resources\s') { + return @{ Data = @( + [pscustomobject]@{ id = $firstId; tags = @{ $fixtureTagName = 'Prod' } } + [pscustomobject]@{ id = $secondId; tags = @{ $fixtureTagName = 'prod' } } + ) } + } + @{ Data = @() } + } + # Mocks don't reach the runspaces behind the nested transport, so replace + # only that transport and keep the scan's own aggregation code. + $fixtureContent = (@{ properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'ResourceId' }, @{ name = 'Currency' }); rows = @(@(100, $firstId, 'USD'), @(200, $secondId, 'USD')) } } | ConvertTo-Json -Depth 8 -Compress).Replace("'", "''") + $definition = (Get-Command Get-CostByTag).Definition + $ast = [Management.Automation.Language.Parser]::ParseInput($definition, [ref]$null, [ref]$null) + $transport = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Invoke-ParallelRestCalls' }, $true) + $transport | Should -Not -BeNullOrEmpty + $replacement = 'function Invoke-ParallelRestCalls { param([array]$Calls, [int]$TimeoutSeconds); foreach ($call in $Calls) { @{ Call = $call; Result = [pscustomobject]@{ StatusCode = 200; Content = ''' + $fixtureContent + ''' } } } }' + $scan = [scriptblock]::Create($definition.Remove($transport.Extent.StartOffset, $transport.Extent.EndOffset - $transport.Extent.StartOffset).Insert($transport.Extent.StartOffset, $replacement)) + + $result = & $scan -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' }) -ExistingTags @{ $TagName = [pscustomobject]@{ TotalResources = 2 } } + + @($result.CostByTag[$TagName]).Count | Should -Be 2 + ($result.CostByTag[$TagName] | Where-Object { $_.TagValue -ceq 'Prod' }).Cost | Should -Be 100 + ($result.CostByTag[$TagName] | Where-Object { $_.TagValue -ceq 'prod' }).Cost | Should -Be 200 + $result.UnallocatedCost | Should -Be 0 + (Get-KpiComputedValue -KpiId 'pct-costs-untagged' -Data $result).Value | Should -Be 0 + } + } + } + + Context 'Coverage' { + It 'Withholds cost per GB when ' -ForEach @( + @{ Failure = 'storage capacity is unreadable' } + @{ Failure = 'storage capacity has no measurement' } + @{ Failure = 'managed disk inventory is unavailable' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Failure = $Failure } { + param($Failure) + $fixtureFailure = $Failure + Mock Resolve-CostMgId { $null } + Mock Search-AzGraphSafe { + if ($Query -match 'microsoft.compute/disks') { + if ($fixtureFailure -eq 'managed disk inventory is unavailable') { return $null } + return @{ Data = @([pscustomobject]@{ totalGb = 100 }) } + } + if ($Query -match 'storageaccounts') { return @{ Data = @([pscustomobject]@{ id = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Storage/storageAccounts/fixturestore' }) } } + @{ Data = @() } + } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -match 'Microsoft.Insights/metrics') { + if ($fixtureFailure -eq 'storage capacity is unreadable') { return [pscustomobject]@{ StatusCode = 403; Content = '{}' } } + $average = if ($fixtureFailure -eq 'storage capacity has no measurement') { $null } else { 0 } + return [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @(@{ timeseries = @(@{ data = @(@{ timeStamp = '2026-09-15T00:00:00Z'; average = $average }) }) }) } | ConvertTo-Json -Depth 8) } + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'MeterCategory' }, @{ name = 'Currency' }); rows = @(, @(1000.0, 'Storage', 'USD')) } } | ConvertTo-Json -Depth 8) } + } + + $result = Get-UnitEconomics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' }) -WarningAction SilentlyContinue + + $result.StorageCost | Should -Be 1000 + $result.CostPerGb | Should -BeNullOrEmpty + $result.Note | Should -Match 'cost per GB stored is unavailable' + } + } + + It 'Publishes cost per GB when every storage account reports a measured zero' { + InModuleScope FinOpsMultitool { + Mock Resolve-CostMgId { $null } + Mock Search-AzGraphSafe { + if ($Query -match 'microsoft.compute/disks') { return @{ Data = @([pscustomobject]@{ totalGb = 100 }) } } + if ($Query -match 'storageaccounts') { return @{ Data = @([pscustomobject]@{ id = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Storage/storageAccounts/fixturestore' }) } } + @{ Data = @() } + } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -match 'Microsoft.Insights/metrics') { + return [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @(@{ timeseries = @(@{ data = @(@{ timeStamp = '2026-09-15T00:00:00Z'; average = 0 }) }) }) } | ConvertTo-Json -Depth 8) } + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ properties = @{ columns = @(@{ name = 'Cost' }, @{ name = 'MeterCategory' }, @{ name = 'Currency' }); rows = @(, @(1000.0, 'Storage', 'USD')) } } | ConvertTo-Json -Depth 8) } + } + + $result = Get-UnitEconomics -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' }) + + $result.BlobFileOk | Should -BeTrue + $result.CostPerGb | Should -Be 10 + } + } + + It 'Rejects an export run without a manifest when ' -ForEach @( + @{ Signal = 'the export is partitioned'; Partitioned = $true; BlobName = 'costs/selected/20260901-20260930/run/data.csv' } + @{ Signal = 'the run folder is a run ID'; Partitioned = $false; BlobName = 'costs/selected/20260901-20260930/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/data.csv' } + @{ Signal = 'the file is a partition'; Partitioned = $false; BlobName = 'costs/selected/20260901-20260930/run/part_0.csv' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Partitioned = $Partitioned; BlobName = $BlobName } { + param($Partitioned, $BlobName) + $fixtureBlobName = $BlobName + Mock Get-PlainAccessToken { 'synthetic-token' } + Mock Get-StorageBlobList { + @{ Listed = $true; Blobs = @([pscustomobject]@{ Name = $fixtureBlobName; LastModified = [datetime]'2026-09-16' }) } + } + Mock Get-StorageBlobBytes { throw 'An unverified run must not be downloaded.' } + $export = [pscustomobject]@{ Name = 'selected'; Format = 'Csv'; Type = 'FocusCost'; Partitioned = $Partitioned; RootFolder = 'costs'; Container = 'exports'; StorageResourceId = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Storage/storageAccounts/fixturestore' } + + { Get-CostExportData -Export $export } | Should -Throw '*no manifest.json*' + Should -Invoke Get-StorageBlobBytes -Times 0 -Exactly + } + } + + It 'Rejects an export run whose manifest declarations are ' -ForEach @( + @{ Defect = 'blank'; Parts = @('part_0.csv'); Declared = @('') } + @{ Defect = 'duplicated'; Parts = @('part_0.csv'); Declared = @('part_0.csv', 'part_0.csv') } + @{ Defect = 'duplicated with a matching count'; Parts = @('part_0.csv', 'part_1.csv'); Declared = @('part_0.csv', 'part_0.csv') } + @{ Defect = 'missing a stored part'; Parts = @('part_0.csv', 'part_1.csv'); Declared = @('part_0.csv') } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Parts = $Parts; Declared = $Declared } { + param($Parts, $Declared) + $fixtureFolder = 'costs/selected/20260901-20260930/run' + $fixtureManifest = @{ blobs = @($Declared | ForEach-Object { @{ blobName = $(if ($_) { "$fixtureFolder/$_" } else { '' }) } }) } | ConvertTo-Json -Depth 4 + $fixtureBlobs = @($Parts | ForEach-Object { [pscustomobject]@{ Name = "$fixtureFolder/$_"; LastModified = [datetime]'2026-09-16' } }) + + [pscustomobject]@{ Name = "$fixtureFolder/manifest.json"; LastModified = [datetime]'2026-09-16' } + Mock Get-PlainAccessToken { 'synthetic-token' } + Mock Get-StorageBlobList { @{ Listed = $true; Blobs = $fixtureBlobs } } + Mock Get-StorageBlobBytes { + if ($Uri -like '*manifest.json') { return , [Text.Encoding]::UTF8.GetBytes($fixtureManifest) } + throw 'An unverified run must not be downloaded.' + } + $export = [pscustomobject]@{ Name = 'selected'; Format = 'Csv'; Type = 'FocusCost'; Partitioned = $true; RootFolder = 'costs'; Container = 'exports'; StorageResourceId = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Storage/storageAccounts/fixturestore' } + + { Get-CostExportData -Export $export } | Should -Throw '*incomplete*' + Should -Invoke Get-StorageBlobBytes -Times 0 -Exactly -ParameterFilter { $Uri -like '*.csv' } + } + } + + It 'Reads every part that the manifest lists ( compression)' -ForEach @( + @{ Compression = 'no'; Extension = 'csv' } + @{ Compression = 'Gzip'; Extension = 'csv.gz' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Extension = $Extension } { + param($Extension) + $fixtureFolder = 'costs/selected/20260901-20260930/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + $fixtureParts = @("$fixtureFolder/part_0_0001.$Extension", "$fixtureFolder/part_1_0001.$Extension") + $fixtureManifest = @{ blobs = @($fixtureParts | ForEach-Object { @{ blobName = $_ } }) } | ConvertTo-Json -Depth 4 + Mock Get-PlainAccessToken { 'synthetic-token' } + Mock Get-StorageBlobList { + @{ Listed = $true; Blobs = @(@($fixtureParts + "$fixtureFolder/manifest.json") | ForEach-Object { [pscustomobject]@{ Name = $_; LastModified = [datetime]'2026-09-16' } }) } + } + Mock Get-StorageBlobBytes { + if ($Uri -like '*manifest.json') { return , [Text.Encoding]::UTF8.GetBytes($fixtureManifest) } + $amount = if ($Uri -match 'part_1') { 20 } else { 10 } + $bytes = [Text.Encoding]::UTF8.GetBytes("SubscriptionId,BilledCost,BillingCurrency,ChargePeriodStart`n11111111-1111-1111-1111-111111111111,$amount,USD,2026-09-15") + if ($Uri -notlike '*.gz') { return , $bytes } + $stream = [IO.MemoryStream]::new() + $gzip = [IO.Compression.GZipStream]::new($stream, [IO.Compression.CompressionMode]::Compress) + $gzip.Write($bytes, 0, $bytes.Length) + $gzip.Dispose() + , $stream.ToArray() + } + $export = [pscustomobject]@{ Name = 'selected'; Format = 'Csv'; Type = 'FocusCost'; Partitioned = $true; RootFolder = 'costs'; Container = 'exports'; StorageResourceId = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Storage/storageAccounts/fixturestore' } + + $data = Get-CostExportData -Export $export + + $data.RowCount | Should -Be 2 + ($data.Rows | Measure-Object -Property BilledCost -Sum).Sum | Should -Be 30 + } + } + + It 'Reads a legacy export whose name starts with part_ without requiring a manifest' { + InModuleScope FinOpsMultitool { + Mock Get-PlainAccessToken { 'synthetic-token' } + Mock Get-StorageBlobList { + @{ Listed = $true; Blobs = @( + [pscustomobject]@{ Name = 'costs/part_daily/20260901-20260930/part_daily_first.csv'; LastModified = [datetime]'2026-09-15' } + [pscustomobject]@{ Name = 'costs/part_daily/20260901-20260930/part_daily_second.csv'; LastModified = [datetime]'2026-09-16' } + ) } + } + Mock Get-StorageBlobBytes { + $amount = if ($Uri -match 'second') { 20 } else { 10 } + , [Text.Encoding]::UTF8.GetBytes("SubscriptionId,BilledCost,BillingCurrency,ChargePeriodStart`n11111111-1111-1111-1111-111111111111,$amount,USD,2026-09-15") + } + $export = [pscustomobject]@{ Name = 'part_daily'; Format = 'Csv'; Type = 'FocusCost'; Partitioned = $false; RootFolder = 'costs'; Container = 'exports'; StorageResourceId = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Storage/storageAccounts/fixturestore' } + + $data = Get-CostExportData -Export $export + + $data.RowCount | Should -Be 1 + Should -Invoke Get-StorageBlobBytes -Times 1 -Exactly -ParameterFilter { $Uri -match 'part_daily_second' } + } + } + + It 'Reads the latest hub CSV run for each export scope ()' -ForEach @( + @{ Scenario = 'separate export scopes'; Expected = 300; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 100 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; Parts = @{ 'part_0.csv' = 200 } } + ) } + @{ Scenario = 'every part of a run'; Expected = 30; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10; 'part_1.csv' = 20 } } + ) } + @{ Scenario = 'a later run in another folder layout'; Expected = 20; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = '202609150100/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'runs without a known order'; Expected = $null; ExpectedError = "*can't be ordered*"; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = $null; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'runs submitted at the same time'; Expected = $null; ExpectedError = "*can't be ordered*"; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'a run missing a listed part'; Expected = $null; ExpectedError = "*aren't in msexports*"; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10; 'part_1.csv' = 20 }; Missing = 'part_1.csv' } + ) } + @{ Scenario = 'other datasets and unlisted files'; Expected = 10; ExpectedError = $null; Stray = 'resourceGroups/rg-a/focuscost/20260901-20260930/stray.csv'; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'cccccccc-cccc-cccc-cccc-cccccccccccc'; Submitted = '2026-09-16T01:00:00Z'; Type = 'PriceSheet'; Parts = @{ 'part_0.csv' = 500 } } + ) } + @{ Scenario = 'only the newest month'; Expected = 20; ExpectedError = $null; Stray = $false; SkippedDownload = '*20260801-20260831*.csv'; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 20 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202608'; Submitted = '2026-08-31T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + ) } + @{ Scenario = 'an empty current month'; Expected = 10; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-01T01:00:00Z'; Empty = $true; Parts = @{ 'part_0.csv' = 0 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202608'; Submitted = '2026-08-31T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + ) } + @{ Scenario = 'a run outside a month folder'; Expected = 10; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; OutsideMonthFolder = $true; Parts = @{ 'part_0.csv' = 10 } } + ) } + @{ Scenario = 'an earlier month filed in the current month folder'; Expected = 50; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Month = '202610'; Submitted = '2026-10-05T01:00:00Z'; Parts = @{ 'part_0.csv' = 50 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202609'; FolderMonth = '202610'; Submitted = '2026-10-06T01:00:00Z'; Parts = @{ 'part_0.csv' = 900 } } + ) } + @{ Scenario = 'two months with an earlier month filed in the current month folder'; Expected = 950; Months = 2; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Month = '202610'; Submitted = '2026-10-05T01:00:00Z'; Parts = @{ 'part_0.csv' = 50 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202609'; FolderMonth = '202610'; Submitted = '2026-10-06T01:00:00Z'; Parts = @{ 'part_0.csv' = 900 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'cccccccc-cccc-cccc-cccc-cccccccccccc'; Month = '202609'; Submitted = '2026-09-30T01:00:00Z'; Parts = @{ 'part_0.csv' = 800 } } + ) } + @{ Scenario = 'a later run filed in an earlier month folder'; Expected = 40; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Month = '202610'; Submitted = '2026-10-03T01:00:00Z'; Parts = @{ 'part_0.csv' = 30 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202610'; FolderMonth = '202609'; Submitted = '2026-10-05T01:00:00Z'; Parts = @{ 'part_0.csv' = 40 } } + ) } + @{ Scenario = 'an empty later run'; Expected = 300; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 100 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; Empty = $true; Parts = @{ 'part_0.csv' = 0 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'cccccccc-cccc-cccc-cccc-cccccccccccc'; Submitted = '2026-09-16T01:00:00Z'; Parts = @{ 'part_0.csv' = 200 } } + ) } + @{ Scenario = 'another subscription''s run missing a listed part'; Expected = 10; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'other-subscription'; ExportScope = '/subscriptions/22222222-2222-2222-2222-222222222222'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; Parts = @{ 'part_0.csv' = 20 }; Missing = 'part_0.csv' } + ) } + @{ Scenario = 'a billing account run missing a listed part'; Expected = $null; ExpectedError = "*aren't in msexports*"; Stray = $false; Runs = @( + @{ Scope = 'billing'; ExportScope = '/providers/Microsoft.Billing/billingAccounts/fixture'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 }; Missing = 'part_0.csv' } + ) } + @{ Scenario = 'an unlisted CSV in a month without manifests'; Expected = 10; ExpectedError = $null; Stray = 'resourceGroups/rg-a/focuscost/20260901-20260930/stray.csv'; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Month = '202608'; Submitted = '2026-08-31T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + ) } + @{ Scenario = 'an unreadable manifest'; Expected = $null; ExpectedError = "*couldn't be read*"; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'unknown'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; Unreadable = $true; Parts = @{ 'part_0.csv' = 20 }; Missing = 'part_0.csv' } + ) } + @{ Scenario = 'an unreadable manifest dated before the months read'; Expected = 10; ExpectedError = $null; Stray = $false; IgnoredWarning = $true; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'unknown'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202608'; Submitted = '2026-08-16T01:00:00Z'; Unreadable = $true; Parts = @{ 'part_0.csv' = 20 }; Missing = 'part_0.csv' } + ) } + @{ Scenario = 'an unreadable manifest in a month that is still needed'; Expected = $null; ExpectedError = "*couldn't be read*"; Months = 2; DownloadsBeforeError = $true; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'unknown'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202608'; Submitted = '2026-08-16T01:00:00Z'; Unreadable = $true; Parts = @{ 'part_0.csv' = 20 }; Missing = 'part_0.csv' } + ) } + @{ Scenario = 'invalid counts for a selected scope'; Expected = $null; ExpectedError = '*invalid blob or row counts*'; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; BlobCountValue = 'invalid'; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'invalid counts dated before the months read'; Expected = 10; ExpectedError = $null; Stray = $false; IgnoredWarning = $true; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202608'; Submitted = '2026-08-16T01:00:00Z'; BlobCountValue = 'invalid'; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'invalid counts for another subscription'; Expected = 10; ExpectedError = $null; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'other-subscription'; ExportScope = '/subscriptions/22222222-2222-2222-2222-222222222222'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; BlobCountValue = 'invalid'; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'a manifest without a dataset type'; Expected = $null; ExpectedError = "*doesn't identify its dataset*"; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; OmitType = $true; Parts = @{ 'part_0.csv' = 50 } } + ) } + @{ Scenario = 'a dataset type that is not text'; Expected = $null; ExpectedError = "*doesn't identify its dataset*"; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-a'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; TypeValue = 123; Parts = @{ 'part_0.csv' = 50 } } + ) } + @{ Scenario = 'invalid counts for the read month filed in an earlier folder'; Expected = $null; ExpectedError = '*invalid blob or row counts*'; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; FolderMonth = '202608'; Submitted = '2026-09-16T01:00:00Z'; BlobCountValue = 'invalid'; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'invalid counts for an earlier month filed in the read folder'; Expected = 10; ExpectedError = $null; Stray = $false; IgnoredWarning = $true; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202608'; FolderMonth = '202609'; Submitted = '2026-09-16T01:00:00Z'; BlobCountValue = 'invalid'; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'an invalid row count'; Expected = $null; ExpectedError = '*invalid blob or row counts*'; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; RowCountValue = 'invalid'; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'an unreadable manifest outside a month folder'; Expected = $null; ExpectedError = "*couldn't be read*"; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'unknown'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202608'; Submitted = '2026-08-16T01:00:00Z'; Unreadable = $true; OutsideMonthFolder = $true; Parts = @{ 'part_0.csv' = 20 }; Missing = 'part_0.csv' } + ) } + @{ Scenario = 'an unreadable earlier manifest whose CSV remains'; Expected = 10; ExpectedError = $null; Stray = $false; IgnoredWarning = $true; UnlistedWarning = $true; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'unknown'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202608'; Submitted = '2026-08-16T01:00:00Z'; Unreadable = $true; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'more manifests than the reader checks'; Expected = $null; ExpectedError = '*more than the 1 this storage reader checks*'; MaxManifests = 1; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'an oversized manifest in the read month'; Expected = $null; ExpectedError = '*larger than 1 MB*'; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; Oversized = $true; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'an oversized manifest dated before the months read'; Expected = 10; ExpectedError = $null; Stray = $false; IgnoredWarning = $true; UnlistedWarning = $true; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202608'; Submitted = '2026-08-16T01:00:00Z'; Oversized = $true; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'a part listed by two export runs'; Expected = $null; ExpectedError = '*counted twice*'; DownloadsBeforeError = $true; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = 'resourceGroups/rg-b'; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Submitted = '2026-09-16T01:00:00Z'; ListedPart = 'resourceGroups/rg-a/focuscost/20260901-20260930/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/part_0.csv'; Parts = @{ 'part_0.csv' = 20 } } + ) } + @{ Scenario = 'a scope with control characters'; Expected = $null; ExpectedError = "*doesn't identify its export scope*"; Stray = $false; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; ExportScope = "/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/rg-a$([char]27)[8m"; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + ) } + @{ Scenario = 'an unreadable earlier manifest with control characters in its path'; Expected = 10; ExpectedError = $null; Stray = $false; IgnoredWarning = $true; Runs = @( + @{ Scope = 'resourceGroups/rg-a'; Run = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'; Submitted = '2026-09-15T01:00:00Z'; Parts = @{ 'part_0.csv' = 10 } } + @{ Scope = "resourceGroups/rg-$([char]27)[8m"; Run = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; Month = '202608'; Submitted = '2026-08-16T01:00:00Z'; Unreadable = $true; Parts = @{ 'part_0.csv' = 20 }; Missing = 'part_0.csv' } + ) } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Runs = $Runs; Expected = $Expected; ExpectedError = $ExpectedError; Stray = $Stray; SkippedDownload = $_.SkippedDownload; Months = $(if ($_.Months) { $_.Months } else { 1 }); IgnoredWarning = [bool]$_.IgnoredWarning; DownloadsBeforeError = [bool]$_.DownloadsBeforeError; UnlistedWarning = [bool]$_.UnlistedWarning; MaxManifests = $_.MaxManifests } { + param($Runs, $Expected, $ExpectedError, $Stray, $SkippedDownload, $Months, $IgnoredWarning, $DownloadsBeforeError, $UnlistedWarning, $MaxManifests) + $fixtureSubscription = '11111111-1111-1111-1111-111111111111' + $fixtureSkippedDownload = $SkippedDownload + $fixtureFiles = @{} + $fixtureOversized = [System.Collections.Generic.HashSet[string]]::new() + foreach ($run in $Runs) { + $type = if ($run.Type) { $run.Type } else { 'FocusCost' } + $exportName = $type.ToLowerInvariant() + $month = if ($run.Month) { $run.Month } else { '202609' } + $year = [int]$month.Substring(0, 4) + $monthNumber = [int]$month.Substring(4, 2) + $folderMonth = if ($run.FolderMonth) { $run.FolderMonth } else { $month } + $periodFolder = '{0}01-{0}{1:00}' -f $folderMonth, [datetime]::DaysInMonth([int]$folderMonth.Substring(0, 4), [int]$folderMonth.Substring(4, 2)) + $folder = if ($run.OutsideMonthFolder) { "$($run.Scope)/$exportName/$($run.Run)" } else { "$($run.Scope)/$exportName/$periodFolder/$($run.Run)" } + $exportScope = if ($run.ExportScope) { $run.ExportScope } else { "/subscriptions/$fixtureSubscription/$($run.Scope)" } + $blobs = @(foreach ($part in $run.Parts.Keys) { + if ($part -ne $run.Missing -and -not $run.Empty) { $fixtureFiles["$folder/$part"] = "BilledCost,BillingCurrency,SubAccountId`n$($run.Parts[$part]),USD,$fixtureSubscription" } + @{ blobName = "$folder/$part"; byteCount = 1; dataRowCount = $(if ($run.Empty) { 0 } else { 1 }) } + }) + if ($run.ListedPart) { $blobs = @(@{ blobName = $run.ListedPart; byteCount = 1; dataRowCount = 1 }) } + if ($run.Oversized) { [void]$fixtureOversized.Add("$folder/manifest.json") } + $fixtureFiles["$folder/manifest.json"] = if ($run.Unreadable) { '{ "blobCount": ' } else { + @{ + manifestVersion = '2024-04-01'; blobCount = $(if ($run.ContainsKey('BlobCountValue')) { $run.BlobCountValue } else { $blobs.Count }); dataRowCount = $(if ($run.ContainsKey('RowCountValue')) { $run.RowCountValue } elseif ($run.Empty) { 0 } else { $blobs.Count }) + exportConfig = $(if ($run.OmitType) { @{ exportName = $exportName; resourceId = "$exportScope/providers/Microsoft.CostManagement/exports/$exportName" } } else { @{ exportName = $exportName; type = $(if ($run.ContainsKey('TypeValue')) { $run.TypeValue } else { $type }); resourceId = "$exportScope/providers/Microsoft.CostManagement/exports/$exportName" } }) + runInfo = @{ submittedTime = $run.Submitted; runId = [guid]::NewGuid().ToString(); startDate = ('{0}-{1:00}-01T00:00:00' -f $year, $monthNumber) } + blobs = $blobs + } | ConvertTo-Json -Depth 6 + } + } + if ($Stray) { $fixtureFiles[$Stray] = "BilledCost,BillingCurrency,SubAccountId`n99,USD,$fixtureSubscription" } + Mock New-AzStorageContext { $null } + Mock Write-Warning { } + Mock Get-AzDataLakeGen2ChildItem { + if ($FileSystem -eq 'ingestion') { return @() } + foreach ($path in $fixtureFiles.Keys) { [pscustomobject]@{ Name = Split-Path $path -Leaf; Path = $path; IsDirectory = $false; Length = $(if ($fixtureOversized.Contains($path)) { 2MB } else { 100 }) } } + } + Mock Get-AzDataLakeGen2ItemContent { + if (-not $fixtureFiles.ContainsKey($Path)) { throw "Unexpected download: $Path" } + Set-Content -LiteralPath $Destination -Value $fixtureFiles[$Path] + } + + $readParameters = @{ StorageAccountName = 'fixture'; ResourceGroupName = 'fixture'; Months = $Months; SubscriptionIds = @($fixtureSubscription) } + if ($MaxManifests) { $readParameters.MaxManifests = $MaxManifests } + + if ($ExpectedError) { + # Rows emitted before the error would reach callers that stream the output. + $streamed = [System.Collections.Generic.List[object]]::new() + { Read-FinOpsHubData @readParameters | ForEach-Object { $streamed.Add($_) } } | Should -Throw $ExpectedError + $streamed.Count | Should -Be 0 + Should -Invoke Get-AzDataLakeGen2ItemContent -Times $(if ($DownloadsBeforeError) { 1 } else { 0 }) -Exactly -ParameterFilter { $Path -like '*.csv' } + } + else { + $rows = @(Read-FinOpsHubData @readParameters) + ($rows | Measure-Object -Property BilledCost -Sum).Sum | Should -Be $Expected + Should -Invoke Write-Warning -Times $(if ($IgnoredWarning) { 1 } else { 0 }) -Exactly -ParameterFilter { $Message -like 'Ignored 1 export manifest*dated before the months read*' } + Should -Invoke Write-Warning -Times $(if ($Stray -or $UnlistedWarning) { 1 } else { 0 }) -Exactly -ParameterFilter { $Message -like '*no readable export manifest lists*' } + if ($fixtureSkippedDownload) { Should -Invoke Get-AzDataLakeGen2ItemContent -Times 0 -Exactly -ParameterFilter { $Path -like $fixtureSkippedDownload } } + } + Should -Invoke Get-AzDataLakeGen2ItemContent -Times 0 -Exactly -ParameterFilter { $fixtureOversized.Contains($Path) } + Should -Invoke Write-Warning -Times 0 -Exactly -ParameterFilter { $Message -match '[\p{Cc}\p{Cf}]' } + } + } + + It 'Reads only the newest snapshot from an unpartitioned export folder' { + InModuleScope FinOpsMultitool { + Mock Get-PlainAccessToken { 'synthetic-token' } + Mock Get-StorageBlobList { + @{ Listed = $true; Blobs = @( + [pscustomobject]@{ Name = 'costs/selected/20260901-20260930/selected_first.csv'; LastModified = [datetime]'2026-09-15' } + [pscustomobject]@{ Name = 'costs/selected/20260901-20260930/selected_second.csv'; LastModified = [datetime]'2026-09-16' } + ) } + } + Mock Get-StorageBlobBytes { + $amount = if ($Uri -match 'second') { 20 } else { 10 } + , [Text.Encoding]::UTF8.GetBytes("SubscriptionId,BilledCost,BillingCurrency,ChargePeriodStart`n11111111-1111-1111-1111-111111111111,$amount,USD,2026-09-15") + } + $export = [pscustomobject]@{ Name = 'selected'; Format = 'Csv'; Type = 'FocusCost'; Partitioned = $false; RootFolder = 'costs'; Container = 'exports'; StorageResourceId = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Storage/storageAccounts/fixturestore' } + + $data = Get-CostExportData -Export $export + $selected = Select-CostExportData -ExportData $data -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' }) + + $data.RowCount | Should -Be 1 + ($selected.Rows | Measure-Object Cost -Sum).Sum | Should -Be 20 + $selected.CoverageIncomplete | Should -BeFalse + Should -Invoke Get-StorageBlobBytes -Times 1 -Exactly -ParameterFilter { $Uri -match 'selected_second' } + } + } + + It 'Retries Advisor through REST after (REST status )' -ForEach @( + @{ Failure = 'an unreadable page'; Status = 200; ExpectedCount = 2; Incomplete = $false } + @{ Failure = 'an unreadable page'; Status = 403; ExpectedCount = 0; Incomplete = $true } + @{ Failure = 'no Resource Graph response'; Status = 200; ExpectedCount = 2; Incomplete = $false } + @{ Failure = 'a non-numeric savings value'; Status = 200; ExpectedCount = 2; Incomplete = $false } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Failure = $Failure; Status = $Status; ExpectedCount = $ExpectedCount; Incomplete = $Incomplete } { + param($Failure, $Status, $ExpectedCount, $Incomplete) + $fixtureFailure = $Failure + $fixtureStatus = $Status + Mock Search-AzGraphSafe { + if ($fixtureFailure -eq 'no Resource Graph response') { return $null } + if ($fixtureFailure -eq 'a non-numeric savings value') { + $recommendation = @{ subscriptionId = '11111111-1111-1111-1111-111111111111'; shortDescriptionProblem = 'Right-size underused virtual machines'; shortDescriptionSolution = 'Resize'; impact = 'High'; impactedField = 'Microsoft.Compute/virtualMachines'; savingsAmount = ''; savingsCurrency = 'USD' } + return @{ Data = @( + [pscustomobject](@{ id = 'rec-a'; impactedValue = 'vm-a'; annualSavings = '100' } + $recommendation) + [pscustomobject](@{ id = 'rec-b'; impactedValue = 'vm-b'; annualSavings = 'not available' } + $recommendation) + ) } + } + throw 'Resource Graph query failed after 1 page(s); results are incomplete.' + } + Mock Invoke-AzRestMethodWithRetry { + if ($fixtureStatus -ne 200) { return [pscustomobject]@{ StatusCode = $fixtureStatus; Content = '{"error":{"code":"AuthorizationFailed"}}' } } + $items = @( + @{ properties = @{ impact = 'High'; impactedField = 'Microsoft.Compute/virtualMachines'; impactedValue = 'vm-a'; shortDescription = @{ problem = 'Right-size underused virtual machines'; solution = 'Resize' }; extendedProperties = @{ annualSavingsAmount = '100'; savingsCurrency = 'USD' } } } + @{ properties = @{ impact = 'Medium'; impactedField = 'Microsoft.Compute/virtualMachines'; impactedValue = 'vm-b'; shortDescription = @{ problem = 'Right-size underused virtual machines'; solution = 'Resize' }; extendedProperties = @{ annualSavingsAmount = '50'; savingsCurrency = 'USD' } } } + ) + [pscustomobject]@{ StatusCode = 200; Content = (@{ value = $items } | ConvertTo-Json -Depth 8) } + } + + $result = Get-OptimizationAdvice -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' }) -WarningAction SilentlyContinue + + $result.TotalCount | Should -Be $ExpectedCount + $result.CoverageIncomplete | Should -Be $Incomplete + if ($Incomplete) { $result.Note | Should -Match 'incomplete' } + else { $result.EstimatedAnnualSavings | Should -Be 150 } + Should -Invoke Search-AzGraphSafe -Times 1 -Exactly -ParameterFilter { $All -and $Query -match 'project id, subscriptionId' } + } + } + + It 'Counts each reservation recommendation once when the Resource Graph read fails partway' { + InModuleScope FinOpsMultitool { + $fixtureRecommendation = @{ subscriptionId = '11111111-1111-1111-1111-111111111111'; shortDescriptionProblem = 'Consider virtual machine reserved instances'; shortDescriptionSolution = 'Buy reserved instances'; impact = 'High'; impactedField = 'Microsoft.Compute/virtualMachines'; savingsCurrency = 'USD'; term = 'P1Y'; region = 'eastus'; displayQty = '1' } + Mock Search-AzGraphSafe { + @{ SkipToken = $null; Data = @( + [pscustomobject](@{ recName = 'rec-a'; impactedValue = 'vm-a'; displaySKU = 'Standard_D2s_v5'; annualSavings = '100' } + $fixtureRecommendation) + [pscustomobject](@{ recName = 'rec-b'; impactedValue = 'vm-b'; displaySKU = 'Standard_D4s_v5'; annualSavings = 'not available' } + $fixtureRecommendation) + ) } + } + Mock Invoke-AzRestMethodWithRetry { + if ($Path -notlike '*/Microsoft.Advisor/recommendations*') { return [pscustomobject]@{ StatusCode = 200; Content = '{"value":[]}' } } + $items = foreach ($fixture in @(@('rec-a', 'vm-a', 'Standard_D2s_v5', '100'), @('rec-b', 'vm-b', 'Standard_D4s_v5', '50'))) { + @{ name = $fixture[0]; properties = @{ impact = 'High'; impactedField = 'Microsoft.Compute/virtualMachines'; impactedValue = $fixture[1]; shortDescription = @{ problem = 'Consider virtual machine reserved instances'; solution = 'Buy reserved instances' }; extendedProperties = @{ annualSavingsAmount = $fixture[3]; savingsCurrency = 'USD'; term = 'P1Y'; displaySKU = $fixture[2]; region = 'eastus'; displayQty = '1' } } } + } + [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @($items) } | ConvertTo-Json -Depth 8) } + } + + $result = Get-ReservationAdvice -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' }) -WarningAction SilentlyContinue + + $result.TotalAdvisorCount | Should -Be 2 + @($result.AdvisorRecommendations | ForEach-Object { $_.DuplicateCount }) | Should -Be @(1, 1) + $result.EstimatedAnnualSavings | Should -Be 150 + } + } + + It 'Counts only enabled anomaly rules as detection coverage ()' -ForEach @( + @{ RuleStatus = 'Disabled'; Expected = 0 } + @{ RuleStatus = 'Enabled'; Expected = 1 } + ) { + InModuleScope FinOpsMultitool -Parameters @{ RuleStatus = $RuleStatus; Expected = $Expected } { + param($RuleStatus, $Expected) + $fixtureRuleStatus = $RuleStatus + Mock Invoke-AzRestMethodWithRetry { + $items = if ($Path -like '/subscriptions/11111111-*/providers/Microsoft.CostManagement/scheduledActions*') { + @(@{ name = 'fixture-rule'; kind = 'InsightAlert'; properties = @{ status = $fixtureRuleStatus; scope = '/subscriptions/11111111-1111-1111-1111-111111111111'; displayName = 'Fixture rule' } }) + } + else { @() } + [pscustomobject]@{ StatusCode = 200; Content = (@{ value = @($items) } | ConvertTo-Json -Depth 8) } + } + + $result = Get-AnomalyAlerts -Subscriptions @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'B' } + ) + + @($result.ConfiguredRules).Count | Should -Be 1 + $result.ConfiguredRuleCount | Should -Be $Expected + (Get-KpiComputedValue -KpiId 'anomaly-detection-rate' -Data $result).Value | Should -Be $Expected + } + } + } + + Context 'Report and runner consumers' { + BeforeAll { + $launcher = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool/Invoke-FinOpsMultitool.ps1' + $script:LauncherFunctions = @(([System.Management.Automation.Language.Parser]::ParseFile($launcher, [ref]$null, [ref]$null)).FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -in @('Invoke-SelectedScans', 'Get-FinOpsReportRoot', 'Assert-FinOpsReportPath', 'New-FinOpsReportDirectory', 'Write-FinOpsReportFile', + 'Show-ResultsSummary', 'Show-Banner', 'Write-SectionHeader', 'Write-ColorizedLine', 'Write-FinOpsConsole', 'Protect-FinOpsExportText', 'ConvertTo-FinOpsExportCell', 'ConvertTo-FinOpsExportRows') + }, $true) | ForEach-Object { $_.Extent.Text }) + } + + It 'Reports incomplete Advisor coverage instead of a healthy environment' { + InModuleScope FinOpsMultitool -Parameters @{ Functions = $script:LauncherFunctions; ReportRoot = (Join-Path $TestDrive 'advisor-coverage') } { + param($Functions, $ReportRoot) + foreach ($definition in $Functions) { . ([scriptblock]::Create($definition)) } + $results = @{ 'Get-OptimizationAdvice' = [pscustomobject]@{ Recommendations = @(); TotalCount = 0; EstimatedAnnualSavings = $null; Currency = $null; CostIssue = $null; CoverageIncomplete = $true; ReadErrors = @('A: 403'); Note = 'Advisor recommendations are incomplete. A: 403' } } + $modules = @(@{ Fn = 'Get-OptimizationAdvice'; Name = 'Optimization Advice'; Selected = $true; Category = 'Advisor' }) + + $null = Show-ResultsSummary -Results $results -Modules $modules -ExportPath $ReportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $ReportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match 'Advisor recommendations are incomplete' + $html | Should -Not -Match 'well optimized' + } + } + + It 'Passes verified cost data to the resource cost scan' { + InModuleScope FinOpsMultitool -Parameters @{ Functions = $script:LauncherFunctions } { + param($Functions) + foreach ($definition in $Functions) { . ([scriptblock]::Create($definition)) } + Mock Get-CostData { @{ '11111111-1111-1111-1111-111111111111' = @{ Actual = 100; Forecast = 100; ForecastSource = 'Forecast'; Currency = 'USD' } } } + Mock Get-ResourceCosts { @() } + $modules = @( + [pscustomobject]@{ Name = 'Cost Data'; Fn = 'Get-CostData'; Selected = $true } + [pscustomobject]@{ Name = 'Resource Costs'; Fn = 'Get-ResourceCosts'; Selected = $true } + ) + + $null = Invoke-SelectedScans -Modules $modules -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'A' }) -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -DataSource @{ Source = 'API' } + + Should -Invoke Get-ResourceCosts -Times 1 -Exactly -ParameterFilter { $CostData -is [hashtable] -and $CostData['11111111-1111-1111-1111-111111111111'].ForecastSource -eq 'Forecast' } + } + } + } +} diff --git a/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 new file mode 100644 index 000000000..1a6cc2090 --- /dev/null +++ b/src/powershell/Tests/Unit/MultitoolSafety.Tests.ps1 @@ -0,0 +1,4340 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +Describe 'FinOps Multitool safety' { + + # Scoped to this Describe: Initialize-Tests.ps1 already declares a root-level + # BeforeAll, and Pester 6 rejects a second one during discovery. + BeforeAll { + $script:ModuleRoot = Join-Path $PSScriptRoot '../../Private/FinOpsMultitool' + $script:MultitoolModule = Join-Path $script:ModuleRoot 'FinOpsMultitool.psm1' + Import-Module $script:MultitoolModule -Force + } + + AfterAll { + Remove-Module FinOpsMultitool -ErrorAction SilentlyContinue + } + + Context 'Every command the module calls actually resolves' { + + # A cleanup commit once deleted a helper and left its caller behind, so + # the scan threw CommandNotFoundException on its normal success path. + It 'Has no calls to undefined internal commands ()' -ForEach @( + @{ CommandPlatform = 'host'; HideWindowsCommands = $false } + @{ CommandPlatform = 'without Windows cmdlets'; HideWindowsCommands = $true } + ) { + $files = Get-ChildItem -Path $script:ModuleRoot -Recurse -Include *.ps1, *.psm1 -File + + # Sibling private functions live one level up and are legitimate callees. + $defFiles = Get-ChildItem -Path (Join-Path $script:ModuleRoot '..') -Recurse -Include *.ps1, *.psm1 -File + + $defined = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + $optional = [System.Collections.Generic.HashSet[string]]::new( + [System.StringComparer]::OrdinalIgnoreCase) + $called = @{} + + foreach ($file in $defFiles) { + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $file.FullName, [ref]$null, [ref]$null) + foreach ($fn in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) { + [void]$defined.Add($fn.Name) + } + } + + foreach ($file in $files) { + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $file.FullName, [ref]$null, [ref]$null) + + foreach ($cmd in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)) { + $name = $cmd.GetCommandName() + if (-not $name) { continue } + if ((-not $IsWindows -or $HideWindowsCommands) -and $file.Name -eq 'Read-FinOpsHubData.ps1' -and + $name -in @('Get-Acl', 'Get-AuthenticodeSignature')) { continue } + + # A name probed through Get-Command is an optional callback, + # so its absence is deliberate rather than a broken call. + if ($name -eq 'Get-Command') { + foreach ($el in $cmd.CommandElements) { + if ($el -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + [void]$optional.Add($el.Value) + } + } + continue + } + + if (-not $called.ContainsKey($name)) { + $called[$name] = '{0}:{1}' -f $file.Name, $cmd.Extent.StartLineNumber + } + } + } + + # Az cmdlets are excluded so the assertion does not depend on which + # Az modules happen to be installed on the runner. + $unresolved = foreach ($name in $called.Keys) { + if ($defined.Contains($name)) { continue } + if ($optional.Contains($name)) { continue } + if ($name -match '^(Az|AzureRm)' -or $name -match '-Az') { continue } + if (-not ($HideWindowsCommands -and $name -in @('Get-Acl', 'Get-AuthenticodeSignature')) -and + (Get-Command -Name $name -ErrorAction SilentlyContinue)) { continue } + '{0} (called at {1})' -f $name, $called[$name] + } + + @($unresolved) -join "`n" | Should -BeNullOrEmpty + } + } + + Context 'Read-only scanner invariant' { + BeforeAll { + function Get-ScannerWriteCommand { + param([System.Management.Automation.Language.Ast]$Ast) + foreach ($command in $Ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)) { + $name = $command.GetCommandName() + if (-not $name) { continue } + $name = ($name -split '\\')[-1] + if (($name -match '-Az[A-Za-z0-9]*$' -and $name -notmatch '^(Get|Find|Search|Test|Measure|Read|Resolve)-Az' -and + $name -notin @('New-AzStorageContext', 'Invoke-AzRestMethod', 'Invoke-AzRestMethodWithRetry', 'Invoke-AzGraphQueryPage', 'Invoke-AzOperationalInsightsQuery')) -or $name -in @('Invoke-Expression', 'iex')) { + "$name at line $($command.Extent.StartLineNumber)" + } + if ($name -in @('Invoke-AzRestMethod', 'Invoke-AzRestMethodWithRetry', 'Invoke-WebRequest', 'Invoke-RestMethod')) { + for ($elementIndex = 1; $elementIndex -lt $command.CommandElements.Count; $elementIndex++) { + $element = $command.CommandElements[$elementIndex] + if ($element -isnot [System.Management.Automation.Language.CommandParameterAst] -or $element.ParameterName -ne 'Method') { continue } + $argument = if ($element.Argument) { $element.Argument } elseif ($elementIndex + 1 -lt $command.CommandElements.Count) { $command.CommandElements[$elementIndex + 1] } else { $null } + if ($argument -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $argument.Value -in @('PUT', 'PATCH', 'DELETE')) { + "$name $($argument.Value) at line $($command.Extent.StartLineNumber)" + } + } + } + } + } + } + + It 'Rejects mutating Azure commands in scanner modules and helpers' -Tag 'DeferredReview' { + $violations = @(foreach ($file in Get-ChildItem -LiteralPath (Join-Path $script:ModuleRoot 'modules') -Filter '*.ps1' -Recurse -File) { + $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$null, [ref]$null) + Get-ScannerWriteCommand -Ast $ast | ForEach-Object { "$($file.Name): $_" } + }) + $violations | Should -BeNullOrEmpty + } + + It 'Documents every private launcher parameter without executing the launcher' -Tag 'DeferredReview' { + $ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + $launcher = $ast.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $args[0].Name -eq 'Invoke-FinOpsMultitool' }, $true) + $help = $launcher.GetHelpContent() + $help.Synopsis | Should -Match 'local reports' + foreach ($parameter in $launcher.Body.ParamBlock.Parameters) { $help.Parameters.ContainsKey($parameter.Name.VariablePath.UserPath.ToUpperInvariant()) | Should -BeTrue } + } + + It 'Recognizes without executing it' -Tag 'DeferredReview' -ForEach @( + @{ Code = 'Set-AzVM -Name fixture'; Expected = 1 } + @{ Code = 'Az.Resources\Remove-AzResource -ResourceId fixture'; Expected = 1 } + @{ Code = 'Invoke-AzResourceAction -Action restart'; Expected = 1 } + @{ Code = 'Invoke-AzVMRunCommand -VMName fixture'; Expected = 1 } + @{ Code = 'Suspend-AzSqlDatabase -Name fixture'; Expected = 1 } + @{ Code = 'Resume-AzSqlDatabase -Name fixture'; Expected = 1 } + @{ Code = 'Repair-AzVmss -Name fixture'; Expected = 1 } + @{ Code = 'Export-AzContext -Path fixture.json'; Expected = 1 } + @{ Code = 'Select-AzSubscription -SubscriptionId fixture'; Expected = 1 } + @{ Code = 'iex "Set-AzVM -Name fixture"'; Expected = 1 } + @{ Code = 'Invoke-AzRestMethod -Method DELETE -Path /fixture'; Expected = 1 } + @{ Code = 'Invoke-WebRequest -Method:PATCH -Uri https://example.invalid'; Expected = 1 } + @{ Code = 'New-AzStorageContext -UseConnectedAccount -StorageAccountName fixture'; Expected = 0 } + @{ Code = 'Invoke-AzRestMethod -Method POST -Path /providers/Microsoft.CostManagement/query'; Expected = 0 } + @{ Code = 'Get-AzVM; "Set-AzVM is documentation, not a call"'; Expected = 0 } + ) { + $ast = [System.Management.Automation.Language.Parser]::ParseInput($Code, [ref]$null, [ref]$null) + @(Get-ScannerWriteCommand -Ast $ast).Count | Should -Be $Expected + } + } + + Context 'Tag inventory evidence' { + It 'Distinguishes from a complete tag inventory' -ForEach @( + @{ Failure = 'none'; Incomplete = $false } + @{ Failure = 'tag values'; Incomplete = $true } + @{ Failure = 'untagged count'; Incomplete = $true } + @{ Failure = 'untagged details'; Incomplete = $true } + @{ Failure = 'total count'; Incomplete = $true } + @{ Failure = 'locations'; Incomplete = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Failure = $Failure; Incomplete = $Incomplete } { + param($Failure, $Incomplete) + $fixtureFailure = $Failure + Mock Search-AzGraphSafe { + if ($Query -like '*ResourceTypes*') { + if ($fixtureFailure -eq 'tag values') { throw 'Synthetic tag values failure.' } + return @{ Data = @([pscustomobject]@{ tagName = 'CostCenter'; tagValue = 'team'; ResourceCount = 3; ResourceTypes = @('fixture') }) } + } + if ($Query -like '*by tagName, subscriptionId*') { + if ($fixtureFailure -eq 'locations') { throw 'Synthetic locations failure.' } + return @{ Data = @([pscustomobject]@{ tagName = 'CostCenter'; subscriptionId = '11111111-1111-1111-1111-111111111111'; resourceGroup = 'fixture' }) } + } + if ($Query -like '*TotalCount*') { + if ($fixtureFailure -eq 'total count') { throw 'Synthetic total count failure.' } + return @{ Data = @([pscustomobject]@{ TotalCount = 4 }) } + } + if ($fixtureFailure -eq 'untagged details') { throw 'Synthetic untagged details failure.' } + @{ Data = @([pscustomobject]@{ name = 'untagged'; type = 'fixture'; resourceGroup = 'fixture'; subscriptionId = '11111111-1111-1111-1111-111111111111'; location = 'eastus' }) } + } + Mock Invoke-AzRestMethodWithRetry { + $queryText = ($Payload | ConvertFrom-Json).query + if (($fixtureFailure -eq 'untagged count' -and $queryText -like '*UntaggedCount*') -or + ($fixtureFailure -eq 'total count' -and $queryText -match 'TotalCount|TaggedCount')) { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + $column = if ($queryText -like '*UntaggedCount*') { 'UntaggedCount' } elseif ($queryText -like '*TaggedCount*') { 'TaggedCount' } else { 'TotalCount' } + $count = if ($column -eq 'UntaggedCount') { 1 } elseif ($column -eq 'TaggedCount') { 3 } else { 4 } + [pscustomobject]@{ StatusCode = 200; Content = (@{ data = @(@{ $column = $count }) } | ConvertTo-Json -Depth 5) } + } + + $result = Get-TagInventory -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result.CoverageIncomplete | Should -Be $Incomplete + if ($Incomplete) { + $result.TagCoverage | Should -BeNullOrEmpty + $result.ReadErrors.Count | Should -BeGreaterThan 0 + $result.Note | Should -Match 'incomplete' + } + else { $result.TagCoverage | Should -Be 75; $result.TotalResources | Should -Be 4 } + if ($Failure -ne 'tag values') { $result.TagNames.CostCenter.Values[0].Value | Should -Be 'team' } + } + } + } + + Context 'Anomaly alert evidence' { + It 'Preserves alert labels and counts only anomaly notification rules' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + $items = if ($Path -like '*alerts?*') { + @( + @{ name = 'first'; properties = @{ description = 'Describe this alert'; status = 'Active'; definition = @{ type = 'Anomaly' }; details = @{ amount = 0; currentSpend = 0; unit = 'EUR' } } } + @{ name = 'second'; properties = @{ costEntityId = '/budgets/monthly'; definition = @{ type = 'Budget' } } } + @{ name = 'third'; properties = @{ definition = @{ category = 'Cost'; type = 'Budget' } } } + @{ name = 'fourth'; properties = @{} } + ) + } + else { @(@{ name = 'rule'; kind = 'InsightAlert'; properties = @{ status = 'Enabled' } }, @{ name = 'other'; kind = 'Email'; properties = @{} }) } + [pscustomobject]@{ StatusCode = 200; Content = (@{ value = $items } | ConvertTo-Json -Depth 9) } + } + + $result = Get-AnomalyAlerts -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result.CoverageIncomplete | Should -BeFalse + $result.AnomalyAlertCount | Should -Be 1 + $result.BudgetAlertCount | Should -Be 2 + $result.ActiveAlertCount | Should -Be 1 + $result.ConfiguredRuleCount | Should -Be 1 + $result.TriggeredAlerts.AlertLabel | Should -Be @('Describe this alert', 'monthly (Budget)', 'Cost Budget', 'fourth') + $result.TriggeredAlerts[0].Amount | Should -Be 0 + $result.TriggeredAlerts[1].Amount | Should -BeNullOrEmpty + $result.TriggeredAlerts[1].Unit | Should -BeNullOrEmpty + } + } + + Context 'Carbon measurement evidence' { + It 'Preserves carbon data without inventing headline values' -ForEach @( + @{ Scenario = 'complete'; Incomplete = $false } + @{ Scenario = 'failed headline'; Incomplete = $true } + @{ Scenario = 'denied probes'; Incomplete = $true } + @{ Scenario = 'zero baseline'; Incomplete = $false } + @{ Scenario = 'failed recent probe'; Incomplete = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Scenario = $Scenario; Incomplete = $Incomplete } { + param($Scenario, $Incomplete) + $fixtureScenario = $Scenario + $probeState = @{ SummaryCalls = 0; Windows = [Collections.Generic.List[string]]::new() } + Mock Invoke-AzRestMethodWithRetry { + $request = $Payload | ConvertFrom-Json + if ($fixtureScenario -eq 'denied probes') { return [pscustomobject]@{ StatusCode = 403; Content = '{}' } } + if ($request.reportType -eq 'OverallSummaryReport') { + $probeState.SummaryCalls++ + $probeState.Windows.Add([string]$request.dateRange.end) + if ($probeState.SummaryCalls -eq 1) { + if ($fixtureScenario -eq 'failed recent probe') { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + return [pscustomobject]@{ StatusCode = 200; Content = '{"value":[]}' } + } + if ($fixtureScenario -eq 'failed headline' -and $probeState.SummaryCalls -gt 2) { return [pscustomobject]@{ StatusCode = 503; Content = '{}' } } + $value = @(@{ latestMonthEmissions = 50; previousMonthEmissions = $(if ($fixtureScenario -eq 'zero baseline') { 0 } else { 25 }) }) + } + elseif ($request.reportType -eq 'ItemDetailsReport') { $value = @(@{ itemName = '11111111-1111-1111-1111-111111111111'; latestMonthEmissions = 50 }) } + else { $value = @(@{ date = $request.dateRange.end; totalCarbonEmission = 50 }) } + [pscustomobject]@{ StatusCode = 200; Content = (@{ value = $value } | ConvertTo-Json -Depth 6) } + } + + $result = Get-CarbonMetrics -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result.CoverageIncomplete | Should -Be $Incomplete + if ($Incomplete) { + if ($Scenario -eq 'failed recent probe') { $result.TotalEmissionsKg | Should -Be 50; $result.LatestMonth | Should -Be $probeState.Windows[1].Substring(0, 7) } + else { $result.TotalEmissionsKg | Should -BeNullOrEmpty; $result.ChangeRatio | Should -BeNullOrEmpty } + $result.Note | Should -Match '403|503' + } + else { + $result.TotalEmissionsKg | Should -Be 50 + $result.LatestMonth | Should -Be $probeState.Windows[1].Substring(0, 7) + ([datetime]$probeState.Windows[1]) | Should -BeLessThan ([datetime]$probeState.Windows[0]) + if ($Scenario -eq 'zero baseline') { $result.ChangeRatio | Should -BeNullOrEmpty; $result.ChangeValueKg | Should -Be 50 } + else { $result.ChangeRatio | Should -Be 100 } + } + } + } + } + + Context 'Idle VM metric evidence' { + It 'Classifies only from measured CPU and network' -ForEach @( + @{ Case = 'idle'; Cpu = 2; Network = 1MB; Classification = 'Idle'; Failed = $false } + @{ Case = 'underutilized'; Cpu = 7; Network = 1MB; Classification = 'Underutilized'; Failed = $false } + @{ Case = 'network active'; Cpu = 4; Network = 20MB; Classification = 'Underutilized'; Failed = $false } + @{ Case = 'busy'; Cpu = 20; Network = 1MB; Classification = $null; Failed = $false } + @{ Case = 'measured zero'; Cpu = 0; Network = 0; Classification = 'Idle'; Failed = $false } + @{ Case = 'missing CPU'; Cpu = $null; Network = 1MB; Classification = $null; Failed = $true } + @{ Case = 'missing network'; Cpu = 2; Network = $null; Classification = $null; Failed = $true } + @{ Case = 'denied'; Cpu = 2; Network = 1MB; Classification = $null; Failed = $true } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Case = $Case; Cpu = $Cpu; Network = $Network; Classification = $Classification; Failed = $Failed } { + param($Case, $Cpu, $Network, $Classification, $Failed) + $fixtureCase = $Case + $fixtureCpu = $Cpu + $fixtureNetwork = $Network + Mock Search-AzGraphSafe { + @{ Data = @([pscustomobject]@{ name = 'fixture'; resourceGroup = 'fixture'; subscriptionId = '11111111-1111-1111-1111-111111111111'; location = 'eastus'; powerState = 'PowerState/running'; vmSize = 'Standard_D2s_v5' }) } + } + Mock Get-PlainAccessToken { 'synthetic-token' } + Mock Invoke-WebRequest { + if ($fixtureCase -eq 'denied') { throw 'Synthetic HTTP 403.' } + $metrics = @( + @{ name = @{ value = 'Percentage CPU' }; timeseries = @(@{ data = @(@{ average = $fixtureCpu }) }) } + @{ name = @{ value = 'Network In Total' }; timeseries = @(@{ data = @(@{ total = $fixtureNetwork }) }) } + @{ name = @{ value = 'Network Out Total' }; timeseries = @(@{ data = @(@{ total = 0 }) }) } + ) + [pscustomobject]@{ Content = (@{ value = $metrics } | ConvertTo-Json -Depth 8) } + } + + $result = Get-IdleVMs -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result.MetricFailures | Should -Be ([int]$Failed) + $result.EvaluatedVMs | Should -Be (1 - [int]$Failed) + if ($Classification) { $result.IdleVMs[0].Classification | Should -Be $Classification } + else { $result.Count | Should -Be 0 } + Should -Invoke Invoke-WebRequest -Times 0 -Exactly -ParameterFilter { $MaximumRedirection -ne 0 } + } + } + + It 'Skips metrics when all virtual machines are deallocated' { + Mock Search-AzGraphSafe -ModuleName FinOpsMultitool { @{ Data = @([pscustomobject]@{ powerState = 'PowerState/deallocated' }) } } + Mock Get-PlainAccessToken -ModuleName FinOpsMultitool { throw 'No token should be requested.' } + + $result = Get-IdleVMs -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111' }) + + $result.TotalVMs | Should -Be 1 + $result.Note | Should -Match 'none are running' + Should -Invoke Get-PlainAccessToken -ModuleName FinOpsMultitool -Times 0 -Exactly + } + } + + Context 'Scanner domain behavior' { + It 'Calculates AHB rates from matching Windows and Linux consumption meters and caches by SKU and region' -Tag 'DeferredReview' { + InModuleScope FinOpsMultitool { + $script:AhbRateCache = @{} + Mock Invoke-RestMethod { + @{ Items = @( + @{ skuName = 'Example Spot'; meterName = 'Example Spot'; productName = 'Virtual Machines Example Windows'; unitPrice = 0.01 } + @{ skuName = 'Example'; meterName = 'Example Low Priority'; productName = 'Virtual Machines Example'; unitPrice = 0.01 } + @{ skuName = 'Example'; meterName = 'Example'; productName = 'Virtual Machines Example Windows'; unitPrice = 0 } + @{ skuName = 'Example'; meterName = 'Example'; productName = 'Virtual Machines Example Windows'; unitPrice = 0.4 } + @{ skuName = 'Example'; meterName = 'Example'; productName = 'Virtual Machines Example'; unitPrice = 0.2 } + ) + } + } + + $rates = Get-AhbVmRates -VmSize 'Standard_Example' -Region 'eastus' + $rates.WindowsRate | Should -Be 0.4 + $rates.LinuxRate | Should -Be 0.2 + $rates.HourlyPremium | Should -Be 0.2 + $rates.Ratio | Should -Be 0.5 + Get-AhbVmSavingsRatio -VmSize 'standard_example' -Region 'EASTUS' | Should -Be 0.5 + $null = Get-AhbVmRates -VmSize 'Standard_Example' -Region 'westus' + $null = Get-AhbVmRates -VmSize 'Standard_Other' -Region 'eastus' + Should -Invoke Invoke-RestMethod -Times 3 -Exactly + Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { [uri]::UnescapeDataString($Uri) -like "*armRegionName eq 'eastus' and armSkuName eq 'Standard_Example' and priceType eq 'Consumption' and serviceName eq 'Virtual Machines'*" } + } + } + + It 'Retains the documented AHB fallback for ' -Tag 'DeferredReview' -ForEach @( + @{ Scenario = 'no prices'; Failure = 'empty' } + @{ Scenario = 'missing Linux price'; Failure = 'windows only' } + @{ Scenario = 'invalid premium'; Failure = 'reversed' } + @{ Scenario = 'lookup failure'; Failure = 'throw' } + ) { + InModuleScope FinOpsMultitool -Parameters @{ Failure = $Failure } { + param($Failure) + $fixtureFailure = $Failure + $script:AhbRateCache = @{} + Mock Invoke-RestMethod { + if ($fixtureFailure -eq 'throw') { throw 'Synthetic retail lookup failure.' } + $items = @() + if ($fixtureFailure -ne 'empty') { $items += @{ skuName = 'Example'; meterName = 'Example'; productName = 'Virtual Machines Example Windows'; unitPrice = 0.4 } } + if ($fixtureFailure -eq 'reversed') { $items += @{ skuName = 'Example'; meterName = 'Example'; productName = 'Virtual Machines Example'; unitPrice = 0.8 } } + @{ Items = $items } + } + + Get-AhbVmRates -VmSize 'Standard_Example' -Region 'eastus' | Should -BeNullOrEmpty + Get-AhbVmSavingsRatio -VmSize 'Standard_Example' -Region 'eastus' | Should -Be 0.6 + Should -Invoke Invoke-RestMethod -Times 1 -Exactly + } + } + + It 'Prioritizes high-impact legacy resources and preserves category counts' { + Mock Search-AzGraphSafe -ModuleName FinOpsMultitool { + $rows = if ($Query -like '*publicipaddresses*') { @([pscustomobject]@{ name = 'basic-ip'; sku = 'Basic' }) } + elseif ($Query -like '*microsoft.compute/disks*') { @([pscustomobject]@{ name = 'hdd'; diskSizeGb = 256; sku = 'Standard_LRS' }) } + else { @() } + @{ Data = $rows } + } + + $result = Get-LegacyResources -Subscriptions @([pscustomobject]@{ Id = 'fixture' }) + + $result.TotalCount | Should -Be 2 + $result.LegacyResources[0].Impact | Should -Be 'High' + $result.LegacyResources[1].Detail | Should -Match '256 GB' + @($result.ByCategory | Where-Object Count -EQ 1).Count | Should -Be 2 + } + + It 'Labels AHB retail estimates and preserves unavailable per-VM rates' { + Mock Search-AzGraphSafe -ModuleName FinOpsMultitool { + @{ Data = $(if ($Query -like '*microsoft.compute/virtualmachines*') { + @([pscustomobject]@{ name = 'priced'; vmSize = 'known'; location = 'eastus' }, [pscustomobject]@{ name = 'unpriced'; vmSize = 'unknown'; location = 'eastus' }) + } + else { @() }) + } + } + Mock Get-AhbVmRates -ModuleName FinOpsMultitool { if ($VmSize -eq 'known') { @{ HourlyPremium = 0.1 } } } + + $result = Get-AHBOpportunities -Subscriptions @([pscustomobject]@{ Id = 'fixture' }) + + $result.TotalOpportunities | Should -Be 2 + $result.EstMonthlyVMSavings | Should -Be 73 + $result.SavingsCurrency | Should -Be 'USD' + ($result.WindowsVMs | Where-Object name -EQ 'unpriced').estMonthlySavings | Should -BeNullOrEmpty + } + + It 'Matches tag spelling and variants without losing the original tag name' { + $result = Get-TagRecommendations -ExistingTags @{ 'COST-CENTER' = @{}; 'BusinessUnit' = @{} } -TagLocations @{ 'COST-CENTER' = @('Fixture / rg') } + + $result.Present.Count | Should -Be 2 + $result.MissingRequired.Count | Should -Be 5 + $result.CompliancePercent | Should -Be 29 + $costCenter = $result.Analysis | Where-Object TagName -EQ 'CostCenter' + $costCenter.Status | Should -Match 'Variation found' + $costCenter.ActualTagName | Should -BeExactly 'COST-CENTER' + $costCenter.Location | Should -Be 'Fixture / rg' + } + + It 'Allocates a shared pool once per case-insensitive spoke and reconciles the split' { + Mock Resolve-SharedCostPool -ModuleName FinOpsMultitool { @([pscustomobject]@{ Id = 'pool'; Name = 'Pool'; Type = 'fixture'; SubscriptionId = 'hub' }) } + Mock Get-AllocationCostMaps -ModuleName FinOpsMultitool { @{ ByResource = @{ pool = 1000 }; BySub = @{ 'spoke-a' = 200; 'spoke-b' = 300 }; Currency = 'EUR'; Source = 'LiveApi'; Period = 'MonthToDate' } } + + $result = Get-SharedCostAllocation -SharedResourceIds 'pool' -Spokes @('spoke-a', 'spoke-b', 'SPOKE-A') -WeightingValues @{ 'spoke-a' = 3; 'spoke-b' = 1 } -FixedRatio 0.5 + + $result.Allocations.Count | Should -Be 2 + ($result.Allocations | Where-Object Spoke -EQ 'spoke-a').AllocatedShared | Should -Be 625 + ($result.Allocations | Where-Object Spoke -EQ 'spoke-b').SolutionCost | Should -Be 675 + ($result.Allocations | Measure-Object AllocatedShared -Sum).Sum | Should -Be 1000 + ($result.RuleTargets | Measure-Object percentage -Sum).Sum | Should -Be 100 + $result.Currency | Should -Be 'EUR' + } + + It 'Keeps usage-weighted allocation nonnegative and reconciles across consumers' -ForEach @( + @{ Amount = 100; Consumers = 3 } + @{ Amount = 0.03; Consumers = 5 } + @{ Amount = 0.01; Consumers = 9 } + ) { + $fixtureConsumers = $Consumers + Mock Get-TelemetryWeighting -ModuleName FinOpsMultitool { + $weights = @{} + foreach ($consumer in 1..$fixtureConsumers) { $weights["consumer-$consumer"] = 1 } + [pscustomobject]@{ Ok = $true; Weights = $weights; ConsumerDimension = 'KubernetesNamespace'; Source = 'Fixture'; Note = '' } + } + Mock Resolve-SharedCostPool -ModuleName FinOpsMultitool { throw 'Explicit pools must not query resources.' } + + $result = Get-UsageProportionalAllocation -WorkspaceId 'fixture' -PoolAmount $Amount -PoolCurrency 'EUR' -PoolPeriod '2026-08' + + ($result.Allocations | Measure-Object AllocatedCost -Sum).Sum | Should -Be $Amount + @($result.Allocations | Where-Object { $_.AllocatedCost -lt 0 }).Count | Should -Be 0 + $result.Currency | Should -Be 'EUR' + $result.Period | Should -Be '2026-08' + $result.Mode | Should -Be 'Showback' + $result.BillingWritable | Should -BeFalse + $result.RuleTargets | Should -BeNullOrEmpty + Should -Invoke Resolve-SharedCostPool -ModuleName FinOpsMultitool -Times 0 -Exactly + } + } + + Context 'Storage tier metric evidence' { + It 'Distinguishes from valid activity measurements' -ForEach @( + @{ Case = 'empty transactions'; TransactionPoints = @(); CapacityPoints = @(@{ average = 10GB }); ExpectedFailures = 1; ExpectedRecommendations = 0 } + @{ Case = 'null transactions'; TransactionPoints = @(@{ total = $null }); CapacityPoints = @(@{ average = 10GB }); ExpectedFailures = 1; ExpectedRecommendations = 0 } + @{ Case = 'invalid transactions'; TransactionPoints = @(@{ total = 'invalid' }); CapacityPoints = @(@{ average = 10GB }); ExpectedFailures = 1; ExpectedRecommendations = 0 } + @{ Case = 'empty capacity'; TransactionPoints = @(@{ total = 0 }); CapacityPoints = @(); ExpectedFailures = 1; ExpectedRecommendations = 0 } + @{ Case = 'negative capacity'; TransactionPoints = @(@{ total = 0 }); CapacityPoints = @(@{ average = -1 }); ExpectedFailures = 1; ExpectedRecommendations = 0 } + @{ Case = 'measured zero transactions'; TransactionPoints = @(@{ total = 0 }); CapacityPoints = @(@{ average = 10GB }); ExpectedFailures = 0; ExpectedRecommendations = 1 } + @{ Case = 'measured active storage'; TransactionPoints = @(@{ total = 2000 }); CapacityPoints = @(@{ average = 10GB }); ExpectedFailures = 0; ExpectedRecommendations = 0 } + ) { + $fixtureTransactions = $TransactionPoints + $fixtureCapacity = $CapacityPoints + Mock Search-AzGraphSafe -ModuleName FinOpsMultitool { + @{ Data = @([pscustomobject]@{ name = 'fixture'; resourceGroup = 'fixture'; subscriptionId = '11111111-1111-1111-1111-111111111111'; accessTier = 'Hot'; sku = 'Standard_LRS' }) } + } + Mock Get-PlainAccessToken -ModuleName FinOpsMultitool { 'synthetic-token' } + Mock Invoke-WebRequest -ModuleName FinOpsMultitool { + $points = if ($Uri -like '*metricnames=Transactions*') { $fixtureTransactions } else { $fixtureCapacity } + [pscustomobject]@{ Content = (@{ value = @(@{ timeseries = @(@{ data = @($points) }) }) } | ConvertTo-Json -Depth 8) } + } + + $result = Get-StorageTierAdvice -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + + $result.MetricFailures | Should -Be $ExpectedFailures + $result.EvaluatedAccounts | Should -Be (1 - $ExpectedFailures) + @($result.Recommendations).Count | Should -Be $ExpectedRecommendations + Should -Invoke Invoke-WebRequest -ModuleName FinOpsMultitool -Times 0 -Exactly -ParameterFilter { $MaximumRedirection -ne 0 } + } + } + + Context 'Allocation percentages' { + + It 'Normalizes uneven shares to exactly 100' { + $r = ConvertTo-AllocationPercentage -Targets @( + @{ subscriptionId = 'a'; allocatedShared = 33.333 } + @{ subscriptionId = 'b'; allocatedShared = 33.333 } + @{ subscriptionId = 'c'; allocatedShared = 33.334 } + ) + + $r.Ok | Should -BeTrue + ($r.Values | ForEach-Object { $_.percentage } | Measure-Object -Sum).Sum | Should -Be 100 + } + + It 'Reports failure instead of inventing percentages' { + (ConvertTo-AllocationPercentage -Targets @()).Ok | Should -BeFalse + (ConvertTo-AllocationPercentage -Targets @( + @{ subscriptionId = 'a'; allocatedShared = 0 } + )).Ok | Should -BeFalse + } + } + + Context 'Automatic report storage' { + BeforeAll { + $launcherAst = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $script:ModuleRoot 'Invoke-FinOpsMultitool.ps1'), [ref]$null, [ref]$null) + foreach ($definition in $launcherAst.FindAll({ + $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $args[0].Name -in @('Get-FinOpsReportRoot', 'Assert-FinOpsReportPath', 'New-FinOpsReportDirectory', 'Write-FinOpsReportFile', + 'Show-ResultsSummary', 'Show-Banner', 'Write-SectionHeader', 'Write-ColorizedLine', 'Write-FinOpsConsole', 'Protect-FinOpsExportText', 'ConvertTo-FinOpsExportCell', 'ConvertTo-FinOpsExportRows') + }, $true)) { . ([scriptblock]::Create($definition.Extent.Text)) } + + function Get-ReportLayoutFixture { + $subscriptions = @(foreach ($scopeIndex in 1..85) { + [pscustomobject]@{ + Id = '00000000-0000-0000-0000-{0:D12}' -f $scopeIndex + Name = 'Example subscription {0:D3}' -f $scopeIndex + TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + } + }) + $values = @(foreach ($valueIndex in 1..240) { + [pscustomobject]@{ Value = 'Example team {0:D3}' -f $valueIndex; ResourceCount = 241 - $valueIndex } + }) + $technicalTag = 'hidden-link:/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-synthetic-long-resource-group/providers/Microsoft.Web/sites/synthetic-monitoring-association' + $tagNames = @{ + Owner = [pscustomobject]@{ TotalResources = 28920; Values = $values } + CostCenter = [pscustomobject]@{ TotalResources = 5; Values = @([pscustomobject]@{ Value = 'Example '; ResourceCount = 5 }) } + $technicalTag = [pscustomobject]@{ TotalResources = 5; Values = @([pscustomobject]@{ Value = 'Resource'; ResourceCount = 5 }) } + } + foreach ($tagIndex in 1..82) { + $tagNames[('Example field {0:D3}' -f $tagIndex)] = [pscustomobject]@{ TotalResources = 1; Values = @([pscustomobject]@{ Value = 'Example value'; ResourceCount = 1 }) } + } + $assignments = @(foreach ($assignmentIndex in 1..40) { + [pscustomobject]@{ + AssignmentName = 'Example assignment {0:D3}' -f $assignmentIndex + AssignmentId = "/subscriptions/$($subscriptions[$assignmentIndex - 1].Id)/providers/Microsoft.Authorization/policyAssignments/fixture" + Scope = "/subscriptions/$($subscriptions[$assignmentIndex - 1].Id)" + Source = 'Initiative' + EnforcementMode = 'Default' + } + }) + $costs = @{} + $resourceCosts = @(foreach ($subscription in $subscriptions) { + $costs[$subscription.Id] = @{ Name = $subscription.Name; Actual = 100; Forecast = 120; ForecastSource = 'Forecast'; Currency = 'USD'; ActualPeriod = 'Synthetic month-to-date window' } + foreach ($resourceIndex in 1..5) { + [pscustomobject]@{ Subscription = $subscription.Name; SubscriptionId = $subscription.Id; ResourcePath = "/subscriptions/$($subscription.Id)/resourceGroups/rg-synthetic/providers/Microsoft.Compute/virtualMachines/example-resource-$resourceIndex"; ResourceType = 'Virtual Machine'; ResourceGroup = 'rg-synthetic'; Actual = 10 * $resourceIndex; Currency = 'USD'; ActualPeriod = 'Synthetic month-to-date window' } + } + }) + $analysis = @( + [pscustomobject]@{ + DisplayName = 'Example cost-allocation policy'; Status = 'Assigned (Initiative)'; Category = 'Tags'; Priority = 'Required'; DefaultEffect = 'Audit' + MatchedAssignments = $assignments; Purpose = 'Track cost allocation across the selected subscriptions.'; Note = 'Synthetic fixture, no Azure queries.' + } + [pscustomobject]@{ + DisplayName = 'Example regional governance policy'; Status = 'Missing'; Category = 'Governance'; Priority = 'Recommended'; DefaultEffect = 'Deny' + MatchedAssignments = @(); Purpose = 'Review the resource locations allowed by the current assignments.'; Note = 'Example note.' + } + ) + @{ + Subscriptions = $subscriptions + Modules = @( + @{ Fn = 'Get-CostData'; Name = 'Cost Data'; Selected = $true; Category = 'Cost Analysis' } + @{ Fn = 'Get-ResourceCosts'; Name = 'Resource Costs'; Selected = $true; Category = 'Cost Analysis' } + @{ Fn = 'Get-TagInventory'; Name = 'Tag Inventory'; Selected = $true; Category = 'Governance' } + @{ Fn = 'Get-PolicyRecommendations'; Name = 'Policy Recommendations'; Selected = $true; Category = 'Governance' } + ) + Results = @{ + 'Get-CostData' = $costs + 'Get-ResourceCosts' = $resourceCosts + 'Get-TagInventory' = [pscustomobject]@{ + TagNames = $tagNames; TagCount = 85; SpellingCount = 115; TotalResources = 30000; TaggedCount = 28920; UntaggedCount = 1080; TagCoverage = 96.4; CoverageIncomplete = $false + CaseVariants = @(foreach ($variantIndex in 1..30) { [pscustomobject]@{ TagKey = "ExampleTag$variantIndex"; Detail = "ExampleTag$variantIndex (1), EXAMPLETAG$variantIndex (1)" } }) + } + 'Get-PolicyRecommendations' = [pscustomobject]@{ + Analysis = $analysis; Assigned = @($analysis[0]); Missing = @($analysis[1]); TotalRecommended = 2; CompliancePct = 50; CoverageIncomplete = $false + } + } + } + } + } + + It 'Sorts credits and unavailable values using the actual report JavaScript' -Tag 'ReportGridSorting' -Skip:(-not (Get-Command node -ErrorAction SilentlyContinue)) { + $source = [IO.File]::ReadAllText((Join-Path $script:ModuleRoot 'Invoke-FinOpsMultitool.ps1')) + $functions = [regex]::Match($source, '(?s) function parseGridNumber\(text\).*?(?= function updateRows\(\))').Value + $functions | Should -Not -BeNullOrEmpty + $inputData = @{ + source = $functions + cases = @( + @{ text = '($500.00)'; expected = -500 } + @{ text = '-$500.00'; expected = -500 } + @{ text = '$-500.00'; expected = -500 } + @{ text = 'USD -500.00'; expected = -500 } + @{ text = 'USD 2,500.50'; expected = 2500.5 } + @{ text = '0%'; expected = 0 } + @{ text = 'USD 1E-12'; expected = 1e-12 } + ) + } | ConvertTo-Json -Depth 5 -Compress + $runner = @' +const vm = require('node:vm'); +const assert = require('node:assert/strict'); +const input = JSON.parse(require('node:fs').readFileSync(0, 'utf8')); +const context = { sortColumn: 0, sortDirection: 1, collator: new Intl.Collator('en-US', { numeric: true, sensitivity: 'base' }) }; +vm.createContext(context); +new vm.Script(input.source).runInContext(context); +for (const sample of input.cases) { assert.equal(context.parseGridNumber(sample.text).value, sample.expected); } +assert.equal(context.parseGridNumber('Unavailable'), null); +const values = ['$20.00', '($500.00)', '-$10.00', '$0.00', 'Unavailable']; +const rows = values.map((text, index) => ({ cells: [{}, { textContent: text }], getAttribute: () => String(index) })); +assert.deepEqual(rows.slice().sort(context.compareCells).map(row => row.cells[1].textContent), ['($500.00)', '-$10.00', '$0.00', '$20.00', 'Unavailable']); +context.sortDirection = -1; +assert.deepEqual(rows.slice().sort(context.compareCells).map(row => row.cells[1].textContent), ['$20.00', '$0.00', '-$10.00', '($500.00)', 'Unavailable']); +console.log('Credit, numeric, and unavailable sorting passed'); +'@ + $output = $inputData | node -e $runner + $LASTEXITCODE | Should -Be 0 + $output | Should -Match 'sorting passed' + } + + It 'Keeps a large-tenant HTML report compact without losing exported detail' -Tag 'LargeReportLayout' { + $fixture = Get-ReportLayoutFixture + $reportRoot = Join-Path $TestDrive 'large-report-layout' + Mock Write-Host { } + Set-Variable -Name permissionInfo -Value @{} -Scope Local + + $null = Show-ResultsSummary @fixture -ExportPath $reportRoot -DataSourceLabel 'Synthetic fixture, no Azure queries' -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html.Contains('
') | Should -BeTrue + $html.Contains('85 subscriptions') | Should -BeTrue + $html.Contains('Example subscription 085') | Should -BeTrue + $metadataLine = [regex]::Match($html, '

.*?

').Value + $metadataLine | Should -Match 'Subscriptions: 85 selected' + $metadataLine | Should -Not -Match 'Example subscription' + $html.Contains('
') | Should -BeTrue + $html.Contains('30 tag-key spelling groups') | Should -BeTrue + $html.Contains('class="report-table table-tags"') | Should -BeTrue + $html.Contains('class="report-table table-policies"') | Should -BeTrue + $html.Contains('data-table-id="table-Get-TagInventory"') | Should -BeTrue + $html.Contains('id="filter-Get-TagInventory"') | Should -BeTrue + $html.Contains('data-page-action="next"') | Should -BeTrue + $html.Contains('data-table-id="table-story-costs"') | Should -BeTrue + $html.Contains('data-table-id="table-story-resources"') | Should -BeTrue + $html.Contains('235 more values') | Should -BeTrue + $html.Contains('Example team 240') | Should -BeTrue + $html.Contains('40 assignments') | Should -BeTrue + $html.Contains('Example assignment 040') | Should -BeTrue + $html.Contains('') | Should -BeFalse + $html.Contains('<script>fixture</script>') | Should -BeTrue + $html.Contains('') | Should -BeFalse + $html.Contains('<img src=x onerror=alert(1)>') | Should -BeTrue + [regex]::Matches($html, '
]*\bopen\b').Count | Should -Be 0 + $tagCsv = Get-Content -LiteralPath (Join-Path $run 'Get-TagInventory.csv') -Raw + $policyCsv = Get-Content -LiteralPath (Join-Path $run 'Get-PolicyRecommendations.csv') -Raw + $tagCsv.Contains('Example team 240') | Should -BeTrue + $policyCsv.Contains('Example assignment 040') | Should -BeTrue + $fixture.Results['Get-TagInventory'].TagNames.Owner.Values.Count | Should -Be 240 + $fixture.Results['Get-PolicyRecommendations'].Analysis[0].MatchedAssignments.Count | Should -Be 40 + } + + It 'Keeps a single selected subscription visible without a scope disclosure' -Tag 'LargeReportLayout' { + $fixture = Get-ReportLayoutFixture + $fixture.Subscriptions = @($fixture.Subscriptions[0]) + $reportRoot = Join-Path $TestDrive 'small-report-layout' + Mock Write-Host { } + Set-Variable -Name permissionInfo -Value @{} -Scope Local + + $null = Show-ResultsSummary @fixture -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html.Contains('
') | Should -BeFalse + $html.Contains('Example subscription 001 [00000000-0000-0000-0000-000000000001]') | Should -BeTrue + } + + It 'Groups each scan''s notes in one scrollable panel above its results' -Tag 'LargeReportLayout' { + $fixture = Get-ReportLayoutFixture + $reportRoot = Join-Path $TestDrive 'scan-notes-layout' + Mock Write-Host { } + Set-Variable -Name permissionInfo -Value @{} -Scope Local + + $null = Show-ResultsSummary @fixture -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match '\.scan-notes \{ max-height: 18rem; overflow-y: auto;' + $html | Should -Match '@media print \{[^@]*\.scan-notes \{ max-height: none; overflow: visible; \}' + $html | Should -Match '@media print \{[^@]*\.scan-notes \.detail-content \{ max-height: none; overflow: visible; \}' + $panelCount = [regex]::Matches($html, '
').Count + $panelCount | Should -BeGreaterThan 0 + [regex]::Matches($html, '
' + foreach ($scan in @( + @{ Fn = 'Get-TagInventory'; Name = 'Tag Inventory'; Notes = @('Coverage: 96.4%', '30 tag-key spelling groups', 'Values are the distinct tag values in use') } + @{ Fn = 'Get-PolicyRecommendations'; Name = 'Policy Recommendations'; Notes = @('Assignment coverage compares recommended definition IDs') } + )) { + $panelStart = $html.IndexOf("

$($scan.Name)

") + $panelStart | Should -BeGreaterOrEqual 0 -Because $scan.Fn + $resultsStart = $html.IndexOf("id=`"table-$($scan.Fn)`"") + foreach ($note in $scan.Notes) { + $noteIndex = $html.IndexOf($note, $panelStart) + $noteIndex | Should -BeGreaterThan $panelStart -Because $note + $noteIndex | Should -BeLessThan $resultsStart -Because $note + } + } + } + + It 'Splits formatted table rows cleanly for output' -Tag 'TableLineEndings' -ForEach @( + @{ LineEnding = 'CRLF'; Separator = "`r`n" } + @{ LineEnding = 'LF'; Separator = "`n" } + ) { + $splitters = @($launcherAst.FindAll({ + $args[0] -is [System.Management.Automation.Language.BinaryExpressionAst] -and + $args[0].Operator -eq 'Isplit' -and $args[0].Left.Extent.Text -eq '$_.TrimEnd()' + }, $true)) + $splitters.Count | Should -Be 3 + $expectedLines = @('Name Cost', '---- ----', 'demo-resource 12.50') + $formattedTable = ($expectedLines -join $Separator) + $Separator + $captured = [Collections.Generic.List[string]]::new() + Mock Write-Host { [void]$captured.Add([string]$Object) } + + foreach ($splitter in $splitters) { + $lines = @($formattedTable | ForEach-Object ([scriptblock]::Create($splitter.Extent.Text))) + $lines | Should -Be $expectedLines + foreach ($line in $lines) { Write-ColorizedLine -Text $line } + } + + ($captured -join '') | Should -Not -Match '\\u000[AD]|[\p{Cc}\p{Cf}]' + Should -Invoke Write-Host -Times 9 -Exactly -ParameterFilter { $Object -ne '' -and $NoNewline } + } + + It 'Escapes terminal control sequences while preserving host colors' -Tag 'TableLineEndings' { + $captured = [Collections.Generic.List[string]]::new() + Mock Write-Host { [void]$captured.Add([string]$Object) } + $payload = "$([char]27)[2J$([char]27)]52;c;synthetic$([char]7)$([char]0x202E)`r`n" + + Write-FinOpsConsole -Object $payload -ForegroundColor Yellow -NoNewline + + $captured[0] | Should -Not -Match '[\p{Cc}\p{Cf}]' + $captured[0] | Should -Match '\\u001B\[2J' + $captured[0] | Should -Match '\\u0007\\u202E' + $captured[0] | Should -Match '\\u000D\\u000A' + Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter { $ForegroundColor -eq 'Yellow' -and $NoNewline } + } + + It 'Preserves the banner line layout through safe console output' { + $captured = [Collections.Generic.List[string]]::new() + function Get-VersionNumber { '0.0.0' } + Mock Clear-Host { } + Mock Write-Host { [void]$captured.Add([string]$Object) } + + Show-Banner + + $captured.Count | Should -BeGreaterThan 15 + ($captured -join '') | Should -Not -Match '\\u000[AD]|[\p{Cc}\p{Cf}]' + } + + It 'Keeps hostile tag controls out of terminal output without changing scan data' { + $reportRoot = Join-Path $TestDrive 'terminal-controls' + $captured = [Collections.Generic.List[string]]::new() + Mock Write-Host { [void]$captured.Add([string]$Object) } + $payload = "$([char]27)[2Jsynthetic " + $inventory = ConvertTo-TagInventoryFromHub -HubData @([pscustomobject]@{ + ResourceId = '/resources/fixture'; ResourceType = 'Fixture'; Tags = (@{ CostCenter = $payload } | ConvertTo-Json -Compress) + }) + $modules = @(@{ Fn = 'Get-TagInventory'; Name = 'Tag Inventory'; Selected = $true; Category = 'Governance' }) + + $null = Show-ResultsSummary -Results @{ 'Get-TagInventory' = $inventory } -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + ($captured -join '') | Should -Not -Match '[\p{Cc}\p{Cf}]' + ($captured -join '') | Should -Match '\\u001B\[2J' + $inventory.TagNames.CostCenter.Values[0].Value | Should -BeExactly $payload + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match '<script>example</script>' + $html | Should -Not -Match '' + } + + It 'Keeps incomplete policy and storage evidence visible without healthy guidance' { + $reportRoot = Join-Path $TestDrive 'partial-evidence' + $captured = [Collections.Generic.List[string]]::new() + Mock Write-Host { [void]$captured.Add([string]$Object) } + $results = @{ + 'Get-PolicyInventory' = [pscustomobject]@{ Assignments = @(); AssignmentCount = 0; CoverageIncomplete = $false; ComplianceCoverageIncomplete = $true; CompliancePct = $null; TotalCompliant = 10; TotalNonCompliant = 0; HasComplianceData = $false; Note = 'Policy compliance coverage is incomplete.' } + 'Get-StorageTierAdvice' = [pscustomobject]@{ Recommendations = @(); TotalHotAccounts = 1; MetricFailures = 1; EvaluatedAccounts = 0; HasData = $false; MetricFailureDetail = @('No transaction measurements.') } + } + $modules = @( + @{ Fn = 'Get-PolicyInventory'; Name = 'Policy Inventory'; Selected = $true; Category = 'Governance' } + @{ Fn = 'Get-StorageTierAdvice'; Name = 'Storage Tier Advice'; Selected = $true; Category = 'Optimization' } + ) + + $null = Show-ResultsSummary -Results $results -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match 'Policy compliance coverage is incomplete' + $html | Should -Match 'Storage tier assessment is incomplete' + [regex]::Matches($html, '>Limited data').Count | Should -Be 2 + $html | Should -Not -Match 'Full policy compliance|All storage accounts are appropriately tiered' + ($captured -join ' ') | Should -Not -Match 'all are appropriately tiered|Compliance: 100' + (Import-Csv -LiteralPath (Join-Path $run 'Get-PolicyInventory.csv'))[0].'Summary.ComplianceCoverageIncomplete' | Should -Be 'True' + } + + It 'Names the unit-cost share denominator and captured period in report output' { + $reportRoot = Join-Path $TestDrive 'unit-cost-context' + $captured = [Collections.Generic.List[string]]::new() + Mock Write-Host { [void]$captured.Add([string]$Object) } + $data = [pscustomobject]@{ + HasData = $true; Currency = 'USD'; CostAvailable = $true + ComputeCost = 0.65; StorageCost = 12.26; ComputeSharePct = 5; StorageSharePct = 95 + CostPerVCpu = 0.08125; CostPerGbRam = 0.0203125; CostPerVm = 0.1625; CostPerGb = 0.03892063 + VmCount = 4; TotalVCpu = 8; TotalMemoryGb = 32; DiskGb = 300; BlobFileGb = 15; TotalStorageGb = 315 + CostPeriodStartUtc = [datetime]::new(2026, 9, 1, 0, 0, 0, [DateTimeKind]::Utc) + CostPeriodEndUtc = [datetime]::new(2026, 9, 24, 12, 0, 0, [DateTimeKind]::Utc) + Period = 'MonthToDate'; ScannedSubs = 1 + } + $modules = @(@{ Fn = 'Get-UnitEconomics'; Name = 'Unit Economics'; Selected = $true; Category = 'Cost Analysis' }) + + $null = Show-ResultsSummary -Results @{ 'Get-UnitEconomics' = $data } -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + foreach ($outputText in @($html, ($captured -join ' '))) { + $outputText | Should -Match '5% of VM compute \+ storage spend' + $outputText | Should -Match 'Subtotal: USD 12\.91' + $outputText | Should -Match '2026-09-01 00:00.*2026-09-24 12:00.*UTC' + $outputText | Should -Match 'Amortized cost' + $outputText | Should -Match 'not an efficiency score' + } + $html | Should -Match 'Calculation and thresholds' + $html | Should -Match 'current inventory' + $html | Should -Match 'Other Azure services are excluded' + } + + It 'Keeps policy definition failures visible in console, HTML, CSV, and text reports' -Tag 'PolicyDefinitionCoverage' { + $reportRoot = Join-Path $TestDrive 'policy-definition-coverage' + $captured = [System.Collections.Generic.List[string]]::new() + Mock Write-Host { [void]$captured.Add([string]$Object) } + Mock Write-Warning { } + Mock Write-Host -ModuleName FinOpsMultitool { } + Mock Write-Warning -ModuleName FinOpsMultitool { } + Mock Get-AzContext -ModuleName FinOpsMultitool { throw 'Report fixtures must not read an Azure context.' } + Mock Invoke-RestMethod -ModuleName FinOpsMultitool { throw 'Report fixtures must not make HTTP requests.' } + Mock Search-AzGraphSafe -ModuleName FinOpsMultitool { + [pscustomobject]@{ Data = @([pscustomobject]@{ subscriptionId = '11111111-1111-1111-1111-111111111111'; Total = 10; Compliant = 10; NonCompliant = 0 }) } + } + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { + if ($Path -eq '/subscriptions/11111111-1111-1111-1111-111111111111/providers/Microsoft.Authorization/policyAssignments?api-version=2022-06-01') { + return [pscustomobject]@{ StatusCode = 200; Content = (@{ + value = @(@{ + id = '/subscriptions/11111111-1111-1111-1111-111111111111/providers/Microsoft.Authorization/policyAssignments/fixture' + name = 'fixture' + properties = @{ displayName = 'Policy '; policyDefinitionId = '/providers/Microsoft.Authorization/policyDefinitions/unavailable' } + }) + } | ConvertTo-Json -Depth 8) + } + } + if ($Path -eq '/providers/Microsoft.Authorization/policyDefinitions/unavailable?api-version=2023-04-01') { + return [pscustomobject]@{ StatusCode = 503; Content = '{}' } + } + throw 'Unexpected request scope.' + } + $data = Get-PolicyInventory -TenantId 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' -Subscriptions @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Selected subscription'; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } + ) + $results = @{ 'Get-PolicyInventory' = $data } + $modules = @(@{ Fn = 'Get-PolicyInventory'; Name = 'Policy Inventory'; Selected = $true; Category = 'Governance' }) + + $null = Show-ResultsSummary -Results $results -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $runs = @(Get-ChildItem -LiteralPath $reportRoot -Directory) + $runs.Count | Should -Be 1 + $html = Get-Content -LiteralPath (Join-Path $runs[0].FullName 'FinOpsReport.html') -Raw + $summary = Get-Content -LiteralPath (Join-Path $runs[0].FullName 'ScanSummary.txt') -Raw + $csv = Get-Content -LiteralPath (Get-ChildItem -LiteralPath $runs[0].FullName -Filter '*.csv').FullName -Raw + $summary | Should -Match 'Policy Inventory: Limited data: Policy definition coverage is incomplete' + $html | Should -Match 'Limited data' + foreach ($outputText in @($html, ($captured -join ' '))) { + $outputText | Should -Match 'Policy definition coverage is incomplete' + $outputText | Should -Not -Match 'Strong governance posture' + } + $html | Should -Match 'Policy <fixture>' + $html | Should -Not -Match 'Policy ' + $csv | Should -Match 'DefinitionCoverageIncomplete' + $csv | Should -Match 'DefinitionErrors' + $csv | Should -Match 'HTTP 503' + $data.AssignmentCount | Should -Be 1 + $data.CompliancePct | Should -Be 100 + $data.ComplianceCoverageIncomplete | Should -BeFalse + Should -Invoke Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool -Times 2 -Exactly + Should -Invoke Get-AzContext -ModuleName FinOpsMultitool -Times 0 -Exactly + Should -Invoke Invoke-RestMethod -ModuleName FinOpsMultitool -Times 0 -Exactly + } + + It 'Explains screening thresholds and separates budget coverage from forecast availability' { + $reportRoot = Join-Path $TestDrive 'screening-context' + Mock Write-Host { } + $budgets = @(foreach ($budgetIndex in 1..6) { + [pscustomobject]@{ + BudgetName = "Budget $budgetIndex"; Amount = 100; ActualSpend = 25; Currency = 'USD'; PctUsed = 25 + Forecast = $(if ($budgetIndex -le 2) { 40 } else { $null }) + ForecastSource = $(if ($budgetIndex -le 2) { 'Budget' } else { 'Unavailable' }) + Risk = $(if ($budgetIndex -le 2) { 'On Track' } else { 'Forecast unavailable' }) + } + }) + $results = @{ + 'Get-IdleVMs' = [pscustomobject]@{ IdleVMs = @(); Count = 0; ScannedVMs = 2; EvaluatedVMs = 1; TotalVMs = 4; MetricFailures = 1; HasData = $false } + 'Get-StorageTierAdvice' = [pscustomobject]@{ Recommendations = @(); Count = 0; TotalHotAccounts = 8; EvaluatedAccounts = 7; MetricFailures = 1; HasData = $false } + 'Get-BudgetStatus' = [pscustomobject]@{ Budgets = $budgets; TotalBudgets = 6; AtRiskCount = 0; OverBudgetCount = 0; BudgetCoverage = 100; SubsWithBudget = 1; TotalSubs = 1; ScannedSubs = 1; CoverageIncomplete = $false } + } + $modules = @( + @{ Fn = 'Get-IdleVMs'; Name = 'Idle VMs'; Selected = $true; Category = 'Optimization' } + @{ Fn = 'Get-StorageTierAdvice'; Name = 'Storage Tier Advice'; Selected = $true; Category = 'Optimization' } + @{ Fn = 'Get-BudgetStatus'; Name = 'Budget Status'; Selected = $true; Category = 'Monitoring' } + ) + + $null = Show-ResultsSummary -Results $results -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $plain = [System.Net.WebUtility]::HtmlDecode($html) + $plain | Should -Match 'Evaluated: 1 of 2 running VMs' + $plain | Should -Match 'CPU <5% AND combined network <1 MiB/day' + $plain | Should -Match 'CPU <10% AND combined network <10 MiB/day' + $plain | Should -Match 'Evaluated: 7 of 8 storage accounts' + $plain | Should -Match 'fewer than 100 blob transactions' + $plain | Should -Match 'fewer than 1,000 blob transactions' + $plain | Should -Match 'not per-blob last-access analysis' + $plain | Should -Match 'Subscriptions with a budget: 100%' + $plain | Should -Match 'Forecasts available: 2 of 6' + $plain | Should -Match '4 unavailable' + $plain | Should -Match 'not an all-clear' + [regex]::Matches($html, 'Calculation and thresholds').Count | Should -Be 3 + } + + It 'Lists every KPI with context and honest run states without unsafe report links' { + $reportRoot = Join-Path $TestDrive 'kpi-reference' + Mock Write-Host { } + Set-Variable -Name permissionInfo -Value @{} -Scope Local + $catalog = Get-KpiCatalog + $payload = '' + $results = @{ + 'Get-IdleVMs' = [pscustomobject]@{ IdleVMs = @(); Count = 0; ScannedVMs = 1; EvaluatedVMs = 1; TotalVMs = 1; MetricFailures = 0; HasData = $false } + 'Get-StorageTierAdvice' = [pscustomobject]@{ Recommendations = @(); Count = 0; TotalHotAccounts = 1; EvaluatedAccounts = 1; MetricFailures = 0; HasData = $false } + '_error_Get-BudgetStatus' = "Synthetic failure $payload" + } + $modules = @( + @{ Fn = 'Get-IdleVMs'; Name = 'Idle VMs'; Selected = $true; Category = 'Optimization' } + @{ Fn = 'Get-StorageTierAdvice'; Name = 'Storage Tier Advice'; Selected = $true; Category = 'Optimization' } + @{ Fn = 'Get-BudgetStatus'; Name = 'Budget Status'; Selected = $true; Category = 'Monitoring' } + ) + + $null = Show-ResultsSummary -Results $results -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match 'data-target="tab-KpiReference">KPI reference' + [regex]::Matches($html, 'class="kpi-reference-row"').Count | Should -Be $catalog.kpis.Count + foreach ($kpi in $catalog.kpis) { + $html | Should -Match ('id="kpi-' + [regex]::Escape($kpi.id) + '"') + foreach ($field in @('calculation', 'interpretation', 'limitations')) { $kpi.$field | Should -Not -BeNullOrEmpty } + $kpi.requiredInputs.Count | Should -BeGreaterThan 0 + } + $html | Should -Match 'data-kpi-status="Computed"' + $html | Should -Match 'data-kpi-status="Unavailable"' + $html | Should -Match 'data-kpi-status="Not run"' + $html | Should -Match 'data-kpi-status="Informational"' + $html | Should -Match '0% of running VMs idle' + $html | Should -Match 'href="#scan-Get-IdleVMs"' + $html | Should -Not -Match 'href="#scan-Get-UnitEconomics"' + $html | Should -Match 'id="kpi-search"' + $html | Should -Match 'id="kpi-status-filter"' + $html | Should -Match '<img src=x onerror=alert\(1\)>' + $html | Should -Not -Match ']+\soninput=|<[^>]+\sonchange=' + $html | Should -Match 'No universal healthy value' + } + + It 'Keeps informational KPI status when a related scan fails' { + $results = @{ '_error_Get-StorageTierAdvice' = 'Synthetic storage read failure.' } + $modules = @(@{ Fn = 'Get-StorageTierAdvice'; Name = 'Storage Tier Advice'; Selected = $true; Category = 'Optimization' }) + + $entries = @(Get-FinOpsKpiReference -Results $results -Modules $modules -Insights @() | Where-Object SourceFunction -EQ 'Get-StorageTierAdvice') + + $entries.Count | Should -Be 2 + foreach ($entry in $entries) { + $entry.Status | Should -Be 'Informational' + $entry.Value | Should -Match 'does not calculate' + $entry.Context | Should -Match 'Synthetic storage read failure' + } + } + + It 'Keeps all report formats when the KPI catalog throws' { + $reportRoot = Join-Path $TestDrive 'catalog-failure' + Mock Write-Host { } + Mock Get-KpiCatalog { throw 'Synthetic malformed catalog.' } + Mock Get-KpiCatalog -ModuleName FinOpsMultitool { throw 'Synthetic malformed catalog.' } + $results = @{ 'Get-CostData' = @{ 'fixture' = @{ Actual = 10; Currency = 'EUR'; Forecast = $null; ForecastSource = 'Unavailable' } } } + $modules = @(@{ Fn = 'Get-CostData'; Name = 'Cost Data'; Selected = $true; Category = 'Cost Analysis' }) + + { $null = Show-ResultsSummary -Results $results -Modules $modules -ExportPath $reportRoot -ErrorAction Stop } | Should -Not -Throw + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match 'KPI reference unavailable' + $html | Should -Match 'Scan results remain available' + $html | Should -Match 'EUR 10[.,]00' + Test-Path -LiteralPath (Join-Path $run 'Get-CostData.csv') | Should -BeTrue + Get-Content -LiteralPath (Join-Path $run 'ScanSummary.txt') -Raw | Should -Match 'KPI reference unavailable' + } + + It 'Reports incompatible unit costs as unavailable while retaining capacity' { + $reportRoot = Join-Path $TestDrive 'mixed-unit-costs' + $data = [pscustomobject]@{ + HasData = $true; Currency = 'Mixed'; CostAvailable = $false; CostIssue = 'Multiple billing currencies cannot be combined.' + ComputeCost = $null; StorageCost = $null; ComputeSharePct = $null; StorageSharePct = $null + CostPerVCpu = $null; CostPerGbRam = $null; CostPerVm = $null; CostPerGb = $null + VmCount = 4; TotalVCpu = 8; TotalMemoryGb = 32; DiskGb = 0; BlobFileGb = 0.9; TotalStorageGb = 0.9 + Note = 'Multiple billing currencies cannot be combined.' + } + $modules = @(@{ Fn = 'Get-UnitEconomics'; Name = 'Unit Economics'; Selected = $true; Category = 'Cost Analysis' }) + + $null = Show-ResultsSummary -Results @{ 'Get-UnitEconomics' = $data } -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match 'Compute: Unavailable \(Unavailable\) over 4 VMs, 8 vCPU, 32 GB RAM' + $html | Should -Match 'Storage: Unavailable' + $html | Should -Match '>Limited data' + $row = @(Import-Csv -LiteralPath (Join-Path $run 'Get-UnitEconomics.csv'))[0] + $row.ComputeCost | Should -BeNullOrEmpty + $row.CostPerVCpu | Should -BeNullOrEmpty + $row.TotalVCpu | Should -Be '8' + } + + It 'Reports incompatible AI costs without hiding measured usage or inventing a permissions issue' { + $reportRoot = Join-Path $TestDrive 'mixed-ai-costs' + $data = [pscustomobject]@{ + HasData = $true; Currency = 'Mixed'; CostAvailable = $false; CostIssue = 'Multiple billing currencies cannot be combined.' + TotalAICost = $null; CostPer1KTokens = $null; CostPerRequest = $null + TotalTokens = 2000; TotalRequests = 20; TotalPromptTokens = 1600; TotalGeneratedTokens = 400 + AIFootprint = @{ OpenAIAccounts = 2; AIServices = 0; MLWorkspaces = 0; SearchServices = 0; GpuVmCount = 0 } + ByModel = @([pscustomobject]@{ Deployment = 'fixture'; TotalTokens = 2000; PromptTokens = 1600; GeneratedTokens = 400; PctOfTokens = 100 }) + Note = 'Multiple billing currencies cannot be combined.'; Period = 'MonthToDate' + } + $modules = @(@{ Fn = 'Get-AIWorkloadMetrics'; Name = 'AI Workload Metrics'; Selected = $true; Category = 'AI & ML' }) + + $null = Show-ResultsSummary -Results @{ 'Get-AIWorkloadMetrics' = $data } -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match 'AI account cost: Unavailable' + $html | Should -Match 'Tokens: 2,000.*Requests: 20' + $html | Should -Match '>Limited data' + $html | Should -Not -Match 'Grant Cost Management Reader to compute|No AI workloads detected' + $row = @(Import-Csv -LiteralPath (Join-Path $run 'Get-AIWorkloadMetrics.csv'))[0] + $row.'Summary.TotalAICost' | Should -BeNullOrEmpty + $row.'Summary.CostIssue' | Should -Match 'currencies' + } + + It 'Keeps partial cost-by-tag coverage visible in every report without healthy guidance' { + $reportRoot = Join-Path $TestDrive 'partial-cost-by-tag' + $data = [pscustomobject]@{ + CostByTag = @{ CostCenter = @([pscustomobject]@{ TagValue = 'team'; Cost = 125; Currency = 'USD' }) } + CoverageIncomplete = $true; ScannedSubs = 2; TotalSubs = 3; UsedTimeframe = 'MonthToDate' + SuccessfulSubscriptionIds = @('first', 'third') + FailedSubscriptions = @([pscustomobject]@{ SubscriptionId = 'second'; Subscription = 'Second'; StatusCode = 403; Error = 'Cost query returned HTTP 403.' }) + ResourceCostSeen = 125; AllocatedCost = 125; UnallocatedCost = 0 + Note = 'Cost coverage is incomplete: 2 of 3 subscriptions were read. Amounts cover successful subscriptions only.' + } + $modules = @(@{ Fn = 'Get-CostByTag'; Name = 'Cost by Tag'; Selected = $true; Category = 'Cost Analysis' }) + + $null = Show-ResultsSummary -Results @{ 'Get-CostByTag' = $data } -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match 'Cost coverage is incomplete: 2 of 3' + $html | Should -Match '>Limited data' + $html | Should -Not -Match 'No positive untagged cost was found' + $rows = @(Import-Csv -LiteralPath (Join-Path $run 'Get-CostByTag.csv')) + @($rows | Where-Object { $_.TagValue -eq 'team' })[0].'Summary.CoverageIncomplete' | Should -Be 'True' + @($rows | Where-Object { $_.SubscriptionId -eq 'second' -and $_.Error -match '403' }).Count | Should -Be 1 + Get-Content -LiteralPath (Join-Path $run 'ScanSummary.txt') -Raw | Should -Match 'Cost by Tag: Limited data: Cost coverage is incomplete' + } + + It 'Shows unavailable resource forecasts without failing actual resource costs' { + $reportRoot = Join-Path $TestDrive 'resource-forecast-gap' + $periodStart = [datetime]::new(2026, 9, 1, 0, 0, 0, [DateTimeKind]::Utc) + $periodEnd = [datetime]::new(2026, 9, 15, 0, 0, 0, [DateTimeKind]::Utc) + $rows = @( + [pscustomobject]@{ Subscription = 'A'; SubscriptionId = '11111111-1111-1111-1111-111111111111'; ResourceGroup = 'fixture'; ResourceType = 'Managed Disk'; ResourceName = 'disk-a'; ResourcePath = '/subscriptions/11111111-1111-1111-1111-111111111111/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk-a'; Actual = 100; ActualPeriod = '2026-09-01 00:00 to 2026-09-15 00:00 UTC (query window)'; ActualPeriodStart = $periodStart; ActualPeriodEnd = $periodEnd; ActualPeriodSource = 'Query window'; Forecast = 150; ForecastSource = 'Forecast'; Currency = 'USD' } + [pscustomobject]@{ Subscription = 'B'; SubscriptionId = '22222222-2222-2222-2222-222222222222'; ResourceGroup = 'fixture'; ResourceType = 'Managed Disk'; ResourceName = 'disk-b'; ResourcePath = '/subscriptions/22222222-2222-2222-2222-222222222222/resourceGroups/fixture/providers/Microsoft.Compute/disks/disk-b'; Actual = 80; ActualPeriod = '2026-09-01 00:00 to 2026-09-15 00:00 UTC (query window)'; ActualPeriodStart = $periodStart; ActualPeriodEnd = $periodEnd; ActualPeriodSource = 'Query window'; Forecast = $null; ForecastSource = 'Unavailable'; Currency = 'USD'; CostIssue = 'Resource forecasts for B are unavailable; actual costs are kept. Resource forecast returned HTTP 429; results are incomplete.' } + ) + $modules = @(@{ Fn = 'Get-ResourceCosts'; Name = 'Resource Costs'; Selected = $true; Category = 'Cost Analysis' }) + + $null = Show-ResultsSummary -Results @{ 'Get-ResourceCosts' = $rows } -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match '>Limited data' + $html | Should -Match 'Resource forecasts for B are unavailable' + Get-Content -LiteralPath (Join-Path $run 'ScanSummary.txt') -Raw | Should -Match 'Resource Costs: Limited data: Resource forecasts for B are unavailable' + $csv = @(Import-Csv -LiteralPath (Join-Path $run 'Get-ResourceCosts.csv')) + $csv.Count | Should -Be 2 + @($csv | Where-Object ResourceName -EQ 'disk-a')[0].ForecastSource | Should -Be 'Forecast' + @($csv | Where-Object ResourceName -EQ 'disk-b')[0].Actual | Should -Be '80' + @($csv | Where-Object ResourceName -EQ 'disk-b')[0].ForecastSource | Should -Be 'Unavailable' + @($csv | Where-Object ResourceName -EQ 'disk-b')[0].CostIssue | Should -Match 'HTTP 429' + } + + It 'Keeps incompatible recommendation savings separate in rendered reports' { + $reportRoot = Join-Path $TestDrive 'recommendation-currencies' + $recommendations = @( + [pscustomobject]@{ Category = 'Rightsize'; Impact = 'High'; ResourceName = 'one'; AnnualSavings = 100; Currency = 'USD'; Problem = 'Resize'; ResourceType = 'VM' } + [pscustomobject]@{ Category = 'Rightsize'; Impact = 'High'; ResourceName = 'two'; AnnualSavings = 50; Currency = 'EUR'; Problem = 'Resize'; ResourceType = 'VM' } + ) + $results = @{ + 'Get-OptimizationAdvice' = [pscustomobject]@{ Recommendations = $recommendations; TotalCount = 2; EstimatedAnnualSavings = $null; Currency = 'Mixed'; CostIssue = 'Savings in different currencies cannot be combined.' } + 'Get-ReservationAdvice' = [pscustomobject]@{ AdvisorRecommendations = $recommendations; EstimatedAnnualSavings = $null; Currency = 'Mixed'; CostIssue = 'Savings in different currencies cannot be combined.' } + } + $modules = @( + @{ Fn = 'Get-OptimizationAdvice'; Name = 'Optimization Advice'; Selected = $true; Category = 'Advisor' } + @{ Fn = 'Get-ReservationAdvice'; Name = 'Reservation Advice'; Selected = $true; Category = 'Commitments' } + ) + + $null = Show-ResultsSummary -Results $results -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match 'Est. annual savings: Unavailable' + $html | Should -Match 'USD 100' + $html | Should -Match 'EUR 50' + $html | Should -Not -Match 'Mixed 150|\$150' + [regex]::Matches($html, '>Limited data').Count | Should -Be 2 + Get-Content -LiteralPath (Join-Path $run 'ScanSummary.txt') -Raw | Should -Match 'Limited data: Savings in different currencies cannot be combined' + } + + It 'Does not claim healthy VM utilization or alerting when the reads fail' { + Mock Invoke-AzRestMethodWithRetry -ModuleName FinOpsMultitool { [pscustomobject]@{ StatusCode = 403; Content = '{}' } } + $alerts = Get-AnomalyAlerts -Subscriptions @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' }) + $reportRoot = Join-Path $TestDrive 'failed-utilization-alerts' + $results = @{ + 'Get-AnomalyAlerts' = $alerts + 'Get-IdleVMs' = [pscustomobject]@{ IdleVMs = @(); Count = 0; HasData = $false; ScannedVMs = 2; EvaluatedVMs = 0; MetricFailures = 2; MetricFailureDetail = @('HTTP 403'); Note = 'VM utilization coverage is incomplete.' } + } + $modules = @( + @{ Fn = 'Get-AnomalyAlerts'; Name = 'Anomaly Alerts'; Selected = $true; Category = 'Monitoring' } + @{ Fn = 'Get-IdleVMs'; Name = 'Idle VMs'; Selected = $true; Category = 'Optimization' } + ) + + $null = Show-ResultsSummary -Results $results -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $alerts.CoverageIncomplete | Should -BeTrue + (Get-KpiComputedValue -KpiId 'anomaly-detection-rate' -Data $alerts).Value | Should -BeNullOrEmpty + (Get-KpiComputedValue -KpiId 'computational-waste' -Data $results['Get-IdleVMs']).Value | Should -BeNullOrEmpty + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + [regex]::Matches($html, '>Limited data').Count | Should -Be 2 + $html | Should -Not -Match 'actively utilized|Compute spend looks healthy|Monitoring is working|No anomaly detection rules configured' + Get-Content -LiteralPath (Join-Path $run 'ScanSummary.txt') -Raw | Should -Match 'Idle VMs: Limited data: VM utilization coverage is incomplete' + } + + It 'Renders billing currency and unavailable carbon measurements explicitly' { + $reportRoot = Join-Path $TestDrive 'currencies-carbon' + $results = @{ + 'Get-MaccCommitment' = [pscustomobject]@{ Applicable = $true; HasMacc = $true; Commitments = @([pscustomobject]@{ BillingAccount = 'fixture'; Currency = 'EUR'; Commitment = 300; Consumed = 250; Remaining = 50; PctUsed = 83.3; Status = 'Active' }) } + 'Get-CostTrend' = [pscustomobject]@{ Months = @([pscustomobject]@{ Month = 'Aug 2026'; MonthDate = [datetime]'2026-08-01'; Cost = 30; Currency = 'EUR' }) } + 'Get-CarbonMetrics' = [pscustomobject]@{ HasData = $true; LatestMonth = '2026-07'; TotalEmissionsKg = $null; ChangeRatio = $null; Unit = 'kgCO2e'; CoverageIncomplete = $true; BySubscription = @([pscustomobject]@{ Subscription = 'fixture'; EmissionsKg = 50 }); Note = 'Overall carbon measurement is unavailable.' } + } + $modules = @( + @{ Fn = 'Get-MaccCommitment'; Name = 'MACC Commitment'; Selected = $true; Category = 'Account' } + @{ Fn = 'Get-CostTrend'; Name = 'Cost Trend'; Selected = $true; Category = 'Cost Analysis' } + @{ Fn = 'Get-CarbonMetrics'; Name = 'Carbon Emissions'; Selected = $true; Category = 'Sustainability' } + ) + $captured = [Collections.Generic.List[string]]::new() + Mock Write-Host { $captured.Add([string]$Object) } + + $null = Show-ResultsSummary -Results $results -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $html | Should -Match 'EUR 300' + $html | Should -Match 'EUR 30' + $html | Should -Match 'Latest month \(2026-07\): Unavailable' + $html | Should -Match 'month over month Unavailable' + $html | Should -Not -Match '\$300|\$30|month over month 0%' + ($captured -join ' ') | Should -Match 'EUR 300' + ($captured -join ' ') | Should -Match 'EUR 30' + } + + It 'Creates distinct run folders without changing existing reports' { + $root = Join-Path $TestDrive 'reports' + + $first = New-FinOpsReportDirectory -OutputPath $root + Write-FinOpsReportFile -Directory $first -Name 'ScanSummary.txt' -Lines @('First run') + $second = New-FinOpsReportDirectory -OutputPath $root + + $first | Should -Not -Be $second + Split-Path $first -Parent | Should -Be $root + Split-Path $second -Parent | Should -Be $root + Get-Content -LiteralPath (Join-Path $first 'ScanSummary.txt') | Should -Be 'First run' + { Write-FinOpsReportFile -Directory $first -Name 'ScanSummary.txt' -Lines @('Replacement') } | Should -Throw + Get-Content -LiteralPath (Join-Path $first 'ScanSummary.txt') | Should -Be 'First run' + } + + It 'Rejects a Git worktree before creating a report folder' -ForEach @( + @{ Marker = 'directory' } + @{ Marker = 'file' } + ) { + $repository = Join-Path $TestDrive "repository-$Marker" + [void](New-Item -ItemType Directory -Path $repository) + $gitMarker = Join-Path $repository '.git' + if ($Marker -eq 'directory') { [void](New-Item -ItemType Directory -Path $gitMarker) } + else { Set-Content -LiteralPath $gitMarker -Value 'gitdir: elsewhere' } + $target = Join-Path $repository 'nested/reports' + + { New-FinOpsReportDirectory -OutputPath $target } | Should -Throw '*Git*' + + Test-Path -LiteralPath $target | Should -BeFalse + } + + It 'Rejects network and provider paths before writing data ()' -ForEach @( + @{ Destination = '\\server\share\reports' } + @{ Destination = 'https://example.test/reports' } + @{ Destination = 'Env:reports' } + ) { + { New-FinOpsReportDirectory -OutputPath $Destination } | Should -Throw '*local*' + } + + It 'Uses per-user local application data rather than the working directory' { + $base = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData, [Environment+SpecialFolderOption]::DoNotVerify) + + Get-FinOpsReportRoot | Should -Be (Join-Path $base 'FinOpsToolkit/Multitool/Reports') + } + + It 'Creates the default reports folder for a fresh Linux application-data path' -Skip:(-not $IsLinux) { + $applicationData = Join-Path $TestDrive 'fresh-application-data' + $definitions = @('Get-FinOpsReportRoot', 'Assert-FinOpsReportPath', 'New-FinOpsReportDirectory', 'Write-FinOpsReportFile') | + ForEach-Object { "function $_ { $((Get-Command $_).Definition) }" } + $scriptText = "`$ErrorActionPreference = 'Stop'`n" + ($definitions -join "`n") + "`nNew-FinOpsReportDirectory" + $startInfo = [System.Diagnostics.ProcessStartInfo]::new((Get-Process -Id $PID).Path) + $startInfo.UseShellExecute = $false + $startInfo.RedirectStandardOutput = $true + $startInfo.RedirectStandardError = $true + foreach ($argument in @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($scriptText)))) { + $startInfo.ArgumentList.Add($argument) + } + $startInfo.Environment['XDG_DATA_HOME'] = $applicationData + $process = [System.Diagnostics.Process]::new() + try { + $process.StartInfo = $startInfo + [void]$process.Start() + $standardOutput = $process.StandardOutput.ReadToEndAsync() + $standardError = $process.StandardError.ReadToEndAsync() + $process.WaitForExit() + $process.ExitCode | Should -Be 0 -Because $standardError.GetAwaiter().GetResult() + $run = $standardOutput.GetAwaiter().GetResult().Trim() + Split-Path $run -Parent | Should -Be (Join-Path $applicationData 'FinOpsToolkit/Multitool/Reports') + Test-Path -LiteralPath (Join-Path $run '.gitignore') | Should -BeTrue + } + finally { $process.Dispose() } + } + + It 'Automatically saves all formats without OutputPath or a key press' { + $localRoot = Join-Path $TestDrive 'automatic-local' + Mock Get-FinOpsReportRoot { $localRoot } + Mock Read-Host { throw 'Saving reports must not prompt.' } + Mock Write-Host { } + $subscription = [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Fixture' } + $results = @{ 'Get-CostData' = @{ $subscription.Id = @{ Actual = 10; Currency = 'USD'; Name = 'Fixture'; ForecastSource = 'Unavailable' } } } + $modules = @(@{ Fn = 'Get-CostData'; Name = 'Cost Data'; Selected = $true; Category = 'Cost Analysis' }) + + $returned = Show-ResultsSummary -Results $results -Modules $modules -Subscriptions @($subscription) -ErrorAction Stop + + $runs = @(Get-ChildItem -LiteralPath $localRoot -Directory) + $runs.Count | Should -Be 1 + foreach ($name in @('Get-CostData.csv', 'FinOpsReport.html', 'ScanSummary.txt', '.gitignore')) { + Test-Path -LiteralPath (Join-Path $runs[0].FullName $name) | Should -BeTrue + } + (Import-Csv -LiteralPath (Join-Path $runs[0].FullName 'Get-CostData.csv')).Actual | Should -Be '10' + $returned['Get-CostData'][$subscription.Id].Actual | Should -Be 10 + Get-Content -LiteralPath (Join-Path $runs[0].FullName '.gitignore') | Should -Be '*' + Should -Invoke Read-Host -Times 0 -Exactly + } + + It 'Starts the HTML story with scoped spend and visible evidence gaps' { + $reportRoot = Join-Path $TestDrive 'finops-story' + Mock Write-Host { } + $permissionInfo = @{ 'Get-CostTrend' = @{ Role = 'Cost Management Reader'; Scope = 'Subscription'; API = 'Cost Management Query' } } + $subscriptions = @( + [pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Production '; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } + [pscustomobject]@{ Id = '22222222-2222-2222-2222-222222222222'; Name = 'Development'; TenantId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' } + ) + $results = @{ + 'Get-CostData' = @{ + $subscriptions[0].Id = @{ Actual = 100; Currency = 'EUR'; Name = $subscriptions[0].Name; ActualPeriod = '2026-08-01 to 2026-08-31'; Forecast = $null; ForecastSource = 'Unavailable' } + $subscriptions[1].Id = @{ Actual = 200; Currency = 'USD'; Name = $subscriptions[1].Name; ActualPeriod = '2026-09-01 to 2026-09-18'; Forecast = 300; ForecastSource = 'Forecast' } + } + 'Get-CostTrend' = @() + '_error_Get-CostTrend' = '429 Too Many Requests: retry later ' + } + $modules = @( + @{ Fn = 'Get-CostData'; Name = 'Cost Data'; Selected = $true; Category = 'Cost Analysis' } + @{ Fn = 'Get-CostTrend'; Name = 'Cost Trend'; Selected = $true; Category = 'Cost Analysis' } + ) + + $null = Show-ResultsSummary -Results $results -Modules $modules -Subscriptions $subscriptions -ExportPath $reportRoot -DataSourceLabel 'FinOps Hub (fixture)' -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + $story = [regex]::Match($html, '(?s)
]*>.*?
').Value + $story | Should -Not -BeNullOrEmpty + $story | Should -Match 'Observed spend' + $story | Should -Match '2026-08-01 to 2026-08-31' + $story | Should -Match '2026-09-01 to 2026-09-18' + $story | Should -Match 'EUR 100.00' + $story | Should -Match 'USD 200.00' + $story | Should -Match 'Production <east>' + $story | Should -Match 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + $story | Should -Match 'FinOps Hub \(fixture\)' + $story | Should -Match 'Scan status' + $story | Should -Match '429 Too Many Requests' + $story | Should -Match '<script>not markup</script>' + $story | Should -Match 'href="#scan-Get-CostTrend"' + $html | Should -Match ([regex]::Escape($permissionInfo['Get-CostTrend'].Role)) + $story | Should -Match 'Full-month forecast unavailable' + $html | Should -Not -Match 'Total Findings|Every measure below is a FinOps Foundation KPI' + $html | Should -Not -Match 'Current period spend:' + $story | Should -Not -Match '' + $tags = @{} + foreach ($index in 1..20) { $tags[('Tag{0:D2}' -f $index)] = 'Fixture' } + $tags.Tag20 = $longValue + $hubRows = @([pscustomobject]@{ ResourceId = '/resources/one'; ResourceType = 'fixture'; Tags = ($tags | ConvertTo-Json -Compress) }) + foreach ($index in 1..6) { + $hubRows += [pscustomobject]@{ ResourceId = "/resources/extra-$index"; ResourceType = 'fixture'; Tags = (@{ Tag20 = "Other-$index" } | ConvertTo-Json -Compress) } + } + $inventory = ConvertTo-TagInventoryFromHub -HubData $hubRows + $modules = @(@{ Fn = 'Get-TagInventory'; Name = 'Tag Inventory'; Selected = $true; Category = 'Governance' }) + + $null = Show-ResultsSummary -Results @{ 'Get-TagInventory' = $inventory } -Modules $modules -ExportPath $reportRoot -ErrorAction Stop + + $run = @(Get-ChildItem -LiteralPath $reportRoot -Directory)[0].FullName + $html = Get-Content -LiteralPath (Join-Path $run 'FinOpsReport.html') -Raw + [regex]::Matches($html, 'Tag\d{2}').Count | Should -Be 20 + $tagRow = [regex]::Match($html, '(?s)Tag20.*?').Value + $tagRow | Should -Match ([regex]::Escape([System.Net.WebUtility]::HtmlEncode($longValue))) + foreach ($index in 1..6) { $tagRow | Should -Match "Other-$index" } + $tagRow | Should -Not -Match '…|