Add Start-FinOpsMultitool — cross-platform terminal UI for FinOps scanning - #2155
Add Start-FinOpsMultitool — cross-platform terminal UI for FinOps scanning#2155Zac larsen (z-larsen) wants to merge 86 commits into
Conversation
… GUI Adds the Azure FinOps Multitool as a new PowerShell cmdlet in the FinOps toolkit. The Multitool is a WPF-based GUI that scans an Azure tenant for cost optimization, governance, and FinOps insights including cost trends, orphaned resources, idle VMs, tag hygiene, reservation/savings plan utilization, AHB opportunities, budgets, anomaly alerts, and policy compliance. - Public/Start-FinOpsMultitool.ps1: thin launcher cmdlet with comment-based help - Private/FinOpsMultitool/: full implementation (24 scanner modules, WPF GUI, Power BI template) - Tests/Unit/Start-FinOpsMultitool.Tests.ps1: Pester unit tests Windows-only (requires WPF support).
|
@microsoft-github-policy-service agree company="Microsoft" |
|
Zac larsen (@z-larsen) This is exciting! I don't know much about the tool, but would love to learn more. Can you join us at the contributor sync next Wednesday to share? |
|
Thanks, Michael! Would love to join. |
…info - Add contract-aware cost access warning banner (EA/MCA/CSP) on Overview tab - Add contract-specific billing tab messages when billing access unavailable - Add MG hierarchy unavailable info node in tree view with role guidance - Fix tag cost queries: use TagKey grouping type (not Tag/Dimension) - Add batched TagKey+TagValue query attempt with per-tag fallback - Clear skipSubs between batched and per-tag strategies - Add throttle pacing (2s every 2 queries) to avoid 429s - Add EA/MCA cost access detection in Get-CostData - Add runspace pool for API call parallelization
There was a problem hiding this comment.
Requesting changes. A post-submission rubber-duck pass independently rechecked all 11 findings against live head 5f5deaa8; none were withdrawn. Final severity is 0 Critical, 7 High, 4 Medium. The snapshot finding is High rather than Critical because the unsafe private-function path is not currently exposed through the TUI, and the subscription finding is financial-scope correctness rather than an RBAC boundary violation. Merge blockers remain: missing snapshot orphan validation, cross-subscription scope contamination, broken macOS Kusto authentication, silent Hub-to-API fallback with incorrect provenance, unusable nested CSV exports, invalid SecureString bearer-token handling, inaccurate realized-savings calculations, and a Hub-provider suite that fails Pester 6 discovery. Please address the inline findings, align the documentation and PR description with the current head, resolve the base conflicts, and rerun validation.
| 'Microsoft.Compute/disks' = @{ api = '2023-04-02'; label = 'Managed disk'; inUseProps = @('managedBy', 'diskState'); kind = 'attachment' } | ||
| 'Microsoft.Network/publicIPAddresses' = @{ api = '2023-09-01'; label = 'Public IP address'; inUseProps = @('ipConfiguration', 'natGateway'); kind = 'attachment' } | ||
| 'Microsoft.Network/networkInterfaces' = @{ api = '2023-09-01'; label = 'Network interface'; inUseProps = @('virtualMachine', 'privateEndpoint'); kind = 'attachment' } | ||
| 'Microsoft.Compute/snapshots' = @{ api = '2023-04-02'; label = 'Disk snapshot'; inUseProps = @(); kind = 'backup' } |
There was a problem hiding this comment.
High: This snapshot entry has no in-use properties, so the execution-time orphan loop evaluates nothing and always passes. The latest commit only blocks an unparseable GET response; it does not establish that a valid recovery point is orphaned. This path is not reachable through the current TUI and the MCP server was removed, so it is a latent defect requiring a direct private-function call rather than a current UI-triggerable delete. Please remove snapshots from this generic path or require fresh source, age, retention/protection, and scan-evidence validation before any remediation surface exposes it.
| if ($kustoProvider) { | ||
| Write-Host "" | ||
| Write-Host " Querying FinOps Hub Kusto database ($($kustoProvider.Mode))..." -ForegroundColor Green | ||
| $cs = Get-FOHubCostSummary -Provider $kustoProvider |
There was a problem hiding this comment.
High (functional correctness): The selected IDs are computed above, but none of the three Hub preload calls receives -SubscriptionIds $subIdsForDisco. In addition, -RestrictToSelected is declared by the cost summary, trend, and resource-cost scanners but is never passed at any call site. A one-subscription UAT consequently returned 25 Hub subscriptions and cross-subscription resources. This stays within the caller’s RBAC, but it contaminates reports labeled as single-subscription. Pass the selected scope through every Hub, storage, and management-group cost path.
| $tok = (Get-AzAccessToken -ResourceUrl $ResourceUrl).Token | ||
| if ($tok -is [securestring]) { | ||
| $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($tok) | ||
| try { [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) } |
There was a problem hiding this comment.
High: SecureStringToBSTR returns UTF-16 data, but PtrToStringAuto is platform-dependent. On macOS this converted a live 2,612-character JWT into a one-character string, causing every remote Kusto query to return 401. Decode this pointer with Marshal.PtrToStringBSTR.
| if (-not ($rc -is [System.Collections.IDictionary] -and $rc.Contains('Error') -and $rc.Error)) { $hubResourceCosts = $rc } | ||
| $ct = Get-FOHubCostByTag -Provider $kustoProvider | ||
| if (-not ($ct -is [System.Collections.IDictionary] -and $ct.Contains('Error') -and $ct.Error)) { $hubCostByTag = $ct } | ||
| Write-Host " Hub data summarized in-engine (no rows loaded). Forecast is not included; choose API source for live forecast." -ForegroundColor DarkGray |
There was a problem hiding this comment.
High: This success message is unconditional even when all three provider calls returned errors and their results were discarded. The scan then falls through to Cost Management APIs while the UI still labels the source as FinOps Hub. Preserve and surface the provider error, require an explicit fallback decision, and update result provenance whenever the provider changes.
| if ($data -and @($data).Count -gt 0) { | ||
| $safeName = $mod.Fn -replace '[^a-zA-Z0-9\-]', '' | ||
| $csvPath = Join-Path $exportDir "$safeName.csv" | ||
| $data | Export-Csv -Path $csvPath -NoTypeInformation |
There was a problem hiding this comment.
High: Several scan contracts return wrappers containing nested hashtables or arrays. Exporting the wrapper directly produces cells such as System.Collections.Hashtable and System.Object[]; the cost-summary export even turns subscription IDs into columns. Define a tabular projection for each scan contract before CSV export.
| $sub = if ($subNameById.ContainsKey($sid)) { $subNameById[$sid] } else { $sid } | ||
| } | ||
| if ($pm -match 'Reservation') { | ||
| $ri += $cost * 0.4 |
There was a problem hiding this comment.
High: Multiplying amortized committed cost by a fixed discount percentage does not calculate realized savings. For example, a paid cost of $100 at a 40% discount implies about $66.67 savings, not $40, and real discounts vary by SKU, term, region, and agreement. Use benefit-utilization/savings data or compare against the matching PAYG benchmark; otherwise label this explicitly as a heuristic rather than realized savings.
| } | ||
|
|
||
| if ($PreselectedId) { | ||
| $sub = Get-AzSubscription -SubscriptionId $PreselectedId -ErrorAction SilentlyContinue |
There was a problem hiding this comment.
Medium: A supplied -SubscriptionId is resolved only after the tenant picker, and this lookup omits -TenantId. In UAT it attempted tokens across unrelated tenants and emitted repeated conditional-access/MFA warnings. Resolve the subscription and tenant first, set that context, and bypass both pickers when the caller already supplied the scope.
|
|
||
| & "$PSScriptRoot/../Initialize-Tests.ps1" | ||
|
|
||
| BeforeAll { |
There was a problem hiding this comment.
Medium: Initialize-Tests.ps1, invoked above, already declares a root-level BeforeAll. Pester 6 rejects this second root-level block during discovery, so none of the Hub-provider tests runs. Combine the setup into one supported BeforeAll or move the module import into the existing initialization block.
| Write-Host "" | ||
| Write-Host " ↑↓ Navigate │ Enter = Select tenant │ Q = Stay in current" -ForegroundColor DarkGray | ||
|
|
||
| $tKey = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') |
There was a problem hiding this comment.
Medium: The public cmdlet has no non-RawUI path. Cursor repainting plus RawUI.ReadKey makes focus and selection state unreliable for screen readers and can hang or throw in remoting, redirected, and CI hosts. Add a numbered line-oriented accessible mode, explicit noninteractive parameters, and an early RawUI capability check.
| </div> | ||
| <div class="ftk-tile"> | ||
| <div>🛡️ Write-safety policy</div> | ||
| <div>Optional remediation tools are dry-run by default and gated by a configurable write-safety policy. The server is read-only out of the box.</div> |
There was a problem hiding this comment.
Medium: The current head no longer contains the MCP server, yet this page still describes a server and its write-safety mode. Other public help also advertises Excel/JSON/Power BI exports, TUI remediation, and Windows PowerShell 5.1 behavior that the reviewed head does not provide. Please reconcile the public docs, Learn content, module README, agent skill, and PR description with the code that will actually ship.
Finding 6 (High): Get-AzAccessToken now returns Token as a SecureString, so interpolating it produced "Bearer System.Security.SecureString" and every metric query returned 401. The empty catch turned that into zero findings rather than an error. Get-IdleVMs, Get-StorageTierAdvice, and Get-AIWorkloadMetrics now use Get-PlainAccessToken, and the AI scanner surfaces a token failure instead of swallowing it. Finding 3 (High): Get-PlainAccessToken used PtrToStringAuto, which picks the platform default encoding and truncated the JWT to one character on macOS. A BSTR is always UTF-16, so decode with PtrToStringBSTR. Finding 1 (High): Microsoft.Compute/snapshots declared inUseProps = @(), so the orphan verification loop iterated zero times and passed vacuously. Whether a snapshot is safe to delete depends on retention and backup policy, which this tool does not evaluate, and the delete is irreversible. Removed snapshots from the deletable allow-list. Finding 2 (High): the selected subscription scope was computed but never applied. Passed -SubscriptionIds to the three Hub Kusto queries, added -RestrictToSelected in the generic scan dispatcher for the scanners that declare it, and added -SubscriptionIds plus a shared row-subscription resolver to the Hub storage reader, which had no scope mechanism at all. Finding 4 (High): provider errors were discarded and the UI printed "Hub data summarized in-engine" unconditionally, then fell back to the Cost Management API while still labelling results as Hub. Errors are now surfaced per query and a total failure states that results are not from the Hub. Finding 9 (Medium): Initialize-Tests.ps1 already declares a root-level BeforeAll, and Pester 6 rejects a second one during discovery, so the Hub provider tests would not run in CI (CI installs Pester unpinned). Moved the module import into the Describe block. Also replaced MCP tool names and apply=true syntax in user-facing messages with the PowerShell equivalents, since the MCP server was removed.
Savings was computed as amortized committed cost multiplied by a flat discount percentage, which measures a share of what was paid rather than the gap up to pay-as-you-go. $100 paid at a 40% discount implies $66.67 saved, not $40. Savings is now paid * d / (1 - d). The assumed rates are named constants with the derivation documented alongside them. Real discounts vary by SKU, term, region, and agreement, so the RI and savings plan figures remain an estimate. The result now carries IsEstimate and EstimateBasis, the module header no longer claims measured savings, and the terminal UI prints the basis under the breakdown.
…s (review finding 8)
A supplied -SubscriptionId was resolved only after the tenant picker had
already run, and the lookup omitted -TenantId. Get-AzSubscription without a
tenant probes every accessible tenant, so a scoped run emitted repeated
conditional-access/MFA warnings for tenants that refuse.
Resolution now happens immediately after the connection check: the current
context tenant is tried first, falling back to an explicit per-tenant lookup
only if that misses. On success the context is set to that subscription and
tenant and both pickers are bypassed, which is what an explicitly scoped
invocation should do.
Also picks up a formatter pass that split "} catch {" across lines in
Get-SavingsRealized.ps1 to match the surrounding style.
…review finding 5) Exports piped the raw scan wrapper straight to Export-Csv, so collection and hashtable properties landed as "System.Object[]" and "System.Collections.Hashtable" cells, and the cost summary - a hashtable keyed by subscription - turned every subscription id into a column. Adds ConvertTo-FinOpsExportRows, which projects a result to flat rows: explicit projections for the cost summary and cost-by-tag contracts, a key/value shape for other dictionaries, and for wrapper objects the single collection property (falling back to a preferred name when several exist) so scans that follow the existing pattern export correctly without a case of their own. Summary-only contracts export their scalar properties as one row. Every projection then passes through ConvertTo-FinOpsExportCell, which guarantees scalar cells regardless of contract - dictionaries and arrays are rendered as delimited text rather than type names. Verified against a wrapper-plus-array result, a subscription-keyed hashtable, a nested tag map, and a summary-only object: no object-type cells in any.
…n-interactive mode (review finding 10) Remoting sessions and CI hosts crashed on an unhandled IOException from [Console]::SetCursorPosition before either picker rendered. Those hosts still expose $Host.UI.RawUI, so a null check does not detect them, and ReadKey blocks indefinitely there instead of throwing - guarding only the console calls would have turned a fast crash into a hung session. Test-FinOpsRichConsole probes [Console]::CursorTop and IsInputRedirected, and each of the four prompts now has a numbered line-oriented branch beside the arrow-key one, which is also what a screen reader can follow. The arrow-key path is unchanged in a normal terminal. Adds -Scans, -DataSource and -NonInteractive so automation can supply every answer; -SubscriptionId and -OutputPath already covered two of the four prompts. Scan names match either the function or its menu label, and an unknown name is an error rather than a silent no-op. Self-review fixes folded in: a mistyped subscription number and an ambiguous entry at the A/S prompt both widened the scan to the whole tenant, the console probe cached across invocations, -Scans with GraphOnly ran nothing and reported clean, the numbered picker dropped invalid entries silently, and the Read-Host wrapper could swallow a cancellation.
…s that described it Splitting the MCP server out left the four remediation functions with no caller. Their tool names - remediate_delete_orphaned_resource, remediate_deallocate_vm, set_cost_allocation_rule - were MCP tool names, and the terminal UI never referenced them, so nothing that ships in this PR could reach them. Removed here and preserved on feature/finops-multitool-mcp, where their caller lives: modules/Remove-OrphanedResource.ps1 modules/Enable-HybridBenefit.ps1 modules/Stop-IdleVm.ps1 modules/Set-CostAllocationRule.ps1 modules/helpers/Confirm-WriteAction.ps1 Tests/Unit/FinOpsMultitool.WriteSafety.Tests.ps1 The docs claimed otherwise in six places, including a README section that was really an MCP server configuration and eleven KPI hints that told users to run MCP tool names. All of them now describe what this PR actually ships: a read-only scanner. Also in this pass - documented -Scans, -DataSource and -NonInteractive on the cmdlet reference and the terminal fallback behavior; corrected three stale ms.date values and a CSV/JSON export claim, since JSON was never an export format; noted that realized savings are an estimate rather than a measurement; and fixed capitalization, dash, and fenced-code-language lint. Validation: 1884 passed / 0 failed, down 22 for the removed write-safety suite. Zero net-new PSScriptAnalyzer findings excluding Write-Host. Markdown lint clean.
.ftk-scope-test.ps1 was a throwaway harness used to confirm that nested functions see their parent's parameters on PowerShell 5.1 and 7. Its cleanup step did not run, so git add -A picked it up in ddc72db. It is not part of the toolkit and has no callers.
Brings the branch up to date with 49 commits on dev. Five files were touched on both sides; three needed manual resolution and all three are documentation. - changelog.md - dev has shipped v15 and restructured its Unreleased section, so the multitool entry moved into dev's new layout and dropped the v15.0.0 suffix to match dev's convention for unreleased entries. - finops-toolkit-overview.md and powershell-commands.md - ms.date set to today per AGENTS.md rather than taking either side's value. TOC.yml and docs/README.md merged cleanly. Validation: 855 passed / 0 failed. That is dev's own 832 plus this branch's 23 multitool tests. The previous 1884 reflected older data-driven tests that dev has since restructured, not lost coverage. DocsLinks.Tests.ps1 fails discovery on clean dev as well (AllowNullOrEmptyForEach), so it is not introduced here.
…description Read-only is a fact about what the tool writes; safe to run against production is a guarantee about outcomes, and the two are not the same. A tenant-wide scan still puts real load on Resource Graph, Cost Management, and Monitor, which the skill itself warns about. Stating what the tool does and letting the reader draw the conclusion is both accurate and shorter.
… carbon permissions The HTML report carried CSS for guidance blocks and a comment reading "re-evaluate guidance items for HTML", but nothing ever emitted them. Guidance is built during the terminal display pass and the report is written afterwards, so the items were gone by the time the HTML was assembled. An exported report therefore had the tables but none of the interpretation that made them useful. Captures the per-scan items in $guidanceByFn as the display pass builds them and emits them into the report, rather than re-running the 450-line guidance switch a second time and creating two copies to keep in sync. Severity maps onto the .guidance red/yellow/green classes that were already defined, and docs values that look like URLs become links. Messages interpolate scan data, so every value goes through HtmlEncode; verified with a script tag in a guidance message. Also corrects the carbon permissions wording. Learn's carbon optimization permissions table shows Subscription Reader can view emissions but Resource Group Reader and Resource Reader cannot, and that carbon permissions apply at the subscription level only. The docs said "Reader or Carbon Optimization Reader" without that qualifier, so someone holding Reader on a resource group would have read it as sufficient. The in-tool permission readout was already correct. ms.date bumped to today on the three changed Learn articles.
Initialize-Scanner, Get-TenantHierarchy, and New-PowerBITemplate had no callers. Initialize-Scanner also carried a WPF tenant picker, which is Windows-only and does not belong in a cross-platform terminal UI. New-PowerBITemplate was only reachable through the MCP server, which moved to its own branch. Module count assertion updated 33 -> 30.
🛠️ Description
Adds the FinOps multitool to the FinOps toolkit. Discussed with Brett Wilson (@MSBrett), who suggested contributing the tool into the official toolkit.
The multitool scans an Azure environment for cost optimization, governance, and FinOps insights — cost trends, orphaned resources, idle VMs, tag hygiene, reservation and savings plan utilization, Azure Hybrid Benefit opportunities, budgets, anomaly alerts, and policy compliance — and grounds its findings in live resource state.
Everything in this PR is read-only. It needs Reader or Cost Management Reader on the target scope and never creates, changes, or deletes a resource. Remediation and the MCP server were split out to a separate branch and will follow as their own PR.
One scanner engine, two consumers:
Start-FinOpsMultitoolsrc/templates/agent-skills/azor an Azure MCP serverRunning it
The terminal UI uses arrow-key menus when the console supports them. Consoles that can't render those menus — PowerShell remoting sessions, some editor terminals — fall back to numbered prompts, which is also what a screen reader can follow. Both paths run the same scans and produce the same results.
For automation,
-NonInteractivewith-Scans,-DataSource,-SubscriptionId, and-OutputPathsupplies every choice, so the tool runs from a pipeline or a scheduled job:FinOps hub data paths
This addresses Brett Wilson (@MSBrett)'s scaling review. When a FinOps hub is present, cost scans prefer the hub's Kusto database — an Azure Data Explorer or Fabric cluster, auto-discovered through Resource Graph, or a local ftklocal emulator via
FINOPS_HUB_KUSTO_URI— and push aggregation into the engine, returning only summarized result sets. Raw cost rows are never materialized in PowerShell on that path.The storage-export reader remains as a small-dataset fallback rather than the scalable path, and the terminal UI warns before using it on a hub with no reachable cluster, offering the live Cost Management API instead.
Scans
30 scan modules across optimization, governance, cost analysis, commitments, monitoring, Advisor, account, AI and ML, and sustainability. The terminal UI surfaces 26 of them. Results render in the terminal and export to one CSV per scan, a
FinOpsReport.htmlsummary, and aScanSummary.txtfile.📦 Files added / changed
Public/Start-FinOpsMultitool.ps1Invoke-FinOpsMultitool.ps1+FinOpsMultitool.psm1modules/helpers/Get-FOHubProvider.ps1+Invoke-FOHubKustoQuery.ps1agent-skills/finops-multitool/+references/agent-skills/cost-data-source/agent-skills/{power-bi-finops, cost-allocation, …}/Tests/Unit/Start-FinOpsMultitool.Tests.ps1+FOHubProvider.Tests.ps1docs-mslearn/.../powershell/multitool/+docs/multitool.md📸 Screenshots
Screenshots are in the public repo README.
📋 Checklist
🧪 How did you test this change?
🐳 Deploy to test?
N/A — standalone PowerShell tooling, not a template deployment.
🏷️ Do any of the following that apply?
📄 Did you update
docs/changelog.md?📖 Did you update documentation?
docs-mslearn/.../powershell/multitool/, a Jekyll landing page, overview/TOC/changelog entries, and the module README plus thefinops-multitoolandcost-data-sourceskills.