<#
.SYNOPSIS
Finds all Windows Server VMs of a given OS year (e.g. 2012, 2016) across
every customer Azure subscription reachable via your active GDAP
relationships.
.DESCRIPTION
Reads the customer tenant list produced by Get-GDAPCustomerTenants.ps1
(run separately, in its own process, to avoid a Microsoft.Graph/Az.Accounts
assembly conflict), signs into Azure PowerShell, and for each customer
tenant:
- Lists Azure subscriptions. Tenants where your GDAP role doesn't map to
Azure RBAC (or the mapping hasn't been granted) fail here - logged to
the errors CSV and skipped, not a script bug.
- Runs an Azure Resource Graph query across all VMs, flagging any whose
OS image indicates the target OS year. Matching on the year alone
also catches R2 variants (e.g. "2012" matches both 2012-Datacenter
and 2012-R2-Datacenter).
- Falls back to a per-VM instance-view check (Get-AzVM -Status) for
Windows VMs whose image reference is ambiguous (custom/generalized
image, or in-place upgrade).
Exports two CSVs: confirmed findings, and a log of tenants/subscriptions
that couldn't be checked (and why).
.PARAMETER OSYear
The Windows Server release year to search for, e.g. '2012', '2016', '2019'.
.PARAMETER CustomerCsv
Path to the customer tenant list from Get-GDAPCustomerTenants.ps1.
.PARAMETER OutputPath
Folder to write the CSV reports to. Defaults to the script's own folder.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$OSYear,
[string]$CustomerCsv = (Join-Path $PSScriptRoot 'GDAPCustomers.csv'),
[string]$OutputPath = $PSScriptRoot
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $CustomerCsv)) {
throw "Customer list not found at '$CustomerCsv'. Run Get-GDAPCustomerTenants.ps1 first."
}
# Multiple Az module versions are installed side-by-side on this machine.
# Pin the newest of each explicitly, Az.Accounts first, so a stale auto-picked
# version doesn't get loaded ahead of what the others require.
$moduleVersions = [ordered]@{
'Az.Accounts' = '5.5.3'
'Az.Resources' = '7.1.0'
'Az.ResourceGraph' = '1.0.0'
'Az.Compute' = '11.9.0'
}
foreach ($m in $moduleVersions.Keys) {
Import-Module $m -RequiredVersion $moduleVersions[$m] -ErrorAction Stop
}
$customers = Import-Csv -Path $CustomerCsv
if (-not $customers -or $customers.Count -eq 0) {
Write-Warning "No customer tenants in '$CustomerCsv'. Nothing to scan."
return
}
$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$reportPath = Join-Path $OutputPath "Server${OSYear}_Report_$timestamp.csv"
$errorsPath = Join-Path $OutputPath "Server${OSYear}_Report_Errors_$timestamp.csv"
$findings = [System.Collections.Generic.List[object]]::new()
$errors = [System.Collections.Generic.List[object]]::new()
function Add-ErrorRow {
param($Stage, $TenantId, $TenantName, $SubscriptionId, $SubscriptionName, $Message)
$errors.Add([pscustomobject]@{
Stage = $Stage
TenantId = $TenantId
TenantName = $TenantName
SubscriptionId = $SubscriptionId
SubscriptionName = $SubscriptionName
Message = $Message
})
}
Write-Host "`n=== Sign in to Azure PowerShell ===" -ForegroundColor Cyan
Write-Host "You'll get a device code - open the printed URL in your normal browser and enter it there." -ForegroundColor Cyan
Connect-AzAccount -UseDeviceAuthentication | Out-Null
$homeCtx = Get-AzContext
Write-Host "Signed in to Azure as: $($homeCtx.Account.Id)" -ForegroundColor Green
Write-Host "Customer tenants to check: $($customers.Count)" -ForegroundColor Green
Write-Host "Searching for OS year: $OSYear" -ForegroundColor Green
$idx = 0
foreach ($customer in $customers) {
$idx++
$tenantLabel = if ($customer.TenantName) { $customer.TenantName } else { $customer.TenantId }
Write-Host "`n[$idx/$($customers.Count)] Tenant: $tenantLabel ($($customer.TenantId))" -ForegroundColor Yellow
$subs = $null
try {
$subs = Get-AzSubscription -TenantId $customer.TenantId -ErrorAction Stop
} catch {
Write-Host " Skipped - no Azure RBAC access in this tenant: $($_.Exception.Message)" -ForegroundColor DarkGray
Add-ErrorRow -Stage 'ListSubscriptions' -TenantId $customer.TenantId -TenantName $tenantLabel `
-SubscriptionId '' -SubscriptionName '' -Message $_.Exception.Message
continue
}
if (-not $subs -or $subs.Count -eq 0) {
Write-Host " No subscriptions visible in this tenant." -ForegroundColor DarkGray
continue
}
foreach ($sub in $subs) {
Write-Host " Subscription: $($sub.Name) ($($sub.Id))" -ForegroundColor Gray
try {
Set-AzContext -Subscription $sub.Id -Tenant $customer.TenantId -ErrorAction Stop | Out-Null
} catch {
Add-ErrorRow -Stage 'SetContext' -TenantId $customer.TenantId -TenantName $tenantLabel `
-SubscriptionId $sub.Id -SubscriptionName $sub.Name -Message $_.Exception.Message
continue
}
$kql = @"
Resources
| where type =~ 'microsoft.compute/virtualmachines'
| extend osType = tostring(properties.storageProfile.osDisk.osType)
| extend imgPublisher = tostring(properties.storageProfile.imageReference.publisher)
| extend imgOffer = tostring(properties.storageProfile.imageReference.offer)
| extend imgSku = tostring(properties.storageProfile.imageReference.sku)
| extend powerState = tostring(properties.extended.instanceView.powerState.displayStatus)
| project name, resourceGroup, location, osType, imgPublisher, imgOffer, imgSku, powerState, id
"@
try {
$vms = Search-AzGraph -Query $kql -Subscription $sub.Id -ErrorAction Stop
} catch {
Add-ErrorRow -Stage 'ResourceGraphQuery' -TenantId $customer.TenantId -TenantName $tenantLabel `
-SubscriptionId $sub.Id -SubscriptionName $sub.Name -Message $_.Exception.Message
continue
}
foreach ($vm in $vms) {
$isKnownYear = $vm.imgSku -match [regex]::Escape($OSYear) -or $vm.imgOffer -match [regex]::Escape($OSYear)
$isWindowsUnknown = ($vm.osType -eq 'Windows') -and -not $isKnownYear -and
([string]::IsNullOrEmpty($vm.imgSku) -or $vm.imgOffer -notmatch '^WindowsServer$' -or $vm.imgSku -notmatch '^\d{4}')
if ($isKnownYear) {
$findings.Add([pscustomobject]@{
TenantId = $customer.TenantId
TenantName = $tenantLabel
SubscriptionId = $sub.Id
SubscriptionName = $sub.Name
ResourceGroup = $vm.resourceGroup
VMName = $vm.name
Location = $vm.location
PowerState = $vm.powerState
DetectedOS = "$($vm.imgOffer) $($vm.imgSku)"
DetectionMethod = 'ImageReference'
})
Write-Host " [$OSYear] $($vm.name) - $($vm.imgOffer) $($vm.imgSku)" -ForegroundColor Red
}
elseif ($isWindowsUnknown) {
try {
$detail = Get-AzVM -ResourceGroupName $vm.resourceGroup -Name $vm.name -Status -ErrorAction Stop
$osName = $detail.OsName
if ($osName -match [regex]::Escape($OSYear)) {
$findings.Add([pscustomobject]@{
TenantId = $customer.TenantId
TenantName = $tenantLabel
SubscriptionId = $sub.Id
SubscriptionName = $sub.Name
ResourceGroup = $vm.resourceGroup
VMName = $vm.name
Location = $vm.location
PowerState = $vm.powerState
DetectedOS = $osName
DetectionMethod = 'InstanceView'
})
Write-Host " [$OSYear] $($vm.name) - $osName (instance view)" -ForegroundColor Red
}
} catch {
Add-ErrorRow -Stage 'InstanceViewCheck' -TenantId $customer.TenantId -TenantName $tenantLabel `
-SubscriptionId $sub.Id -SubscriptionName $sub.Name -Message "$($vm.name): $($_.Exception.Message)"
}
}
}
}
}
$findings | Export-Csv -Path $reportPath -NoTypeInformation -Encoding UTF8
$errors | Export-Csv -Path $errorsPath -NoTypeInformation -Encoding UTF8
Write-Host "`n=== Done ===" -ForegroundColor Cyan
Write-Host "GDAP customer tenants checked : $($customers.Count)" -ForegroundColor Green
Write-Host "Server $OSYear VMs found : $($findings.Count)" -ForegroundColor $(if ($findings.Count -gt 0) { 'Red' } else { 'Green' })
Write-Host "Report : $reportPath" -ForegroundColor Green
Write-Host "Tenants/subs skipped or errored (no Azure RBAC via GDAP yet): $($errors.Count)" -ForegroundColor Yellow
Write-Host "Errors log : $errorsPath" -ForegroundColor Yellow