diff --git a/docs-mslearn/toolkit/changelog.md b/docs-mslearn/toolkit/changelog.md
index 335a43af1..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
@@ -59,6 +59,12 @@ The following section lists features and enhancements that are currently in deve
- **Fixed**
- Fixed Init-DataFactory deployment script failing when an Event Grid subscription is already provisioning by checking subscription status before attempting subscribe/unsubscribe and polling separately for completion ([#1996](https://github.com/microsoft/finops-toolkit/issues/1996)).
+### [FinOps workbooks](workbooks/finops-workbooks-overview.md)
+
+- **Fixed**
+ - Excluded dev/test subscriptions from Azure Hybrid Benefit reports to align with licensing requirements ([#1819](https://github.com/microsoft/finops-toolkit/issues/1819)).
+ - Azure Hybrid Benefit doesn't apply to Dev/Test resources as Windows licenses are already covered by Visual Studio subscriptions.
+
## v13 Update 1
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 = "*" }
diff --git a/src/workbooks/optimization/AHB/AHB.workbook b/src/workbooks/optimization/AHB/AHB.workbook
index 9c0248b15..4ace37dbf 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 b20d6bc51..04ef3fa94 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",