<#
.SYNOPSIS
Finds all Windows Server 2016 VMs 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 Windows Server 2016.
- 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 Server 2016 findings, and a log of
tenants/subscriptions that couldn't be checked (and why).
.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(
[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 "Server2016_Report_$timestamp.csv"
$errorsPath = Join-Path $OutputPath "Server2016_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 "A native sign-in window will open (WAM broker) - this supports passkeys/Windows Hello/FIDO2," -ForegroundColor Cyan
Write-Host "unlike device-code auth, which some tenants' Conditional Access policies block outright." -ForegroundColor Cyan
Connect-AzAccount | 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
$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) {
$isKnown2016 = $vm.imgSku -match '2016' -or $vm.imgOffer -match '2016'
$isWindowsUnknown = ($vm.osType -eq 'Windows') -and -not $isKnown2016 -and
([string]::IsNullOrEmpty($vm.imgSku) -or $vm.imgOffer -notmatch '^WindowsServer$' -or $vm.imgSku -notmatch '^\d{4}')
if ($isKnown2016) {
$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 " [2016] $($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 '2016') {
$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 " [2016] $($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 2016 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