Skip to content

Add Start-FinOpsMultitool — cross-platform terminal UI for FinOps scanning - #2155

Open
Zac larsen (z-larsen) wants to merge 86 commits into
microsoft:devfrom
z-larsen:feature/finops-multitool
Open

Add Start-FinOpsMultitool — cross-platform terminal UI for FinOps scanning#2155
Zac larsen (z-larsen) wants to merge 86 commits into
microsoft:devfrom
z-larsen:feature/finops-multitool

Conversation

@z-larsen

@z-larsen Zac larsen (z-larsen) commented May 19, 2026

Copy link
Copy Markdown

🛠️ 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:

Interface Entry point Best for
Terminal UI Start-FinOpsMultitool A person who wants a full assessment
Agent skills src/templates/agent-skills/ AI agents answering a single question through az or an Azure MCP server

A separate WPF GUI is maintained outside this repo. This PR contributes the terminal UI and the agent skills.

Running 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, -NonInteractive with -Scans, -DataSource, -SubscriptionId, and -OutputPath supplies every choice, so the tool runs from a pipeline or a scheduled job:

Start-FinOpsMultitool -NonInteractive `
    -SubscriptionId '00000000-0000-0000-0000-000000000000' `
    -Scans Get-OrphanedResources, Get-IdleVMs `
    -DataSource API `
    -OutputPath './results'

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.html summary, and a ScanSummary.txt file.

📦 Files added / changed

Path Purpose
Public/Start-FinOpsMultitool.ps1 Public cmdlet — launches the cross-platform terminal UI
Invoke-FinOpsMultitool.ps1 + FinOpsMultitool.psm1 Terminal UI + module loader
modules/ 30 read-only scanner modules
helpers/Get-FOHubProvider.ps1 + Invoke-FOHubKustoQuery.ps1 Scalable FinOps hub Kusto data path (ADX / Fabric / ftklocal)
agent-skills/finops-multitool/ + references/ Routing hub skill and its investigation references
agent-skills/cost-data-source/ Cost data-source routing skill (Kusto vs storage vs API)
agent-skills/{power-bi-finops, cost-allocation, …}/ 11 FinOps-adjacent skills
Tests/Unit/Start-FinOpsMultitool.Tests.ps1 + FOHubProvider.Tests.ps1 Pester unit tests
docs-mslearn/.../powershell/multitool/ + docs/multitool.md Documentation (command reference, landing page, TOC, changelog)

📸 Screenshots

Screenshots are in the public repo README.

📋 Checklist

🧪 How did you test this change?

  • 🧹 Lint tests
  • 👍 PS -WhatIf / az validate
  • 🔌 Manually deployed + verified
  • 🧪 Unit tests
  • 👀 Integration tests

🐳 Deploy to test?

N/A — standalone PowerShell tooling, not a template deployment.

🏷️ Do any of the following that apply?

  • 🚨 This is a breaking change.
  • 🐣 The change is less than 20 lines of code.

📄 Did you update docs/changelog.md?

  • ✅ Updated changelog
  • ❌ Log not needed (small/internal change)

📖 Did you update documentation?

  • ✅ Documentation updated — FinOps multitool reference under docs-mslearn/.../powershell/multitool/, a Jekyll landing page, overview/TOC/changelog entries, and the module README plus the finops-multitool and cost-data-source skills.
  • ❌ Docs not needed (small/internal change)

… 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).
@z-larsen

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Microsoft"

@flanakin

Copy link
Copy Markdown
Collaborator

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?

https://aka.ms/ftk/contrib-sync

@z-larsen

Copy link
Copy Markdown
Author

Thanks, Michael! Would love to join.

Zac Larsen added 15 commits May 26, 2026 22:11
…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

@MSBrett Brett Wilson (MSBrett) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' }

@MSBrett Brett Wilson (MSBrett) Aug 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@MSBrett Brett Wilson (MSBrett) Aug 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/multitool.md Outdated
</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>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@microsoft-github-policy-service microsoft-github-policy-service Bot added Needs: Attention 👋 Issue or PR needs to be reviewed by the author or it will be closed due to no activity and removed Needs: Review 👀 PR that is ready to be reviewed labels Aug 21, 2026
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.
@microsoft-github-policy-service microsoft-github-policy-service Bot added Needs: Review 👀 PR that is ready to be reviewed and removed Needs: Attention 👋 Issue or PR needs to be reviewed by the author or it will be closed due to no activity labels Aug 21, 2026
Zac Larsen added 7 commits August 21, 2026 12:59
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.
@z-larsen
Zac larsen (z-larsen) marked this pull request as ready for review August 22, 2026 05:12
Copilot AI lite review requested due to automatic review settings August 22, 2026 05:12
Copilot stopped reviewing on behalf of Zac larsen (z-larsen) due to an error August 22, 2026 05:32
Zac Larsen added 2 commits August 21, 2026 23:53
…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.
@z-larsen Zac larsen (z-larsen) changed the title Add Start-FinOpsMultitool cmdlet — interactive GUI for tenant-wide FinOps scanning Add Start-FinOpsMultitool — cross-platform terminal UI for FinOps scanning Aug 22, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs: Review 👀 PR that is ready to be reviewed Tool: PowerShell PowerShell scripts and automation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants