Skip to content

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

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

Zac Larsen (z-larsen) wants to merge 148 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
Zac Larsen added 22 commits September 14, 2026 19:00
- A budget query that returned a non-200 or threw was counted as a subscription without a budget, so missing access produced an exact-looking coverage percentage. Unreadable subscriptions are now tracked separately, excluded from the without-budget count, and suppress the percentage.
- Coverage wording now covers both an unqueried sample and an unreadable subscription.
- The variance analysis section did not point at the budget history scan, which is the one purpose-built for month-by-month budget vs actual. Added it there and to the hand-offs list.
- Budget history reused cached Cost Trend data whenever the subscription was
  present, so requesting more months than the cache held reported the
  uncovered months as zero spend and under budget. Coverage is now checked
  and uncovered windows fall back to a live query.
- Cursor repositioning now goes through a guarded helper so a buffer resize
  mid-render cannot surface a .NET stack trace.
- Corrected the sustainability skill data source table for the Emissions
  Impact Dashboard retirement and marked Azure carbon optimization as preview.
- Shared cost allocation called ConvertTo-AllocationPercentages, which an
  earlier cleanup commit deleted along with the write path, so any scan that
  found a positive pool threw CommandNotFoundException. Reimplemented the
  normalization for the single input shape this caller builds.
- Export amounts parsed under the operator's culture, reading 123.45 as 12345
  wherever '.' is the thousands separator. They now parse invariantly.
- The telemetry dimension name reached KQL unescaped, so a caller could
  terminate the string literal and append query operators.
- Region, SKU, and location reached OData filters without their single quotes
  doubled, allowing the filter predicate to be rewritten.
- Added a guard that fails on any call to a command the module never defines,
  plus coverage for the parsing and escaping fixes.
- Fix null comparison ordering, $matches shadowing an automatic variable,
  and four variables that were assigned but never read.
- Rename Parse-* helpers to approved verbs. Two were both named
  Parse-CostRows with different signatures and collided in module scope.
- Suppress console output, helper shape, and signature parity rules with
  justifications, matching the existing pattern in the repository.
- Test.PowerShell.Lint now passes 7144 of 7144.
- Use interval=FULL for the Idle VMs and Storage Tier metric queries.
  P14D and P30D are not published timegrains, so Azure Monitor rejected
  both with HTTP 400 and the scans returned zero on every tenant.
- Count a failed blob capacity read as a metric failure. Every tier
  recommendation requires a capacity reading, so the previous silent
  failure suppressed the recommendation instead of reporting the
  account as unevaluated.
- Fail closed when an explicitly requested subscription cannot be resolved
  and the host cannot prompt. The unanswered picker read a blank answer as
  "scan everything", so an automated run with a stale -SubscriptionId
  exported every subscription in the tenant.
- URI-encode the OData location value. Doubling the quotes alone left a
  percent-encoded quote to decode back into a literal one and rewrite the
  filter predicate.
- Prefix exported cells that open with =, +, - , @, tab, or CR so a resource
  name or tag cannot execute as a spreadsheet formula. Numbers are left alone
  so a negative cost stays a number.
- Parse hub subscription ids as GUIDs and fail on an unparseable one. The
  character-class check passed 36 hyphens, and an all-invalid list dropped the
  scope filter and returned every subscription in the hub.
- Cap gzip expansion when reading export parts, so a highly compressed blob in
  the container cannot exhaust memory.
- Flag commitment figures that came from the tenant-wide reservation order
  endpoint, which cannot be filtered to the scanned subscriptions.
- Document that -SubscriptionId returns an error when the subscription
  cannot be resolved and nothing can answer a prompt, rather than
  scanning every subscription.
- Refresh ms.date on the six pages this pull request changes. The
  update workflow is skipped for fork pull requests.
- Correct full-month forecast windows, column matching, and row totals.
- Flag actual-only forecast fallbacks.
- Keep measured zero utilization without counting absent commitments.
- Restrict export totals to selected subscriptions.
- Reject incomplete pages, required-query failures, and invalid costs.
- Preserve POST bodies and discard failed attempts before retries.
- Calculate hourly vCPU cost from the captured UTC reporting window.
- Separate billed and amortized costs; reject invalid amounts and incomplete coverage.
- Preserve subscription scope, currencies, credits, and observed periods.
- Keep unknown budget forecasts and unsupported history unavailable.
- Surface hub source failures and unverified financial KPIs.
- Respect explicit data-source selection and retain Kusto provenance.
- Validate savings currencies and exclude non-usage charges.
- Separate month-to-date commitment estimates from the AHB estimate.
- Export nested CSV summaries once and use invariant amounts and ISO dates.
- Align public help and documentation with the Microsoft style guide.
- Add source, savings, export, and pagination regression coverage.

Validation: 2812 unit tests passed (7 skipped), 7144 lint checks passed,
and no ScriptAnalyzer findings. Two independent reviews approved the batch.
Successful online Hub validation remains blocked by a 403 response.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] PR Review

Summary: Ambitious, well-organized addition (108 files, 30 read-only scanner modules, terminal UI, 12 new agent skills). Read-only guarantee holds everywhere it was checked — no mutating Azure calls found in any reviewed file. The dominant issue is a systemic pattern: several modules sum Azure Cost Management amounts across subscriptions without checking currency consistency first, unlike the project's own Get-SavingsRealized.ps1/Get-CostTrend.ps1/Get-CostData.ps1, which correctly throw or track per-currency. In an EA/MCA tenant with subscriptions billed in different currencies, this silently produces wrong dollar totals. There's also one execution-verified macOS bug (8 failing Pester tests) and a fail-fast bug that discards an entire scan's results on the first subscription access error.

Pester run on this branch: 526 passed, 8 failed, 7 skipped — all 8 failures trace to two root causes documented below.

Three smaller, non-blocking polish items were split out to #2335 (v16 milestone) rather than posted inline, since they're real but not urgent: a misattributed diagnostic message, a missing static test-guard for the read-only invariant, and missing comment-based help on the primary TUI entry point.

🚫 Blockers (6)

  1. Get-CostByTag.ps1 — a single failed subscription throws and discards the entire scan's results.
  2. Get-CostByTag.ps1 — sums cost across subscriptions/currencies, labels the total with whichever currency was seen last.
  3. Get-AIWorkloadMetrics.ps1 — same cross-currency summing bug in AI cost-per-token/request metrics.
  4. Get-SharedCostAllocation.ps1 — the FinOps Hub data path has no currency-mismatch check (the Live API path in the same function does).
  5. Get-UnitEconomics.ps1 — currency is correctly flagged "Mixed", but the cost/unit-economics numbers next to it aren't gated on that flag.
  6. Read-FinOpsHubData.ps1 — macOS stat -f '%Lp' drops the sticky bit, breaking private-directory validation for any path under a sticky world-writable ancestor (e.g. /tmp). Execution-verified via the failing Pester run above.

⚠️ Should fix (15)

7-8. Same cross-currency summing pattern in Get-OptimizationAdvice.ps1 and Get-ReservationAdvice.ps1 (Advisor savings estimates).
9-12. Missing nextLink pagination: Get-BillingStructure.ps1 (5 calls), Get-MaccCommitment.ps1 (2 calls), Get-BillingAccount.ps1, MgCostScope.ps1.
13-15. Weak/silent failure logging: Get-TagInventory.ps1, Get-PolicyInventory.ps1, Get-ContractInfo.ps1.
16-17. Invoke-FinOpsMultitool.ps1 — silent Hub-detection failure swallowing, and a shared catch that mislabels an untried scan as failed.
18. MultitoolSafety.Tests.ps1 — the "no undefined commands" self-test false-fails on non-Windows (one of the 8 real Pester failures above).
19. Test coverage: 16 of 30 scanner modules have no test exercising their own domain logic, despite the PR checklist marking unit tests as done.
20. finops-reporting/SKILL.md references a content-humanizer skill that doesn't exist anywhere in the repo.
21. finops-multitool-commands.md's scan-coverage table doesn't disclose that 4 of the 30 modules aren't reachable through the documented menu or -Scans parameter.

💡 Suggestions (8)

Minor: stale .gitignore entry, a malformed survey URL, changelog tense inconsistency, a duplicated bullet, an unjustified tolower(), a USD-only savings figure with no currency label, dead code, and the same hub-path currency pattern as the blockers (lower risk here, but worth the same fix while touching this code).

Comment on lines +511 to +513
if (-not $subResp -or $subResp.StatusCode -ne 200) {
throw "Cost-by-tag query failed for $($pj.Call.SubName) (HTTP $($subResp.StatusCode)); results are incomplete."
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] 🚫 Blocker

Any non-200 response from any subscription's cost query throws immediately, aborting the whole scan and discarding all tag aggregation already collected for prior subscriptions. In a tenant of 50 subscriptions, one 403 (missing Cost Management Reader on a single sub) on subscription #3 discards results for subs 1-2 and never processes subs 4-50. This also makes the $subsFailed bookkeeping and the "N subscription(s) returned data, M skipped" summary message unreachable — the partial-failure design those imply can never actually run.

Suggest catching the per-subscription failure, incrementing $subsFailed/$script:costAccessIssue as already coded, and continue-ing to the next subscription instead of throwing — consistent with how Get-CostData.ps1/Get-CostTrend.ps1 handle partial subscription failures.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Taking this — fix incoming in a stacked PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Fix posted: z-larsen#1

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] 🚫 Blocker

Multi-currency correctness bug. Cost is summed across all subscriptions into $tagAgg/$grandTotal (lines 456, 484-485) as raw numbers with no currency check, and $currencySeen (initialized line 376, overwritten line 456) only ever holds whichever subscription's currency was processed last. In an EA/MCA tenant with subscriptions billed in different currencies, this silently adds e.g. USD + EUR + GBP and labels the meaningless total with one arbitrary currency.

The project's own Resolve-CurrencyLabel.ps1 helper exists to prevent exactly this and is already used correctly in Get-CostTrend.ps1/Get-CostData.ps1 (which throw or return "Mixed"), but this file doesn't use it. Suggest routing through it, or throwing on currency mismatch the way Get-SharedCostAllocation.ps1's Live API path does.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Taking this — fix incoming in a stacked PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Fix posted: z-larsen#1

$amount = if ($iCost -ge 0) { [double]$row[$iCost] } else { [double]$row[0] }
$rid = if ($iRes -ge 0) { [string]$row[$iRes] } else { '' }
if ($iCur -ge 0 -and $row[$iCur]) { Add-CurrencySeen -Seen $currenciesSeen -Currency ([string]$row[$iCur]) }
$aiCost += $amount

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] 🚫 Blocker

Same cross-currency summing bug: $aiCost += $amount sums Cost Management amounts across all AI/OpenAI accounts and subscriptions with no currency gate. Currency is set to 'Mixed' via Resolve-CurrencyLabel at line 373 when multiple currencies are seen, but that label doesn't stop the sum from happening — TotalAICost, CostPer1KTokens, and CostPerRequest are still computed from the cross-currency total and reported as if meaningful.

Contrast with Get-SavingsRealized.ps1, which nulls out its savings fields rather than reporting a number under a "Mixed" label.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Taking this — fix incoming in a stacked PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Fix posted: z-larsen#1

Comment on lines +191 to +202
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 }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] 🚫 Blocker

When cost data comes from -HubData (the FinOps Hub Kusto path this PR explicitly prefers), if ($cur) { $currency = $cur } simply overwrites the currency with whatever the last row happens to carry — no mismatch check. $byResource/$bySub are summed regardless, so a hub export spanning spoke subscriptions billed in different currencies silently produces a wrong SharedPool.TotalCost and per-spoke allocation, labeled with an arbitrary currency.

The else (Live API) branch of this same function at lines 246-249 already does this correctly — it throws 'Allocation cost currency is missing or mixed; cost coverage is incomplete.' on a mismatch. Apply the same check to the hub branch. This also propagates to Get-UsageProportionalAllocation.ps1, which reuses this helper.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Taking this — fix incoming in a stacked PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Fix posted: z-larsen#1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Correction to my reply above — on closer read, this isn't actually a bug: Get-HubCostSchema already validates currency consistency across the full $HubData array before this loop runs (it throws on mismatch), so the per-row $currency = $cur overwrite here can never actually see a different currency in practice. No fix needed, and z-larsen#1 does not touch this file. Sorry for the noise.

Comment on lines +385 to +401
Currency = Resolve-CurrencyLabel -Seen $currenciesSeen
CostPeriodStartUtc = $costPeriodStartUtc
CostPeriodEndUtc = $costPeriodEndUtc
ComputeCost = [math]::Round($computeCost, 2)
StorageCost = [math]::Round($storageCost, 2)
ComputeSharePct = $computeSharePct
StorageSharePct = $storageSharePct
VmCount = $vmCount
TotalVCpu = $totalVCpu
TotalMemoryGb = [math]::Round($totalMemGb, 0)
DiskGb = [math]::Round($diskGb, 1)
BlobFileGb = [math]::Round($blobFileGb, 1)
TotalStorageGb = [math]::Round($totalGb, 1)
CostPerVCpu = $costPerVCpu
CostPerGbRam = $costPerGbRam
CostPerVm = $costPerVm
CostPerGb = $costPerGb

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] 🚫 Blocker

Currency correctly reports "Mixed" via Resolve-CurrencyLabel when more than one currency is seen (tracked via Add-CurrencySeen), but ComputeCost, StorageCost, CostPerVCpu, CostPerGbRam, CostPerVm, and CostPerGb are not gated on that condition — a multi-currency tenant gets Currency = "Mixed" alongside fully-populated, materially wrong unit-economics numbers, and the $notes diagnostics never mention the mixed-currency condition.

Get-SavingsRealized.ps1 establishes the right pattern in this same PR: null out the dependent numeric fields (rather than just labeling the currency) when the underlying amounts are mixed/unresolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Taking this — fix incoming in a stacked PR.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] Fix posted: z-larsen#1

Comment on lines +961 to +972
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
if ($DataSource.Source -eq 'Hub') {
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
}
else {
Write-FinOpsConsole " Loading Hub tag data for fast tag scans..." -ForegroundColor DarkGray
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] 💡 Suggestion

Inside elseif ($DataSource.Source -eq 'Hub' -and $DataSource.HubStorage), the nested if ($DataSource.Source -eq 'Hub') {...} else {...} (966-972) has an unreachable else branch, since the outer condition already guarantees Source -eq 'Hub'. No functional impact, but it reads like a "fast tag scan only" code path that no longer exists — leftover from an earlier refactor.

Comment thread .gitignore Outdated
release/scloud-occurrence-report.md

# FinOps multitool scan output (contains live tenant cost data)
FinOpsResults/

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] 💡 Suggestion

No code path in src/powershell/ creates a directory named FinOpsResults — the only match for that string is $global:FinOpsResults, an in-memory hashtable, not a folder. Real report output goes to LocalApplicationData/FinOpsToolkit/Multitool/Reports/<run> by default (or a user -OutputPath), and Assert-FinOpsReportPath actively throws if that path is inside a Git repo/worktree. This entry looks like it conflated the PowerShell variable name with a directory name — harmless, but doesn't ignore any real artifact.


<!-- prettier-ignore-start -->
> [!div class="nextstepaction"]
> [Vote on or suggest ideas](https://github.com/microsoft/finops-toolkit/issues?q=is%3Aissue%20is%3Aopen%20label%3A%22Tool%3A%20PowerShell%22%20sort%3A"reactions-%2B1-desc")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] 💡 Suggestion

The "Vote on or suggest ideas" link ends ...sort%3A"reactions-%2B1-desc") — literal, unescaped double quotes embedded mid-URL. This looks copy-pasted from the pre-existing docs-mslearn/toolkit/powershell/powershell-commands.md:97 footer (which has the same issue, untouched by this PR). The other new pages in this PR use the clean form (sort%3Areactions-%2B1-desc, no quotes) — worth fixing here while touching this boilerplate rather than propagating it further.

Comment thread docs-mslearn/toolkit/changelog.md Outdated
Comment on lines +61 to +63
- Includes 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.
- Cost scans prefer the FinOps hub's Azure Data Explorer or Microsoft Fabric Kusto database and push aggregation into the engine to scale to large environments, with a storage reader as a small-dataset fallback.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] 💡 Suggestion

Per the coding guidelines, changelog entries should "start with a past-tense verb." These two sub-bullets ("Includes 30 read-only scan modules...", "Cost scans prefer the FinOps hub's...") use present tense, while the sibling sub-bullets ("Added a companion set...", "Added a non-interactive mode...") correctly use past tense. Cosmetic — sub-bullets are meant to provide context, so this may be intentional — but it reads inconsistently within the same entry.

Comment on lines +107 to +108
- An authenticated Azure session (`az login`, or `Connect-AzAccount` for the terminal UI).
- An authenticated Azure session is required (`Connect-AzAccount`).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [AI][Claude Code] 💡 Suggestion

These two bullets state the same fact twice in immediate succession with slightly different wording — looks like a merge/edit leftover. Suggest collapsing into one bullet.

- Preserve partial cost coverage and currency-safe monetary estimates.
- Retain billing pagination and membership lookup failures.
- Keep missing inventory, utilization, and carbon evidence unavailable.
- Reconcile small allocations and preserve macOS permission bits.
- Fix Parquet row mapping and case-sensitive module packaging.
- Add real reader and packaged-launch tests with platform CI evidence.
- Clarify documentation and financial units in agent skills.
@z-larsen

Zac Larsen (z-larsen) commented Sep 24, 2026 •

Copy link
Copy Markdown
Author

Published ae36741e with the September 23-24 review fixes and regression coverage.

Follow-up f11f8611 moves the runner-local temporary directory setting to the test steps. GitHub rejected the original job-level runner.temp expression before starting any tests. The corrected workflow passed actionlint 1.7.12; hosted test results remain pending.

Review follow-up

Concern Change and regression evidence
Incomplete tag maps and failed subscription cost queries Complete tag-map reads are required; successful subscription costs remain explicitly partial when another subscription fails. Failed continuation pages contribute no partial amounts. Covered in CostQueryPagination.Tests.ps1.
AI and unit-economics monetary rates Missing or mixed currencies suppress money, not measured usage or capacity. AI rates use matching account costs and usage. Covered in CostQueryPagination.Tests.ps1 and MultitoolSafety.Tests.ps1.
Advisor and reservation savings Recommendations retain their currencies; incompatible totals remain unavailable. Covered in CurrencyLabel.Tests.ps1 and the real consumer/report checks in MultitoolSafety.Tests.ps1.
Billing pagination and membership failures Account, hierarchy, rule, and MACC reads follow validated pages. Failed or empty membership responses remain visible through the resolver and consumers. Covered in CostQueryPagination.Tests.ps1.
Incomplete inventory and measurement evidence Tags, policy, alerts, idle-VM metrics, and carbon measurements no longer turn failed reads into healthy or zero results. Covered in MultitoolSafety.Tests.ps1 and PolicyEffect.Tests.ps1.
Hub discovery and converter isolation Failed discovery requires an explicit alternative; a failed converter doesn't suppress unrelated scans. Covered through the public launcher in Start-FinOpsMultitool.Tests.ps1.
Allocation integrity Case-insensitive spoke deduplication and cent reconciliation prevent duplicate or negative allocations. The existing Hub currency guard is retained and tested rather than duplicated. Covered in MultitoolSafety.Tests.ps1.
Native filesystem behavior BSD permission checks retain the sticky bit; Windows-only command checks are scoped appropriately. Covered in ParquetPackageClient.Tests.ps1 and MultitoolSafety.Tests.ps1.
Real Parquet loading Integration testing exposed and fixed null row values and nested-schema column misalignment. MultitoolParquet.Tests.ps1 uses real pinned packages, signatures, payload checks, assembly loading, and separate cold/cached processes with Snappy and multiple row groups.
Distributable packaging Fixed directory casing and added an isolated build, manifest import, public launch, and CSV/HTML/text checks in MultitoolPackage.Tests.ps1. Azure calls use synthetic responses.

Local validation

Windows, PowerShell 7.6.6, Pester 6.0.0:

  • Full unit suite: 3,049 passed, 0 failed, 8 skipped.
  • Full lint suite: 7,144 passed, 0 failed.
  • Package and real Parquet integration: 5 passed, 0 failed, 0 skipped.
  • The final README-only cleanup passed all 1,568 documentation-link tests. These are part of the unit suite, not additional coverage counts.
  • Changed-script parsing, analyzer checks, and staged whitespace checks passed.

CI and remaining limits

The workflow includes Windows, Ubuntu, and macOS multitool jobs, with NUnit artifacts and summaries identifying the tested commit and host. No Azure sign-in or deployment credentials are used. Hosted Linux/macOS results are not yet available; local Windows results don't establish native compatibility.

macOS runs the native unit and packaged-launch checks. Microsoft documents NuGet signed-package verification as unsupported on macOS, so real signed Parquet integration runs on Windows and Ubuntu. Production signature verification remains enforced; no bypass was added. Kusto or available CSV exports are the alternatives on macOS.

The integration tests don't prove live Azure coverage. A new VM test package is ready; live validation of the current build remains pending.

The three follow-ups in #2335 remain deferred to v16: storage error classification, a static read-only scanner guard, and private launcher help. This update doesn't claim to close them.

The README retains usage, limitations, and reusable test instructions. PR-specific tracking is kept in this comment instead.

Zac Larsen added 2 commits September 24, 2026 11:31
Add a searchable KPI reference with formulas, inputs, interpretation and limitations. Clarify cost-share denominators, measurement periods and forecast availability. Preserve measured zero unit rates and report export when the KPI catalog is unavailable.

This branch has not been deployed

No deployments
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.

6 participants