From 306a9628f185fe88f7369be0d31a52a1921990b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 20:29:14 +0000 Subject: [PATCH 1/4] Initial plan From 68c8ca5c71cdc67e7eeecbc6ede7959b373b3d6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 10 Oct 2025 20:37:25 +0000 Subject: [PATCH 2/4] Fix: exclude Dev/Test subscriptions from Azure Hybrid Benefit reports Co-authored-by: MSBrett <24294904+MSBrett@users.noreply.github.com> --- docs-mslearn/toolkit/changelog.md | 5 +++++ src/workbooks/optimization/AHB/AHB.workbook | 4 ++-- src/workbooks/optimization/Compute/AHB.workbook | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 7c5d94e4b..760c2c969 100644 --- a/docs-mslearn/toolkit/changelog.md +++ b/docs-mslearn/toolkit/changelog.md @@ -41,6 +41,11 @@ The following section lists features and enhancements that are currently in deve ### Documentation improvements +### [FinOps workbooks](workbooks/finops-workbooks-overview.md) + +- **Fixed** + - Excluded Dev/Test subscriptions from Azure Hybrid Benefit reports to align with licensing requirements. Azure Hybrid Benefit doesn't apply to Dev/Test resources as Windows licenses are already covered by Visual Studio subscriptions ([#1855](https://github.com/microsoft/finops-toolkit/issues/1855)). + ### [Power BI reports](power-bi/reports.md) v13 - **Fixed** diff --git a/src/workbooks/optimization/AHB/AHB.workbook b/src/workbooks/optimization/AHB/AHB.workbook index 119de4c53..3f917960a 100644 --- a/src/workbooks/optimization/AHB/AHB.workbook +++ b/src/workbooks/optimization/AHB/AHB.workbook @@ -5055,7 +5055,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "ResourceContainers | where type =~ 'Microsoft.Resources/subscriptions' | extend SubscriptionName=name \r\n| join (\r\nresources \r\n| where resourceGroup in ({ResourceGroup})\r\n| where type =~ 'microsoft.compute/virtualMachineScaleSets'\r\n| where tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows' and tostring(properties.virtualMachineProfile.licenseType) == \"Windows_Server\"\r\n| extend WindowsId=id, VMName=name, VMLocation=location, VMRG=resourceGroup, OSType=tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType), OSVersion = tostring(properties.virtualMachineProfile.storageProfile.imageReference.sku), VMSize=tostring (properties.hardwareProfile.vmSize), LicenseType = tostring(properties.virtualMachineProfile.licenseType), VMSSize=tostring(sku.name)\r\n ) on subscriptionId \r\n| order by type asc \r\n| project WindowsId,VMName,VMRG,VMSize, VMSSize, VMLocation,OSType, OSVersion,LicenseType, subscriptionId\r\n| join kind = innerunique(\r\n resources\r\n | extend replaced_tags = replace('{}', 'null', tostring(tags))\r\n | extend replaced_tags = parse_json(replaced_tags)\r\n | mv-expand replaced_tags\r\n | extend tagName = tostring(bag_keys(replaced_tags)[0])\r\n | extend tagValue = tostring(replaced_tags['{TagName}']), WindowsId=id\r\n | where tagName has '{TagName}' and tagValue has '{TagValue}'\r\n | distinct WindowsId\r\n )\r\n on WindowsId", + "query": "ResourceContainers | where type =~ 'Microsoft.Resources/subscriptions' | where tostring (properties.subscriptionPolicies.quotaId) !has \"MSDNDevTest_2014-09-01\" | extend SubscriptionName=name \r\n| join (\r\nresources \r\n| where resourceGroup in ({ResourceGroup})\r\n| where type =~ 'microsoft.compute/virtualMachineScaleSets'\r\n| where tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows' and tostring(properties.virtualMachineProfile.licenseType) == \"Windows_Server\"\r\n| extend WindowsId=id, VMName=name, VMLocation=location, VMRG=resourceGroup, OSType=tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType), OSVersion = tostring(properties.virtualMachineProfile.storageProfile.imageReference.sku), VMSize=tostring (properties.hardwareProfile.vmSize), LicenseType = tostring(properties.virtualMachineProfile.licenseType), VMSSize=tostring(sku.name)\r\n ) on subscriptionId \r\n| order by type asc \r\n| project WindowsId,VMName,VMRG,VMSize, VMSSize, VMLocation,OSType, OSVersion,LicenseType, subscriptionId\r\n| join kind = innerunique(\r\n resources\r\n | extend replaced_tags = replace('{}', 'null', tostring(tags))\r\n | extend replaced_tags = parse_json(replaced_tags)\r\n | mv-expand replaced_tags\r\n | extend tagName = tostring(bag_keys(replaced_tags)[0])\r\n | extend tagValue = tostring(replaced_tags['{TagName}']), WindowsId=id\r\n | where tagName has '{TagName}' and tagValue has '{TagValue}'\r\n | distinct WindowsId\r\n )\r\n on WindowsId", "size": 0, "queryType": 1, "resourceType": "microsoft.resourcegraph/resources", @@ -5081,7 +5081,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "ResourceContainers | where type =~ 'Microsoft.Resources/subscriptions' | extend SubscriptionName=name \r\n| join (\r\nresources \r\n| where resourceGroup in ({ResourceGroup})\r\n| where type =~ 'microsoft.compute/virtualMachineScaleSets'\r\n| where tostring(properties.storageProfile.osDisk.osType) == 'Windows' or tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows'\r\n| where tostring(properties.['licenseType']) !has 'Windows' and tostring(properties.virtualMachineProfile.['licenseType']) !has 'Windows'\r\n| extend WindowsId=id, VMName=name, VMLocation=location, VMRG=resourceGroup, OSType=tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType), OsVersion = tostring(properties.virtualMachineProfile.storageProfile.imageReference.sku), VMSize=tostring (properties.hardwareProfile.vmSize), LicenseType = tostring(properties.virtualMachineProfile.licenseType), VMSSize=tostring(sku.name)\r\n ) on subscriptionId \r\n| order by type asc \r\n| project WindowsId,VMName,VMRG,VMSize, VMSSize, VMLocation,OSType, OsVersion,LicenseType, subscriptionId\r\n| join kind = innerunique(\r\n resources\r\n | extend replaced_tags = replace('{}', 'null', tostring(tags))\r\n | extend replaced_tags = parse_json(replaced_tags)\r\n | mv-expand replaced_tags\r\n | extend tagName = tostring(bag_keys(replaced_tags)[0])\r\n | extend tagValue = tostring(replaced_tags['{TagName}']), WindowsId=id\r\n | where tagName has '{TagName}' and tagValue has '{TagValue}'\r\n | distinct WindowsId\r\n )\r\n on WindowsId\r\n", + "query": "ResourceContainers | where type =~ 'Microsoft.Resources/subscriptions' | where tostring (properties.subscriptionPolicies.quotaId) !has \"MSDNDevTest_2014-09-01\" | extend SubscriptionName=name \r\n| join (\r\nresources \r\n| where resourceGroup in ({ResourceGroup})\r\n| where type =~ 'microsoft.compute/virtualMachineScaleSets'\r\n| where tostring(properties.storageProfile.osDisk.osType) == 'Windows' or tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows'\r\n| where tostring(properties.['licenseType']) !has 'Windows' and tostring(properties.virtualMachineProfile.['licenseType']) !has 'Windows'\r\n| extend WindowsId=id, VMName=name, VMLocation=location, VMRG=resourceGroup, OSType=tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType), OsVersion = tostring(properties.virtualMachineProfile.storageProfile.imageReference.sku), VMSize=tostring (properties.hardwareProfile.vmSize), LicenseType = tostring(properties.virtualMachineProfile.licenseType), VMSSize=tostring(sku.name)\r\n ) on subscriptionId \r\n| order by type asc \r\n| project WindowsId,VMName,VMRG,VMSize, VMSSize, VMLocation,OSType, OsVersion,LicenseType, subscriptionId\r\n| join kind = innerunique(\r\n resources\r\n | extend replaced_tags = replace('{}', 'null', tostring(tags))\r\n | extend replaced_tags = parse_json(replaced_tags)\r\n | mv-expand replaced_tags\r\n | extend tagName = tostring(bag_keys(replaced_tags)[0])\r\n | extend tagValue = tostring(replaced_tags['{TagName}']), WindowsId=id\r\n | where tagName has '{TagName}' and tagValue has '{TagValue}'\r\n | distinct WindowsId\r\n )\r\n on WindowsId\r\n", "size": 0, "queryType": 1, "resourceType": "microsoft.resourcegraph/resources", diff --git a/src/workbooks/optimization/Compute/AHB.workbook b/src/workbooks/optimization/Compute/AHB.workbook index 8792c25d2..8b6473320 100644 --- a/src/workbooks/optimization/Compute/AHB.workbook +++ b/src/workbooks/optimization/Compute/AHB.workbook @@ -4032,7 +4032,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "ResourceContainers | where type =~ 'Microsoft.Resources/subscriptions' | extend SubscriptionName=name \r\n| join (\r\nresources \r\n| where resourceGroup in ({ResourceGroup})\r\n| where type =~ 'microsoft.compute/virtualMachineScaleSets'\r\n| where tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows' and tostring(properties.virtualMachineProfile.licenseType) == \"Windows_Server\"\r\n| extend WindowsId=id, VMName=name, VMLocation=location, VMRG=resourceGroup, OSType=tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType), OSVersion = tostring(properties.virtualMachineProfile.storageProfile.imageReference.sku), VMSize=tostring (properties.hardwareProfile.vmSize), LicenseType = tostring(properties.virtualMachineProfile.licenseType), VMSSize=tostring(sku.name)\r\n ) on subscriptionId \r\n| order by type asc \r\n| project WindowsId,VMName,VMRG,VMSize, VMSSize, VMLocation,OSType, OSVersion,LicenseType, subscriptionId\r\n| join kind = innerunique(\r\n resources\r\n | extend replaced_tags = replace('{}', 'null', tostring(tags))\r\n | extend replaced_tags = parse_json(replaced_tags)\r\n | mv-expand replaced_tags\r\n | extend tagName = tostring(bag_keys(replaced_tags)[0])\r\n | extend tagValue = tostring(replaced_tags['{TagName}']), WindowsId=id\r\n | where tagName has '{TagName}' and tagValue has '{TagValue}'\r\n | distinct WindowsId\r\n )\r\n on WindowsId", + "query": "ResourceContainers | where type =~ 'Microsoft.Resources/subscriptions' | where tostring (properties.subscriptionPolicies.quotaId) !has \"MSDNDevTest_2014-09-01\" | extend SubscriptionName=name \r\n| join (\r\nresources \r\n| where resourceGroup in ({ResourceGroup})\r\n| where type =~ 'microsoft.compute/virtualMachineScaleSets'\r\n| where tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows' and tostring(properties.virtualMachineProfile.licenseType) == \"Windows_Server\"\r\n| extend WindowsId=id, VMName=name, VMLocation=location, VMRG=resourceGroup, OSType=tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType), OSVersion = tostring(properties.virtualMachineProfile.storageProfile.imageReference.sku), VMSize=tostring (properties.hardwareProfile.vmSize), LicenseType = tostring(properties.virtualMachineProfile.licenseType), VMSSize=tostring(sku.name)\r\n ) on subscriptionId \r\n| order by type asc \r\n| project WindowsId,VMName,VMRG,VMSize, VMSSize, VMLocation,OSType, OSVersion,LicenseType, subscriptionId\r\n| join kind = innerunique(\r\n resources\r\n | extend replaced_tags = replace('{}', 'null', tostring(tags))\r\n | extend replaced_tags = parse_json(replaced_tags)\r\n | mv-expand replaced_tags\r\n | extend tagName = tostring(bag_keys(replaced_tags)[0])\r\n | extend tagValue = tostring(replaced_tags['{TagName}']), WindowsId=id\r\n | where tagName has '{TagName}' and tagValue has '{TagValue}'\r\n | distinct WindowsId\r\n )\r\n on WindowsId", "size": 0, "queryType": 1, "resourceType": "microsoft.resourcegraph/resources", @@ -4058,7 +4058,7 @@ "type": 3, "content": { "version": "KqlItem/1.0", - "query": "ResourceContainers | where type =~ 'Microsoft.Resources/subscriptions' | extend SubscriptionName=name \r\n| join (\r\nresources \r\n| where resourceGroup in ({ResourceGroup})\r\n| where type =~ 'microsoft.compute/virtualMachineScaleSets'\r\n| where tostring(properties.storageProfile.osDisk.osType) == 'Windows' or tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows'\r\n| where tostring(properties.['licenseType']) !has 'Windows' and tostring(properties.virtualMachineProfile.['licenseType']) !has 'Windows'\r\n| extend WindowsId=id, VMName=name, VMLocation=location, VMRG=resourceGroup, OSType=tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType), OsVersion = tostring(properties.virtualMachineProfile.storageProfile.imageReference.sku), VMSize=tostring (properties.hardwareProfile.vmSize), LicenseType = tostring(properties.virtualMachineProfile.licenseType), VMSSize=tostring(sku.name)\r\n ) on subscriptionId \r\n| order by type asc \r\n| project WindowsId,VMName,VMRG,VMSize, VMSSize, VMLocation,OSType, OsVersion,LicenseType, subscriptionId\r\n| join kind = innerunique(\r\n resources\r\n | extend replaced_tags = replace('{}', 'null', tostring(tags))\r\n | extend replaced_tags = parse_json(replaced_tags)\r\n | mv-expand replaced_tags\r\n | extend tagName = tostring(bag_keys(replaced_tags)[0])\r\n | extend tagValue = tostring(replaced_tags['{TagName}']), WindowsId=id\r\n | where tagName has '{TagName}' and tagValue has '{TagValue}'\r\n | distinct WindowsId\r\n )\r\n on WindowsId\r\n", + "query": "ResourceContainers | where type =~ 'Microsoft.Resources/subscriptions' | where tostring (properties.subscriptionPolicies.quotaId) !has \"MSDNDevTest_2014-09-01\" | extend SubscriptionName=name \r\n| join (\r\nresources \r\n| where resourceGroup in ({ResourceGroup})\r\n| where type =~ 'microsoft.compute/virtualMachineScaleSets'\r\n| where tostring(properties.storageProfile.osDisk.osType) == 'Windows' or tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType) == 'Windows'\r\n| where tostring(properties.['licenseType']) !has 'Windows' and tostring(properties.virtualMachineProfile.['licenseType']) !has 'Windows'\r\n| extend WindowsId=id, VMName=name, VMLocation=location, VMRG=resourceGroup, OSType=tostring(properties.virtualMachineProfile.storageProfile.osDisk.osType), OsVersion = tostring(properties.virtualMachineProfile.storageProfile.imageReference.sku), VMSize=tostring (properties.hardwareProfile.vmSize), LicenseType = tostring(properties.virtualMachineProfile.licenseType), VMSSize=tostring(sku.name)\r\n ) on subscriptionId \r\n| order by type asc \r\n| project WindowsId,VMName,VMRG,VMSize, VMSSize, VMLocation,OSType, OsVersion,LicenseType, subscriptionId\r\n| join kind = innerunique(\r\n resources\r\n | extend replaced_tags = replace('{}', 'null', tostring(tags))\r\n | extend replaced_tags = parse_json(replaced_tags)\r\n | mv-expand replaced_tags\r\n | extend tagName = tostring(bag_keys(replaced_tags)[0])\r\n | extend tagValue = tostring(replaced_tags['{TagName}']), WindowsId=id\r\n | where tagName has '{TagName}' and tagValue has '{TagValue}'\r\n | distinct WindowsId\r\n )\r\n on WindowsId\r\n", "size": 0, "queryType": 1, "resourceType": "microsoft.resourcegraph/resources", From 92ab7570bd2d4eb9129701fad0dd617755f1ccaf Mon Sep 17 00:00:00 2001 From: Michael Flanakin Date: Fri, 27 Feb 2026 02:49:09 -0800 Subject: [PATCH 3/4] Add integration tests for workbook KQL queries Add Workbooks.Tests.ps1 with integration tests that validate all Azure Resource Graph queries embedded in FinOps workbooks by executing them via Search-AzGraph. Tests cover JSON structure validation, ARG query execution, query performance, KQL syntax, and AHB Dev/Test subscription exclusion. Also add -Workbooks switch to Test-PowerShell.ps1. Co-Authored-By: Claude Opus 4.6 --- .../Tests/Integration/Workbooks.Tests.ps1 | 474 ++++++++++++++++++ src/scripts/Test-PowerShell.ps1 | 7 + 2 files changed, 481 insertions(+) create mode 100644 src/powershell/Tests/Integration/Workbooks.Tests.ps1 diff --git a/src/powershell/Tests/Integration/Workbooks.Tests.ps1 b/src/powershell/Tests/Integration/Workbooks.Tests.ps1 new file mode 100644 index 000000000..3c738c4b1 --- /dev/null +++ b/src/powershell/Tests/Integration/Workbooks.Tests.ps1 @@ -0,0 +1,474 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +& "$PSScriptRoot/../Initialize-Tests.ps1" + +<# + .SYNOPSIS + Recursively extracts all query items from a workbook JSON object. + + .DESCRIPTION + Walks the workbook JSON tree and returns objects for each embedded query, including its name, + JSON path, query text, query type, and resource type. +#> +function Get-WorkbookQueries +{ + param( + [Parameter(Mandatory = $true)] + [object]$Object, + + [Parameter()] + [string]$Path = 'root' + ) + + $queries = @() + + if ($null -eq $Object) + { + return $queries + } + + if ($Object -is [System.Collections.IList]) + { + for ($i = 0; $i -lt $Object.Count; $i++) + { + if ($null -ne $Object[$i]) + { + $queries += Get-WorkbookQueries -Object $Object[$i] -Path "$Path[$i]" + } + } + return $queries + } + + if ($Object -is [PSCustomObject]) + { + # Check if this node has a query property + if ($Object.PSObject.Properties['query'] -and $Object.query -is [string] -and $Object.query.Length -gt 0) + { + $queries += [PSCustomObject]@{ + Name = $Object.PSObject.Properties['name'].Value ?? $Path + Path = $Path + Query = $Object.query + QueryType = $Object.PSObject.Properties['queryType'].Value + ResourceType = $Object.PSObject.Properties['resourceType'].Value + } + } + + # Recurse into child properties + foreach ($prop in $Object.PSObject.Properties) + { + if ($null -ne $prop.Value) + { + $queries += Get-WorkbookQueries -Object $prop.Value -Path "$Path.$($prop.Name)" + } + } + } + + return $queries +} + +<# + .SYNOPSIS + Resolves workbook parameter placeholders so queries can be executed against Azure Resource Graph. + + .DESCRIPTION + Workbook queries contain {ParameterName} placeholders that are resolved at runtime by the Azure + Monitor workbook engine. To execute these queries directly via Search-AzGraph, this function: + 1. Removes entire 'where' clauses that depend on parameters (filter lines) + 2. Replaces inline parameter references with safe literal values + 3. Reports any remaining unresolved parameters +#> +function Resolve-WorkbookParameters +{ + param( + [Parameter(Mandatory = $true)] + [string]$Query + ) + + # Remove inline 'and in ({Param})' filter conditions before line processing + # These appear mid-line and can't be caught by line-level where removal + $result = [regex]::Replace($Query, '\s+and\s+\w+\s+in\s*\(\{[A-Za-z_]+\}\)', '') + + $lines = $result -split "`r?`n" + + $resolved = @() + foreach ($line in $lines) + { + $trimmed = $line.Trim() + + # Remove 'where' clauses that contain parameter placeholders + # These are runtime filters that can't be evaluated statically + if ($trimmed -match '^\|?\s*where\b' -and $trimmed -match '\{[A-Za-z_]+\}') + { + continue + } + + $resolved += $line + } + + $result = $resolved -join "`n" + + # Replacement values are unquoted — the workbook engine replaces {param} + # with the raw selected value; query authors add quotes as needed + # NOTE: Use unary comma (,@()) to prevent PowerShell from flattening nested arrays + $replacements = @( + , @('{TagName}', 'Environment') + , @('{TagValue}', '') + , @('{TagFilter}', '') + , @('{ResourceGroup}', '*') + , @('{Subscription}', '*') + , @('{term}', '1Year') + , @('{LookBackPeriod}', '7') + , @('{resourceType}', '*') + , @('{ResourceType}', '*') + , @('{ResourceFilter}', '') + , @('{ResourceIdFilter}', '') + , @('{SelectedResourceId}', '') + , @('{OrphanDisks}', 'Yes') + , @('{OrphanNIC}', 'Yes') + , @('{OrphanNSG}', 'Yes') + , @('{OrphanIPs}', 'Yes') + , @('{OrphanAppGW}', '|') + , @('{OrphanLB}', 'Yes') + , @('{VMState}', '') + , @('{RuleConditionSet}', '') + , @('{AlertDisplayNameFilter}', '') + , @('{AlertNameFilter}', '') + , @('{NewAlertFilter}', '') + , @('{SeverityFilter}', '') + , @('{ResourceGroupFilter}', '') + , @('{DisplayName}', '') + , @('{selectedOwner}', '') + , @('{selectedWorkspaceId}', '') + ) + + foreach ($pair in $replacements) + { + $result = $result.Replace($pair[0], $pair[1]) + } + + # Prefix 'resources |' for fragment queries that start with a clause keyword + # The workbook engine auto-prefixes the table based on resourceType + if ($result.TrimStart() -match '^(where|extend|project|summarize|join|order|mv-expand|parse|distinct)\b') + { + $result = "resources`n| $($result.TrimStart())" + } + + return $result +} + +Describe 'Workbooks' { + BeforeDiscovery { + $workbookRoot = "$PSScriptRoot/../../../workbooks" + + # Find all workbook files (both .workbook and workbook.json formats) + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $workbookFiles = @( + Get-ChildItem -Path $workbookRoot -Recurse -Include '*.workbook', 'workbook.json' ` + | Where-Object { $_.Directory.Name -ne '.scaffold' } ` + | ForEach-Object { + @{ + Name = $_.Directory.Name + '/' + $_.Name + RelativePath = $_.FullName.Substring((Resolve-Path $workbookRoot).Path.Length + 1) + FullPath = $_.FullName + } + } + ) + + # Find AHB workbook files specifically for Dev/Test filter validation + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $ahbWorkbookFiles = @( + $workbookFiles | Where-Object { $_.Name -like '*AHB*' } + ) + + # Extract all ARG queries across all workbooks for execution tests + [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] + $argQueries = @( + $workbookFiles | ForEach-Object { + $wbFile = $_ + $workbook = Get-Content -Path $wbFile.FullPath -Raw | ConvertFrom-Json + $queries = Get-WorkbookQueries -Object $workbook + $queries ` + | Where-Object { $_.QueryType -eq 1 -and $_.ResourceType -eq 'microsoft.resourcegraph/resources' } ` + | ForEach-Object { + @{ + Workbook = $wbFile.Name + QueryName = $_.Name + Query = $_.Query + WorkbookPath = $wbFile.RelativePath + } + } + } + ) + } + + BeforeAll { + $context = Get-AzContext + if (-not $context) + { + throw 'Not authenticated to Azure. Run Connect-AzAccount first.' + } + + # Verify Az.ResourceGraph module is available + if (-not (Get-Module -ListAvailable -Name 'Az.ResourceGraph')) + { + throw 'Az.ResourceGraph module is not installed. Run Install-Module Az.ResourceGraph.' + } + Import-Module Az.ResourceGraph -ErrorAction Stop + + # Define helper functions in test scope (file-scope functions from BeforeDiscovery + # are not visible inside It blocks in Pester) + function Get-WorkbookQueries + { + param( + [Parameter(Mandatory = $true)] + [object]$Object, + [Parameter()] + [string]$Path = 'root' + ) + + $queries = @() + if ($null -eq $Object) { return $queries } + + if ($Object -is [System.Collections.IList]) + { + for ($i = 0; $i -lt $Object.Count; $i++) + { + if ($null -ne $Object[$i]) + { + $queries += Get-WorkbookQueries -Object $Object[$i] -Path "$Path[$i]" + } + } + return $queries + } + + if ($Object -is [PSCustomObject]) + { + if ($Object.PSObject.Properties['query'] -and $Object.query -is [string] -and $Object.query.Length -gt 0) + { + $queries += [PSCustomObject]@{ + Name = $Object.PSObject.Properties['name'].Value ?? $Path + Path = $Path + Query = $Object.query + QueryType = $Object.PSObject.Properties['queryType'].Value + ResourceType = $Object.PSObject.Properties['resourceType'].Value + } + } + + foreach ($prop in $Object.PSObject.Properties) + { + if ($null -ne $prop.Value) + { + $queries += Get-WorkbookQueries -Object $prop.Value -Path "$Path.$($prop.Name)" + } + } + } + + return $queries + } + + function Resolve-WorkbookParameters + { + param( + [Parameter(Mandatory = $true)] + [string]$Query + ) + + # Remove inline 'and in ({Param})' filter conditions before line processing + # These appear mid-line and can't be caught by line-level where removal + $result = [regex]::Replace($Query, '\s+and\s+\w+\s+in\s*\(\{[A-Za-z_]+\}\)', '') + + $lines = $result -split "`r?`n" + + $resolved = @() + foreach ($line in $lines) + { + $trimmed = $line.Trim() + if ($trimmed -match '^\|?\s*where\b' -and $trimmed -match '\{[A-Za-z_]+\}') + { + continue + } + $resolved += $line + } + + $result = $resolved -join "`n" + + # Replacement values are unquoted — the workbook engine replaces {param} + # with the raw selected value; query authors add quotes as needed + # NOTE: Use unary comma (,@()) to prevent PowerShell from flattening nested arrays + $replacements = @( + , @('{TagName}', 'Environment') + , @('{TagValue}', '') + , @('{TagFilter}', '') + , @('{ResourceGroup}', '*') + , @('{Subscription}', '*') + , @('{term}', '1Year') + , @('{LookBackPeriod}', '7') + , @('{resourceType}', '*') + , @('{ResourceType}', '*') + , @('{ResourceFilter}', '') + , @('{ResourceIdFilter}', '') + , @('{SelectedResourceId}', '') + , @('{OrphanDisks}', 'Yes') + , @('{OrphanNIC}', 'Yes') + , @('{OrphanNSG}', 'Yes') + , @('{OrphanIPs}', 'Yes') + , @('{OrphanAppGW}', '|') + , @('{OrphanLB}', 'Yes') + , @('{VMState}', '') + , @('{RuleConditionSet}', '') + , @('{AlertDisplayNameFilter}', '') + , @('{AlertNameFilter}', '') + , @('{NewAlertFilter}', '') + , @('{SeverityFilter}', '') + , @('{ResourceGroupFilter}', '') + , @('{DisplayName}', '') + , @('{selectedOwner}', '') + , @('{selectedWorkspaceId}', '') + ) + + foreach ($pair in $replacements) + { + $result = $result.Replace($pair[0], $pair[1]) + } + + # Prefix 'resources |' for fragment queries that start with a clause keyword + # The workbook engine auto-prefixes the table based on resourceType + if ($result.TrimStart() -match '^(where|extend|project|summarize|join|order|mv-expand|parse|distinct)\b') + { + $result = "resources`n| $($result.TrimStart())" + } + + return $result + } + } + + Context 'JSON validation' { + It 'Should be valid JSON: ' -ForEach $workbookFiles { + $parseError = $null + try + { + $null = Get-Content -Path $FullPath -Raw | ConvertFrom-Json -ErrorAction Stop + } + catch + { + $parseError = $_.Exception.Message + } + $parseError | Should -BeNullOrEmpty -Because "workbook file should be valid JSON" + } + + It 'Should contain at least one query: ' -ForEach $workbookFiles { + # Arrange + $workbook = Get-Content -Path $FullPath -Raw | ConvertFrom-Json + + # Act + $queries = Get-WorkbookQueries -Object $workbook + + # Assert + $queries.Count | Should -BeGreaterThan 0 -Because "workbook should contain at least one embedded query" + } + } + + Context 'AHB Dev/Test subscription exclusion' { + It 'Should exclude Dev/Test subscriptions in all ResourceContainers queries: ' -ForEach $ahbWorkbookFiles { + # Arrange + $workbook = Get-Content -Path $FullPath -Raw | ConvertFrom-Json + $queries = Get-WorkbookQueries -Object $workbook + + # Act — filter to queries that join ResourceContainers with resources (AHB subscription-scoped queries) + # Exclude simple parameter queries that just look up subscription IDs without joining to resources + $subscriptionQueries = $queries | Where-Object { + $_.Query -match 'ResourceContainers' -and $_.Query -match '\bjoin\b' + } + $missingFilter = $subscriptionQueries | Where-Object { + $_.Query -notmatch 'MSDNDevTest_2014-09-01' + } + + # Assert + $subscriptionQueries.Count | Should -BeGreaterThan 0 -Because "AHB workbooks should have ResourceContainers queries" + $missingFilter | Should -BeNullOrEmpty -Because "all ResourceContainers queries in AHB workbooks must exclude Dev/Test subscriptions using the MSDNDevTest_2014-09-01 filter" + } + } + + Context 'ARG query execution' { + It 'Should execute without errors: / ' -ForEach $argQueries { + Monitor "Executing $Workbook / $QueryName..." -Indent ' ' { + # Substitute workbook parameters with safe defaults + $resolvedQuery = Resolve-WorkbookParameters -Query $Query + + # Check for any remaining unresolved parameters (includes merge format like {Param:value}) + if ($resolvedQuery -match '\{[A-Za-z_]+[:\}]') + { + $unresolvedParams = [regex]::Matches($resolvedQuery, '\{[A-Za-z_]+(?::[A-Za-z_]+)?\}') | ForEach-Object { $_.Value } | Select-Object -Unique + Report "Skipping — unresolved parameters: $($unresolvedParams -join ', ')" + Set-ItResult -Inconclusive -Because "query has unresolved parameters: $($unresolvedParams -join ', ')" + return + } + + try + { + $results = Search-AzGraph -Query $resolvedQuery -First 1 -ErrorAction Stop + Report "Returned $($results.Count) result(s)" + } + catch + { + Report "Query failed: $($_.Exception.Message)" -Exception $_.Exception + throw + } + } + } + } + + Context 'ARG query performance' { + It 'Should complete within 30 seconds: / ' -ForEach $argQueries { + # Substitute workbook parameters with safe defaults + $resolvedQuery = Resolve-WorkbookParameters -Query $Query + + # Skip queries with unresolved parameters (includes merge format like {Param:value}) + if ($resolvedQuery -match '\{[A-Za-z_]+(?::[A-Za-z_]+)?\}') + { + Set-ItResult -Skipped -Because 'query has unresolved parameters' + return + } + + Monitor "Testing performance for $Workbook / $QueryName..." -Indent ' ' { + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + try + { + $null = Search-AzGraph -Query $resolvedQuery -First 1 -ErrorAction Stop + $stopwatch.Stop() + $ms = $stopwatch.ElapsedMilliseconds + Report "Completed in ${ms}ms" + $ms | Should -BeLessThan 30000 -Because 'ARG queries should complete within 30 seconds' + } + catch + { + Set-ItResult -Inconclusive -Because "query execution failed: $($_.Exception.Message)" + } + } + } + } + + Context 'KQL syntax validation' { + It 'Should have balanced parentheses: ' -ForEach $workbookFiles { + # Arrange + $workbook = Get-Content -Path $FullPath -Raw | ConvertFrom-Json + $queries = Get-WorkbookQueries -Object $workbook + + # Act — check KQL queries only (not JSON merge queries) + $kqlQueries = $queries | Where-Object { $_.Query -notmatch '^\s*\{' } + $unbalanced = @($kqlQueries | Where-Object { + $open = ($_.Query.ToCharArray() | Where-Object { $_ -eq '(' }).Count + $close = ($_.Query.ToCharArray() | Where-Object { $_ -eq ')' }).Count + $open -ne $close + }) + + # Assert + if ($unbalanced.Count -gt 0) + { + $details = $unbalanced | ForEach-Object { $_.Name } | Select-Object -First 5 + $unbalanced.Count | Should -Be 0 -Because "all KQL queries should have balanced parentheses, but these do not: $($details -join ', ')" + } + } + } +} diff --git a/src/scripts/Test-PowerShell.ps1 b/src/scripts/Test-PowerShell.ps1 index 46d459a86..9d2ceba1d 100644 --- a/src/scripts/Test-PowerShell.ps1 +++ b/src/scripts/Test-PowerShell.ps1 @@ -32,6 +32,9 @@ .PARAMETER Toolkit Optional. Indicates whether to run FinOps toolkit tests. + .PARAMETER Workbooks + Optional. Indicates whether to run FinOps workbook tests. + .PARAMETER Actions Optional. Indicates whether to run GitHub Actions tests. @@ -73,6 +76,9 @@ param ( [switch] $Toolkit, + [switch] + $Workbooks, + [switch] $Actions, @@ -125,6 +131,7 @@ else if ($FOCUS) { $testsToRun += '*-FinOpsSchema*', 'FOCUS.Tests.ps1' } if ($Hubs) { $testsToRun += '*-FinOpsHub*', '*-Hub*', 'Hubs.Tests.ps1' } if ($Toolkit) { $testsToRun += 'Toolkit.Tests.ps1', '*-FinOpsToolkit*' } + if ($Workbooks) { $testsToRun += '*Workbook*' } if ($Actions) { $testsToRun += 'Action.*.Tests.ps1' } if ($Private) { $testsToRun += (Get-ChildItem -Path "$PSScriptRoot/../powershell/Tests/$testType/Unit" -Exclude *-FinOps*, *-Hub*, *-OpenData* -Name *.Tests.ps1) } if (-not $testsToRun) { $testsToRun = "*" } From 4317472a10c059f8eafee2e7859d1e36b5bbfa20 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 27 Feb 2026 11:00:13 +0000 Subject: [PATCH 4/4] chore: Update ms.date in docs-mslearn files --- docs-mslearn/toolkit/changelog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md index 5f956ab4c..0951f3c49 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: 02/25/2026 +ms.date: 02/27/2026 ms.topic: reference ms.service: finops ms.subservice: finops-toolkit