ci: expand Windows feature fixtures and test Server 2022 (#2503)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-07 09:15:58 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 72e10af92d
commit 56137ffdc1
16 changed files with 862 additions and 356 deletions
+478 -289
View File
@@ -299,7 +299,7 @@ jobs:
runs-on: ubuntu-24.04
needs:
- package
- test
- windows-test
permissions:
contents: write
steps:
@@ -317,9 +317,18 @@ jobs:
windows_exporter-*.msi \
sha256sums.txt
test:
runs-on: windows-2025
windows-test:
name: Windows tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
# Server 2025 runners are much slower to provision the same fixtures.
- os: windows-2022
nanoserver: ltsc2022
smtp: true
permissions:
contents: read
env:
@@ -328,248 +337,246 @@ jobs:
CRI_ENDPOINT: "npipe:////./pipe/containerd-containerd"
CRITOOLS_VERSION: "1.37.0"
KUBERNETES_VERSION: "v1.37.1"
NANOSERVER_IMAGE: mcr.microsoft.com/windows/nanoserver:${{ matrix.nanoserver }}
SMTP_ENABLED: ${{ matrix.smtp }}
WINDOWS_EXPORTER_TEST_RDP_USER: rdp-ci
WINDOWS_TEST_OS: ${{ matrix.os }}
# These collectors must work on the fixtures below; missing features are failures.
WINDOWS_EXPORTER_TEST_COLLECTORS: "cache,container,cpu,cpu_info,dhcp,diskdrive,dns,fsrmquota,hyperv,iis,license,logical_disk,memory,mscluster,msmq,net,netframework,nps,os,pagefile,physical_disk,printer,process,scheduled_task,service,smb,smbclient,system,tcp,terminal_services,time,udp,update"
WINDOWS_EXPORTER_TEST_COLLECTORS: "cache,container,cpu,cpu_info,dhcp,diskdrive,dns,fsrmquota,hyperv,iis,license,logical_disk,memory,mscluster,msmq,mssql,net,netframework,nps,os,pagefile,physical_disk,printer,process,scheduled_task,service,smb,smbclient,system,tcp,terminal_services,time,udp,update${{ matrix.smtp && ',smtp' || '' }}"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: 'false'
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
# Skip the SQL Server download and extraction when the setup files are cached.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
# renovate: golang=go
go-version: '1.27.1'
path: ${{ runner.temp }}/sql-server/setup
key: sql-server-2025-express-fa7e1fabc9a2-${{ runner.os }}
- parallel:
- name: Install Containers feature
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# Steps that do not register services run in the background while Windows features install.
- name: Install FreeRDP client
id: install-freerdp
background: true
shell: powershell
run: |
foreach ($attempt in 1..3) {
choco install freerdp.portable --version=3.28.0 --yes --no-progress
if ($LASTEXITCODE -eq 0) { break }
if ($attempt -eq 3) { throw "Failed to install FreeRDP" }
Start-Sleep -Seconds 10
}
Get-Command wfreerdp.exe -ErrorAction Stop
$features = @(
"Containers"
"Failover-Clustering"
"RSAT-Clustering-PowerShell"
)
$result = Install-WindowsFeature -Name $features
$result | Format-List Success, RestartNeeded, ExitCode, FeatureResult
if (-not $result.Success -or $result.RestartNeeded -ne "No") {
throw "Containers feature could not be installed without a restart"
}
Get-WindowsFeature -Name $features |
Format-Table DisplayName, Name, InstallState
- name: Setup storage pool
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$existingDiskIds = @(Get-PhysicalDisk | ForEach-Object ObjectId)
$disk1 = Join-Path $env:RUNNER_TEMP "storage-pool-1.vhdx"
$disk2 = Join-Path $env:RUNNER_TEMP "storage-pool-2.vhdx"
$diskpartScript = Join-Path $env:RUNNER_TEMP "storage-pool-diskpart.txt"
@"
create vdisk file="$disk1" maximum=10240 type=expandable
attach vdisk
create vdisk file="$disk2" maximum=10240 type=expandable
attach vdisk
"@ | Set-Content -Path $diskpartScript -Encoding ascii
diskpart /s $diskpartScript
Update-HostStorageCache
$newDisks = @(Get-PhysicalDisk | Where-Object { $_.ObjectId -notin $existingDiskIds })
$newDisks | Format-Table FriendlyName, DeviceId, MediaType, BusType, Size, CanPool, CannotPoolReason
$poolableDisks = @($newDisks | Where-Object CanPool)
if ($poolableDisks.Count -ne 2) {
throw "Expected two poolable VHDX disks, found $($poolableDisks.Count)"
}
New-StoragePool `
-FriendlyName "GitHubActions" `
-StorageSubsystemFriendlyName "Windows Storage*" `
-PhysicalDisks $poolableDisks
$pool = Get-StoragePool -FriendlyName "GitHubActions" -ErrorAction Stop
$pool | Format-List FriendlyName, HealthStatus, OperationalStatus, Size, AllocatedSize
if ($pool.IsPrimordial) {
throw "Expected a concrete storage pool"
}
New-VirtualDisk -StoragePoolFriendlyName "GitHubActions" `
-FriendlyName "CIVirtualDisk" -Size 1GB -ResiliencySettingName Simple
- name: Download container prerequisites
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$cacheDir = Join-Path $env:RUNNER_TEMP "container-prereqs"
New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null
$downloads = @{
"Install-Containerd.ps1" = "https://raw.githubusercontent.com/kubernetes-sigs/sig-windows-tools/877cfb9ae91749ab9d73314337504ac962d6f90e/hostprocess/Install-Containerd.ps1"
"containerd.tar.gz" = "https://github.com/containerd/containerd/releases/download/v$($env:CONTAINERD_VERSION)/containerd-$($env:CONTAINERD_VERSION)-windows-amd64.tar.gz"
"crictl.tar.gz" = "https://github.com/kubernetes-sigs/cri-tools/releases/download/v$($env:CRITOOLS_VERSION)/crictl-v$($env:CRITOOLS_VERSION)-windows-amd64.tar.gz"
"cni-plugins.tgz" = "https://github.com/containernetworking/plugins/releases/download/v$($env:CNI_VERSION)/cni-plugins-windows-amd64-v$($env:CNI_VERSION).tgz"
"kubelet.exe" = "https://dl.k8s.io/release/$($env:KUBERNETES_VERSION)/bin/windows/amd64/kubelet.exe"
"kubelet.exe.sha256" = "https://dl.k8s.io/release/$($env:KUBERNETES_VERSION)/bin/windows/amd64/kubelet.exe.sha256"
"hns.psm1" = "https://raw.githubusercontent.com/microsoft/SDN/32df9732ef6dfab07dba205440e30bdfbd8e579a/Kubernetes/windows/hns.psm1"
}
foreach ($download in $downloads.GetEnumerator()) {
Invoke-WebRequest `
-Uri $download.Value `
-OutFile (Join-Path $cacheDir $download.Key)
}
$installer = Join-Path $cacheDir "Install-Containerd.ps1"
$installerContent = Get-Content -Raw $installer
$installerContent = $installerContent.Replace(
' curl.exe --silent --fail -Lo $destination $source',
' if (Test-Path $destination) { Write-Host "Using pre-downloaded $destination"; return }' + "`r`n" + ' curl.exe --silent --fail -Lo $destination $source'
)
Set-Content -Path $installer -Value $installerContent -Encoding utf8
- name: Start Hyper-V VM
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
Import-Module Hyper-V
Start-Service vmms
New-VMSwitch -Name "CISwitch" -SwitchType Internal
New-VM `
-Name "GitHubActions" `
-Generation 1 `
-MemoryStartupBytes 256MB `
-NewVHDPath (Join-Path $env:RUNNER_TEMP "hyperv-ci.vhdx") `
-NewVHDSizeBytes 1GB `
-SwitchName "CISwitch"
Add-VMNetworkAdapter -VMName "GitHubActions" -Name "CILegacy" `
-IsLegacy $true -SwitchName "CISwitch"
Start-VM -Name "GitHubActions"
$vm = Get-VM -Name "GitHubActions"
$vm | Format-List Name, State, Status, Generation, Uptime
if ($vm.State -ne "Running") {
throw "Expected Hyper-V VM to be running, got $($vm.State)"
}
- name: Setup local RDP session
id: setup-rdp
continue-on-error: true
- name: Setup BitLocker volumes
id: setup-bitlocker
background: true
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$userName = "rdp-ci"
$password = "WindowsExporter-CI-2026!"
$securePassword = ConvertTo-SecureString $password -AsPlainText -Force
. ./tools/vhd-lock.ps1
if ($null -eq (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) {
New-LocalUser `
-Name $userName `
-Password $securePassword `
-PasswordNeverExpires `
-AccountNeverExpires | Out-Null
}
# Hosted images ship the BitLocker feature; data volumes need no reboot.
function New-BitLockerVolume([string]$Name) {
$vhd = Join-Path $env:RUNNER_TEMP "$Name.vhdx"
New-VHD -Path $vhd -Dynamic -SizeBytes 512MB | Out-Null
$volume = Invoke-WithVhdLock {
Mount-VHD $vhd -PassThru | Get-Disk |
Initialize-Disk -PartitionStyle GPT -PassThru |
New-Partition -UseMaximumSize -AssignDriveLetter |
Format-Volume -FileSystem NTFS -NewFileSystemLabel $Name -Confirm:$false
}
$mountPoint = "$($volume.DriveLetter):"
Add-LocalGroupMember `
-Group "Remote Desktop Users" `
-Member $userName `
-ErrorAction SilentlyContinue
$password = ConvertTo-SecureString ("CI-" + [Guid]::NewGuid().ToString('N')) -AsPlainText -Force
Enable-BitLocker -MountPoint $mountPoint -PasswordProtector -Password $password -UsedSpaceOnly | Out-Null
Set-ItemProperty `
-Path "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server" `
-Name fDenyTSConnections `
-Value 0
Start-Service TermService
(Get-Service TermService).WaitForStatus("Running", [TimeSpan]::FromSeconds(30))
choco install freerdp.portable `
--version=3.28.0 `
--yes `
--no-progress
if ($LASTEXITCODE -ne 0) {
throw "Failed to install FreeRDP"
}
$wfreerdp = (Get-Command wfreerdp.exe -ErrorAction Stop).Source
$freeRdp = Start-Process `
-FilePath $wfreerdp `
-ArgumentList @(
"/v:127.0.0.1"
"/u:$env:COMPUTERNAME\$userName"
"/p:$password"
"/cert:ignore"
"/size:800x600"
) `
-PassThru
Write-Host "Started wfreerdp.exe with PID $($freeRdp.Id)"
$deadline = (Get-Date).AddSeconds(60)
$rdpSession = $null
do {
$sessions = qwinsta.exe
$sessions | ForEach-Object { Write-Host $_ }
$rdpSession = $sessions |
Where-Object { $_ -match "RDP-Tcp#" } |
Select-Object -First 1
if ($null -ne $rdpSession) {
break
$deadline = (Get-Date).AddMinutes(2)
while ((Get-BitLockerVolume -MountPoint $mountPoint).VolumeStatus -ne "FullyEncrypted") {
if ((Get-Date) -ge $deadline) { throw "BitLocker volume $Name did not finish encryption" }
Start-Sleep -Seconds 2
}
if ($freeRdp.HasExited) {
throw "wfreerdp.exe exited before an RDP session was created"
}
Start-Sleep -Seconds 2
} while ((Get-Date) -lt $deadline)
if ($null -eq $rdpSession) {
throw "No RDP-Tcp session was created by FreeRDP"
return $mountPoint
}
Write-Host "RDP session created: $rdpSession"
$unlocked = New-BitLockerVolume CIBitLocker
$locked = New-BitLockerVolume CIBitLockerLocked
Lock-BitLocker -MountPoint $locked -ForceDismount | Out-Null
$remoteFxNetwork = Get-Counter -ListSet "RemoteFX Network" -ErrorAction Stop
$remoteFxGraphics = Get-Counter -ListSet "RemoteFX Graphics" -ErrorAction Stop
Get-BitLockerVolume -MountPoint $unlocked, $locked |
Format-Table MountPoint, VolumeStatus, ProtectionStatus, LockStatus
$networkInstances = @($remoteFxNetwork.PathsWithInstances |
Where-Object { $_ -match "RDP-Tcp#" })
$graphicsInstances = @($remoteFxGraphics.PathsWithInstances |
Where-Object { $_ -match "RDP-Tcp#" })
@{ Unlocked = $unlocked; Locked = $locked } | ConvertTo-Json |
Set-Content (Join-Path $env:RUNNER_TEMP "bitlocker-fixture.json")
Write-Host "RemoteFX Network instances:"
$networkInstances | ForEach-Object { Write-Host $_ }
- name: Setup storage pool
id: setup-storage-pool
background: true
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
Write-Host "RemoteFX Graphics instances:"
$graphicsInstances | ForEach-Object { Write-Host $_ }
$disk1 = Join-Path $env:RUNNER_TEMP "storage-pool-1.vhdx"
$disk2 = Join-Path $env:RUNNER_TEMP "storage-pool-2.vhdx"
$diskpartScript = Join-Path $env:RUNNER_TEMP "storage-pool-diskpart.txt"
if ($networkInstances.Count -eq 0) {
Write-Warning "No RemoteFX Network RDP-Tcp instance was created"
@"
create vdisk file="$disk1" maximum=10240 type=expandable
attach vdisk
create vdisk file="$disk2" maximum=10240 type=expandable
attach vdisk
"@ | Set-Content -Path $diskpartScript -Encoding ascii
. ./tools/vhd-lock.ps1
Invoke-WithVhdLock {
diskpart /s $diskpartScript
if ($LASTEXITCODE -ne 0) { throw "diskpart failed to attach the storage pool disks" }
Update-HostStorageCache
}
if ($graphicsInstances.Count -eq 0) {
Write-Warning "No RemoteFX Graphics RDP-Tcp instance was created"
# Background steps attach their own VHDX files concurrently.
$diskNumbers = @($disk1, $disk2 | ForEach-Object { (Get-DiskImage -ImagePath $_).Number })
$newDisks = @(Get-PhysicalDisk | Where-Object { [int]$_.DeviceId -in $diskNumbers })
$newDisks | Format-Table FriendlyName, DeviceId, MediaType, BusType, Size, CanPool, CannotPoolReason
$poolableDisks = @($newDisks | Where-Object CanPool)
if ($poolableDisks.Count -ne 2) {
throw "Expected two poolable VHDX disks, found $($poolableDisks.Count)"
}
New-StoragePool `
-FriendlyName "GitHubActions" `
-StorageSubsystemFriendlyName "Windows Storage*" `
-PhysicalDisks $poolableDisks
$pool = Get-StoragePool -FriendlyName "GitHubActions" -ErrorAction Stop
$pool | Format-List FriendlyName, HealthStatus, OperationalStatus, Size, AllocatedSize
if ($pool.IsPrimordial) {
throw "Expected a concrete storage pool"
}
New-VirtualDisk -StoragePoolFriendlyName "GitHubActions" `
-FriendlyName "CIVirtualDisk" -Size 1GB -ResiliencySettingName Simple
- name: Start Hyper-V VM
id: start-hyperv-vm
background: true
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
Import-Module Hyper-V
Start-Service vmms
New-VMSwitch -Name "CISwitch" -SwitchType Internal
New-VM `
-Name "GitHubActions" `
-Generation 1 `
-MemoryStartupBytes 256MB `
-NewVHDPath (Join-Path $env:RUNNER_TEMP "hyperv-ci.vhdx") `
-NewVHDSizeBytes 1GB `
-SwitchName "CISwitch"
Add-VMNetworkAdapter -VMName "GitHubActions" -Name "CILegacy" `
-IsLegacy $true -SwitchName "CISwitch"
Start-VM -Name "GitHubActions"
$vm = Get-VM -Name "GitHubActions"
$vm | Format-List Name, State, Status, Generation, Uptime
if ($vm.State -ne "Running") {
throw "Expected Hyper-V VM to be running, got $($vm.State)"
}
- name: Download container prerequisites
id: download-container-prereqs
background: true
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$cacheDir = Join-Path $env:RUNNER_TEMP "container-prereqs"
New-Item -ItemType Directory -Force -Path $cacheDir | Out-Null
$downloads = @{
"Install-Containerd.ps1" = "https://raw.githubusercontent.com/kubernetes-sigs/sig-windows-tools/877cfb9ae91749ab9d73314337504ac962d6f90e/hostprocess/Install-Containerd.ps1"
"containerd.tar.gz" = "https://github.com/containerd/containerd/releases/download/v$($env:CONTAINERD_VERSION)/containerd-$($env:CONTAINERD_VERSION)-windows-amd64.tar.gz"
"crictl.tar.gz" = "https://github.com/kubernetes-sigs/cri-tools/releases/download/v$($env:CRITOOLS_VERSION)/crictl-v$($env:CRITOOLS_VERSION)-windows-amd64.tar.gz"
"cni-plugins.tgz" = "https://github.com/containernetworking/plugins/releases/download/v$($env:CNI_VERSION)/cni-plugins-windows-amd64-v$($env:CNI_VERSION).tgz"
"kubelet.exe" = "https://dl.k8s.io/release/$($env:KUBERNETES_VERSION)/bin/windows/amd64/kubelet.exe"
"kubelet.exe.sha256" = "https://dl.k8s.io/release/$($env:KUBERNETES_VERSION)/bin/windows/amd64/kubelet.exe.sha256"
"hns.psm1" = "https://raw.githubusercontent.com/microsoft/SDN/32df9732ef6dfab07dba205440e30bdfbd8e579a/Kubernetes/windows/hns.psm1"
}
foreach ($download in $downloads.GetEnumerator()) {
Invoke-WebRequest `
-Uri $download.Value `
-OutFile (Join-Path $cacheDir $download.Key) `
-MaximumRetryCount 5 `
-RetryIntervalSec 5
}
$installer = Join-Path $cacheDir "Install-Containerd.ps1"
$installerContent = Get-Content -Raw $installer
$installerContent = $installerContent.Replace(
' curl.exe --silent --fail -Lo $destination $source',
' if (Test-Path $destination) { Write-Host "Using pre-downloaded $destination"; return }' + "`r`n" + ' curl.exe --silent --fail -Lo $destination $source'
)
Set-Content -Path $installer -Value $installerContent -Encoding utf8
# Install every Windows feature in one foreground step. Steps that register
# services, such as SQL Server setup, New-Cluster, and containerd, start only
# afterwards: changing service configuration during the install aborts its
# registry transaction, and the rollback then requires a restart.
- name: Install Windows features
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$features = @(
"Containers"
"DHCP"
"DNS"
"Failover-Clustering"
"FS-FileServer"
"FS-Resource-Manager"
"MSMQ-Server"
"NPAS"
"Print-Server"
"RSAT-Clustering-PowerShell"
"RSAT-DHCP"
"RSAT-DNS-Server"
"RSAT-Print-Services"
"Web-Server"
)
# SMTP was removed from Windows Server 2025.
if ($env:SMTP_ENABLED -eq 'true') {
$features += "SMTP-Server"
}
$result = Install-WindowsFeature -Name $features
$result | Format-List Success, RestartNeeded, ExitCode, FeatureResult
if (-not $result.Success -or $result.RestartNeeded -ne "No") {
Get-WindowsPackage -Online | Where-Object PackageState -eq InstallPending |
Format-Table PackageName, PackageState
throw "Required Windows features could not be installed without a restart"
}
Get-WindowsFeature -Name $features |
Format-Table DisplayName, Name, InstallState
- name: Setup SQL Server Express
id: setup-mssql
background: true
shell: powershell
run: ./tools/setup-sql-server.ps1
- name: Setup MSCluster
id: setup-mscluster
# The Go test requires the cluster and virtual disk.
@@ -610,36 +617,8 @@ jobs:
Get-ClusterNode | Format-Table Name, State, NodeWeight, DynamicWeight
Get-ClusterNetwork | Format-Table Name, State, Role, Address
- name: Install Windows roles
id: install-windows-roles
background: true
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$features = @(
"DHCP"
"DNS"
"FS-FileServer"
"FS-Resource-Manager"
"MSMQ-Server"
"NPAS"
"Print-Server"
"RSAT-DHCP"
"RSAT-DNS-Server"
"RSAT-Print-Services"
"Web-Server"
)
$result = Install-WindowsFeature -Name $features
$result | Format-List Success, RestartNeeded, ExitCode, FeatureResult
if (-not $result.Success -or $result.RestartNeeded -ne "No") {
throw "Required Windows roles could not be installed without a restart"
}
Get-WindowsFeature -Name $features |
Format-Table DisplayName, Name, InstallState
- name: Wait for container prerequisites
wait: [download-container-prereqs]
- name: Prepare container runtime
shell: pwsh
@@ -763,13 +742,13 @@ jobs:
spec:
containers:
- name: nanoserver
image: mcr.microsoft.com/windows/nanoserver:ltsc2025
image: __NANOSERVER_IMAGE__
imagePullPolicy: IfNotPresent
command:
- ping.exe
- -t
- 127.0.0.1
'@ | Set-Content `
'@.Replace('__NANOSERVER_IMAGE__', $env:NANOSERVER_IMAGE) | Set-Content `
-Path (Join-Path $manifestDir "process-isolated.yaml") `
-Encoding ascii
@@ -807,10 +786,10 @@ jobs:
$crictl = Join-Path $env:ProgramFiles "containerd\crictl.exe"
& $crictl `
--runtime-endpoint $env:CRI_ENDPOINT `
pull mcr.microsoft.com/windows/nanoserver:ltsc2025
pull $env:NANOSERVER_IMAGE
if ($LASTEXITCODE -ne 0) {
throw "Failed to pull Windows Server 2025 Nano Server image"
throw "Failed to pull Nano Server image $env:NANOSERVER_IMAGE"
}
- name: Setup container network
@@ -858,7 +837,7 @@ jobs:
$imageNames = @($images.images.repoTags)
$hostProcessReady = $imageNames -contains "mcr.microsoft.com/oss/kubernetes/windows-host-process-containers-base-image:v1.0.0"
$nanoServerReady = $imageNames -contains "mcr.microsoft.com/windows/nanoserver:ltsc2025"
$nanoServerReady = $imageNames -contains $env:NANOSERVER_IMAGE
if ($networkReady -and $hostProcessReady -and $nanoServerReady) {
break
@@ -920,10 +899,135 @@ jobs:
throw "Static pods did not reach Running state"
}
- name: Wait for Windows roles
wait: [install-windows-roles]
- name: Wait for FreeRDP client
wait: [install-freerdp]
- parallel:
- name: Setup local RDP session
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$userName = "rdp-ci"
$password = "WindowsExporter-CI-2026!"
$securePassword = ConvertTo-SecureString $password -AsPlainText -Force
if ($null -eq (Get-LocalUser -Name $userName -ErrorAction SilentlyContinue)) {
New-LocalUser `
-Name $userName `
-Password $securePassword `
-PasswordNeverExpires `
-AccountNeverExpires | Out-Null
}
Add-LocalGroupMember `
-Group "Remote Desktop Users" `
-Member $userName `
-ErrorAction SilentlyContinue
Set-ItemProperty `
-Path "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server" `
-Name fDenyTSConnections `
-Value 0
Start-Service TermService
(Get-Service TermService).WaitForStatus("Running", [TimeSpan]::FromSeconds(30))
$wfreerdp = (Get-Command wfreerdp.exe -ErrorAction Stop).Source
$stdout = Join-Path $env:RUNNER_TEMP "rdp-client.stdout.log"
$stderr = Join-Path $env:RUNNER_TEMP "rdp-client.stderr.log"
# Ending a step can kill its process tree. Task Scheduler starts the
# client in the runner user's console session instead, so it keeps the
# RDP session active after this step. Runners are disposable, so
# nothing stops it.
$clientArguments = @(
"/v:127.0.0.1"
"/u:$env:COMPUTERNAME\$userName"
"/p:$password"
"/cert:ignore"
"/size:800x600"
"/log-level:INFO"
) -join " "
$action = New-ScheduledTaskAction -Execute "cmd.exe" `
-Argument "/c $wfreerdp $clientArguments > $stdout 2> $stderr"
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
-LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 2)
Register-ScheduledTask -TaskName "CIFreeRDP" -Action $action -Principal $principal `
-Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName "CIFreeRDP"
$deadline = (Get-Date).AddSeconds(15)
while ((Get-ScheduledTask -TaskName "CIFreeRDP").State -ne "Running") {
if ((Get-Date) -ge $deadline) { throw "Scheduled task CIFreeRDP did not start" }
Start-Sleep -Milliseconds 500
}
Write-Host "Started wfreerdp.exe through scheduled task CIFreeRDP"
$deadline = (Get-Date).AddSeconds(120)
$rdpSession = $null
do {
$sessions = qwinsta.exe
$sessions | ForEach-Object { Write-Host $_ }
$rdpSession = $sessions |
Where-Object { $_ -match "^\s*>?\s*rdp-tcp#\d+\s+$userName\s+\d+\s+Active\b" } |
Select-Object -First 1
if ($null -ne $rdpSession) {
break
}
if ((Get-ScheduledTask -TaskName "CIFreeRDP").State -ne "Running") {
Get-Content $stdout,$stderr -ErrorAction SilentlyContinue
throw "wfreerdp.exe exited before the RDP user became active"
}
Start-Sleep -Seconds 2
} while ((Get-Date) -lt $deadline)
if ($null -eq $rdpSession) {
Get-Content $stdout,$stderr -ErrorAction SilentlyContinue
throw "No authenticated active RDP session was created by FreeRDP"
}
Write-Host "Active RDP session: $rdpSession"
$sessionName = [regex]::Match($rdpSession, 'rdp-tcp#\d+', 'IgnoreCase').Value.Replace('#', ' ')
$remoteFxNetwork = Get-Counter -ListSet "RemoteFX Network" -ErrorAction Stop
$remoteFxGraphics = Get-Counter -ListSet "RemoteFX Graphics" -ErrorAction Stop
$networkInstances = @($remoteFxNetwork.PathsWithInstances |
Where-Object { $_ -match "RDP-Tcp[# ]\d+" })
$graphicsInstances = @($remoteFxGraphics.PathsWithInstances |
Where-Object { $_ -match "RDP-Tcp[# ]\d+" })
Write-Host "RemoteFX Network instances:"
$networkInstances | ForEach-Object { Write-Host $_ }
Write-Host "RemoteFX Graphics instances:"
$graphicsInstances | ForEach-Object { Write-Host $_ }
if ($networkInstances.Count -eq 0) {
Write-Warning "No RemoteFX Network RDP-Tcp instance was created"
}
if ($graphicsInstances.Count -eq 0) {
Write-Warning "No RemoteFX Graphics RDP-Tcp instance was created"
}
@{
SessionName = $sessionName
RemoteFX = ($networkInstances.Count -gt 0 -and $graphicsInstances.Count -gt 0)
} | ConvertTo-Json | Set-Content (Join-Path $env:RUNNER_TEMP "rdp-ready.json")
- name: Start SMTP server
if: ${{ matrix.smtp }}
shell: powershell
run: |
$ErrorActionPreference = "Stop"
Start-Service SMTPSVC
(Get-Service SMTPSVC).WaitForStatus("Running", [TimeSpan]::FromSeconds(30))
- name: Setup IIS
shell: pwsh
run: |
@@ -977,6 +1081,7 @@ jobs:
Select-Object -ExpandProperty Paths
- name: Enable Windows Update
timeout-minutes: 5
shell: powershell
run: |
$ErrorActionPreference = "Stop"
@@ -993,6 +1098,19 @@ jobs:
$historyCount = $searcher.GetTotalHistoryCount()
Write-Host "Windows Update history count: $historyCount"
# Some runner images have no usable cached catalog. Search online
# once to populate it; the Go collector still searches offline.
# Searching only retrieves metadata, without installing updates.
try {
$result = $searcher.Search("IsInstalled=0 and IsHidden=0")
}
catch {
Write-Warning "Offline catalog search failed: $($_.Exception.ToString())"
$searcher.Online = $true
$result = $searcher.Search("IsInstalled=0 and IsHidden=0")
}
Write-Host "Windows Update catalog contains $($result.Updates.Count) pending updates"
Get-Service wuauserv |
Format-Table Name, Status, StartType
@@ -1141,6 +1259,33 @@ jobs:
Get-SmbShare -Name "CIShare"
Get-SmbConnection | Format-Table ServerName, ShareName, Dialect, NumOpens
# Keep an SMB file open and the .NET runtime loaded during the Go tests.
# Task Scheduler keeps the workload alive after this step.
$workload = Join-Path $env:RUNNER_TEMP "netframework-workload.ps1"
@'
$data = New-Object byte[] 1MB
$path = "\\$env:COMPUTERNAME\CIShare\test.txt"
$stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::ReadWrite)
try {
while ($true) {
$stream.Position = 0
$stream.WriteByte(65)
$stream.Flush()
[System.GC]::KeepAlive($data)
Start-Sleep -Seconds 1
}
} finally {
$stream.Dispose()
}
'@ | Set-Content $workload
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -File $workload"
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" `
-LogonType Interactive -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 2)
Register-ScheduledTask -TaskName "CIWorkload" -Action $action -Principal $principal `
-Settings $settings -Force | Out-Null
Start-ScheduledTask -TaskName "CIWorkload"
- name: Setup DNS and DHCP
shell: pwsh
run: |
@@ -1194,45 +1339,77 @@ jobs:
}
- name: Wait for background setup
wait: [pull-hostprocess, pull-nanoserver, setup-mscluster, setup-rdp]
# The foreground waits for SQL Server here anyway, so Go setup costs nothing.
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
# renovate: golang=go
go-version: '1.27.1'
- name: Start .NET Framework and SMB workloads
- name: Wait for background setup
wait:
- pull-hostprocess
- pull-nanoserver
- setup-bitlocker
- setup-mscluster
- setup-mssql
- setup-storage-pool
- start-hyperv-vm
- name: Export background fixtures
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$workload = Join-Path $env:RUNNER_TEMP "netframework-workload.ps1"
@'
$data = New-Object byte[] 1MB
$path = "\\$env:COMPUTERNAME\CIShare\test.txt"
$stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::ReadWrite)
try {
while ($true) {
$stream.Position = 0
$stream.WriteByte(65)
$stream.Flush()
[System.GC]::KeepAlive($data)
Start-Sleep -Seconds 1
}
} finally {
$stream.Dispose()
$readyFile = Join-Path $env:RUNNER_TEMP "rdp-ready.json"
if (-not (Test-Path $readyFile)) {
Get-Content (Join-Path $env:RUNNER_TEMP "rdp-client.*.log") -ErrorAction SilentlyContinue
throw "RDP fixture did not become ready"
}
'@ | Set-Content $workload
$process = Start-Process powershell.exe -PassThru `
-ArgumentList "-NoProfile", "-File", $workload
$process.Id | Set-Content (Join-Path $env:RUNNER_TEMP "netframework.pid")
$fixture = Get-Content $readyFile -Raw | ConvertFrom-Json
"WINDOWS_EXPORTER_TEST_RDP_SESSION=$($fixture.SessionName)" | Add-Content $env:GITHUB_ENV
if ($fixture.RemoteFX) {
"WINDOWS_EXPORTER_TEST_COLLECTORS=$env:WINDOWS_EXPORTER_TEST_COLLECTORS,remote_fx" |
Add-Content $env:GITHUB_ENV
}
$bitlocker = Get-Content (Join-Path $env:RUNNER_TEMP "bitlocker-fixture.json") -Raw | ConvertFrom-Json
"WINDOWS_EXPORTER_TEST_BITLOCKER_VOLUME=$($bitlocker.Unlocked)" | Add-Content $env:GITHUB_ENV
"WINDOWS_EXPORTER_TEST_BITLOCKER_LOCKED_VOLUME=$($bitlocker.Locked)" | Add-Content $env:GITHUB_ENV
- name: Resync WMI performance counters
shell: pwsh
run: |
winmgmt.exe /resyncperf
if ($LASTEXITCODE -ne 0) { throw "Failed to synchronize WMI performance counters" }
- name: Go tests
id: go-tests
shell: pwsh
run: |
go test -v -count=1 -race -timeout=10m ./... 2>&1 | Tee-Object test-output.txt
go test -json -count=1 -race -timeout=10m ./... 2>&1 | Tee-Object test-results.jsonl |
ForEach-Object {
$line = $_.ToString()
try {
$event = $line | ConvertFrom-Json -ErrorAction Stop
if ($event.Output) { [Console]::Write($event.Output) }
} catch {
Write-Host $line
}
}
$testExitCode = $LASTEXITCODE
Select-String -Path test-output.txt -Pattern '^--- SKIP:' |
ForEach-Object Line | Add-Content $env:GITHUB_STEP_SUMMARY
"exit_code=$testExitCode" | Add-Content $env:GITHUB_OUTPUT
exit $testExitCode
- name: Summarize Windows feature tests
if: ${{ always() }}
shell: pwsh
env:
TEST_EXIT_CODE: ${{ steps.go-tests.outputs.exit_code || '1' }}
run: |
Copy-Item "$env:windir/Logs/DISM/dism.log" (Join-Path $env:RUNNER_TEMP "dism.log") -ErrorAction SilentlyContinue
# CBS.log names the files that make role installation request a restart.
Copy-Item "$env:windir/Logs/CBS/CBS.log" (Join-Path $env:RUNNER_TEMP "cbs.log") -ErrorAction SilentlyContinue
./tools/test-summary.ps1 -TestExitCode ([int]$env:TEST_EXIT_CODE)
- name: Build and smoke test exporter
shell: pwsh
run: |
@@ -1244,24 +1421,19 @@ jobs:
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: windows-test-results
name: windows-test-results-${{ matrix.os }}
path: |
test-output.txt
test-results.jsonl
feature-summary.md
${{ runner.temp }}/windows_exporter.*.log
${{ runner.temp }}/kubelet.*.log
${{ runner.temp }}/sql-server-summary.txt
${{ runner.temp }}/rdp-client.*.log
${{ runner.temp }}/dism.log
${{ runner.temp }}/cbs.log
if-no-files-found: ignore
- name: Stop test workloads
if: ${{ always() }}
shell: pwsh
run: |
$pidFile = Join-Path $env:RUNNER_TEMP "netframework.pid"
if (Test-Path $pidFile) {
Stop-Process -Id (Get-Content $pidFile) -ErrorAction SilentlyContinue
}
Get-Process kubelet -ErrorAction SilentlyContinue | Stop-Process -ErrorAction SilentlyContinue
Get-VM -Name GitHubActions -ErrorAction SilentlyContinue | Stop-VM -TurnOff -Force -ErrorAction SilentlyContinue
lint:
runs-on: windows-2025
permissions:
@@ -1332,3 +1504,20 @@ jobs:
# VALIDATE_XML: true
# VALIDATE_YAML: true
# Require this job in the merge rules. It is skipped, which counts as passing,
# unless a needed job failed or was cancelled.
fail:
if: ${{ cancelled() || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'failure') }}
needs:
- build
- package
- docker
- windows-test
- lint
- super-linter
runs-on: ubuntu-24.04
steps:
- name: Verify checks passed
run: |
echo "Some checks have failed!"
exit 1
+5 -5
View File
@@ -152,6 +152,8 @@ func waitUntilListening(tb testing.TB, network, address string) error {
deadline := time.Now().Add(30 * time.Second)
// Before the listener starts, dials fail with connection refused or, on a
// busy Windows host, with a dial timeout. Retry both until the deadline.
for time.Now().Before(deadline) {
conn, err = dialer.DialContext(tb.Context(), network, address)
if err == nil {
@@ -160,13 +162,11 @@ func waitUntilListening(tb testing.TB, network, address string) error {
return nil
}
if errors.Is(err, windows.Errno(10061)) {
time.Sleep(50 * time.Millisecond)
continue
if tb.Context().Err() != nil {
break
}
break
time.Sleep(50 * time.Millisecond)
}
if winErr, ok := errors.AsType[windows.Errno](err); ok {
+23
View File
@@ -0,0 +1,23 @@
# Windows feature tests
The CI Windows job prepares real Windows roles and workloads before running
`go test -json -count=1 -race -timeout=10m ./...`. Collector tests validate the
metrics from those workloads. A final exporter smoke check builds and starts the
binary, requests `/metrics` once, and checks for a nonempty HTTP 200 response.
The job runs on Windows Server 2022; Server 2025 runners take much longer to
provision the same fixtures. It provisions Containers, Hyper-V, SQL Server
Express, IIS, SMTP, MSMQ, NPS, FSRM, SMB, DNS, DHCP, a workgroup failover
cluster, printer and storage fixtures, unlocked and locked BitLocker volumes,
and an authenticated RDP session.
`WINDOWS_EXPORTER_TEST_COLLECTORS` lists required collectors. Their tests fail
when setup is missing or collection fails instead of skipping. Fixture assertions
check known instances, including the active RDP user's session and SQL database.
RemoteFX is required when both network and graphics instances are available.
Each Windows job publishes a feature table in its Actions summary and a
`windows-test-results-<OS>` artifact containing `feature-summary.md`, structured
Go test events, readable test output, and setup diagnostics. The table distinguishes
required fixtures, unavailable collectors, and empty optional instance groups.
This records Windows feature availability rather than Go statement coverage.
+1
View File
@@ -32,4 +32,5 @@ func BenchmarkCollector(b *testing.B) {
func TestCollector(t *testing.T) {
metrics := testutils.TestCollector(t, hyperv.New, nil)
testutils.RequireFixtureMetric(t, metrics, hyperv.Name, "windows_hyperv_dynamic_memory_vm_physical_bytes", prometheus.Labels{"vm": "GitHubActions"})
testutils.RequireFixtureMetric(t, metrics, hyperv.Name, "windows_hyperv_vm_processor_count", prometheus.Labels{"vm": "GitHubActions"})
}
+67 -25
View File
@@ -46,6 +46,8 @@ const (
Name = "logical_disk"
subCollectorMetrics = "metrics"
subCollectorBitlocker = "bitlocker_status"
bitlockerStatusLocked = 6
)
type Config struct {
@@ -514,46 +516,86 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error
}
if slices.Contains(c.config.CollectorsEnabled, subCollectorBitlocker) {
c.bitlockerReqCh <- data.Name
c.collectBitlocker(ch, data.Name, false)
}
}
bitlockerStatus := <-c.bitlockerResCh
if bitlockerStatus.err != nil {
c.logger.Warn("failed to get BitLocker status for "+data.Name,
slog.Any("err", bitlockerStatus.err),
)
if slices.Contains(c.config.CollectorsEnabled, subCollectorBitlocker) {
// Locked BitLocker volumes have no LogicalDisk performance instance.
seen := make(map[string]struct{}, len(c.perfDataObject))
for _, data := range c.perfDataObject {
seen[strings.ToUpper(data.Name)] = struct{}{}
}
for mountPoint := range volumes {
if len(mountPoint) != 2 || mountPoint[1] != ':' {
continue
}
if bitlockerStatus.status == -1 {
c.logger.Debug("BitLocker status for "+data.Name+" is unknown",
slog.Int("status", bitlockerStatus.status),
)
if _, ok := seen[strings.ToUpper(mountPoint)]; ok {
continue
}
for i, status := range []string{"disabled", "on", "off", "encrypting", "decrypting", "suspended", "locked", "unknown", "waiting_for_activation"} {
val := 0.0
if bitlockerStatus.status == i {
val = 1.0
}
ch <- prometheus.MustNewConstMetric(
c.bitlockerStatus,
prometheus.GaugeValue,
val,
data.Name,
status,
)
if c.config.VolumeExclude.MatchString(mountPoint) || !c.config.VolumeInclude.MatchString(mountPoint) {
continue
}
c.collectBitlocker(ch, mountPoint, true)
}
}
return nil
}
// collectBitlocker emits the BitLocker status of a volume. With lockedOnly,
// volumes in any other state are skipped.
func (c *Collector) collectBitlocker(ch chan<- prometheus.Metric, volume string, lockedOnly bool) {
c.bitlockerReqCh <- volume
bitlockerStatus := <-c.bitlockerResCh
if bitlockerStatus.err != nil {
level := slog.LevelWarn
if lockedOnly {
// Drive letters without performance data include empty media drives.
level = slog.LevelDebug
}
c.logger.Log(context.Background(), level, "failed to get BitLocker status for "+volume,
slog.Any("err", bitlockerStatus.err),
)
return
}
if bitlockerStatus.status == -1 {
c.logger.Debug("BitLocker status for "+volume+" is unknown",
slog.Int("status", bitlockerStatus.status),
)
return
}
if lockedOnly && bitlockerStatus.status != bitlockerStatusLocked {
return
}
for i, status := range []string{"disabled", "on", "off", "encrypting", "decrypting", "suspended", "locked", "unknown", "waiting_for_activation"} {
val := 0.0
if bitlockerStatus.status == i {
val = 1.0
}
ch <- prometheus.MustNewConstMetric(
c.bitlockerStatus,
prometheus.GaugeValue,
val,
volume,
status,
)
}
}
func getDriveType(driveType uint32) string {
switch driveType {
case windows.DRIVE_UNKNOWN:
@@ -26,6 +26,7 @@ import (
"github.com/prometheus-community/windows_exporter/internal/collector/logical_disk"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
@@ -45,6 +46,31 @@ func TestCollector(t *testing.T) {
})
}
func TestCollectorBitlocker(t *testing.T) {
metrics := testutils.TestCollector(t, logical_disk.New, &logical_disk.Config{
CollectorsEnabled: []string{"metrics", "bitlocker_status"},
VolumeInclude: types.RegExpAny,
VolumeExclude: types.RegExpEmpty,
})
for env, want := range map[string]string{
"WINDOWS_EXPORTER_TEST_BITLOCKER_VOLUME": "on",
"WINDOWS_EXPORTER_TEST_BITLOCKER_LOCKED_VOLUME": "locked",
} {
volume := os.Getenv(env)
if volume == "" {
continue
}
status := testutils.RequireFixtureMetric(t, metrics, logical_disk.Name, "windows_logical_disk_bitlocker_status", prometheus.Labels{
"volume": volume,
"status": want,
})
require.NotNil(t, status)
require.InDelta(t, 1, status.GetGauge().GetValue(), 0, "BitLocker fixture volume %s is not reported as %s", volume, want)
}
}
func TestCollectorVolumeFilters(t *testing.T) {
t.Parallel()
@@ -32,4 +32,5 @@ func BenchmarkCollector(b *testing.B) {
func TestCollector(t *testing.T) {
metrics := testutils.TestCollector(t, mscluster.New, nil)
testutils.RequireFixtureMetric(t, metrics, mscluster.Name, "windows_mscluster_virtualdisk_size_bytes", prometheus.Labels{"name": "CIVirtualDisk"})
testutils.RequireFixtureMetric(t, metrics, mscluster.Name, "windows_mscluster_storagepool_size_bytes", prometheus.Labels{"name": "GitHubActions"})
}
+4 -1
View File
@@ -22,6 +22,7 @@ import (
"github.com/prometheus-community/windows_exporter/internal/collector/mssql"
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
"github.com/prometheus/client_golang/prometheus"
)
func BenchmarkCollector(b *testing.B) {
@@ -29,5 +30,7 @@ func BenchmarkCollector(b *testing.B) {
}
func TestCollector(t *testing.T) {
testutils.TestCollector(t, mssql.New, nil)
metrics := testutils.TestCollector(t, mssql.New, nil)
testutils.RequireFixtureMetric(t, metrics, mssql.Name, "windows_mssql_instance_info", prometheus.Labels{"mssql_instance": "CISQL"})
testutils.RequireFixtureMetric(t, metrics, mssql.Name, "windows_mssql_databases_data_files_size_bytes", prometheus.Labels{"mssql_instance": "CISQL", "database": "CIWindowsExporter"})
}
@@ -18,10 +18,12 @@
package remote_fx_test
import (
"os"
"testing"
"github.com/prometheus-community/windows_exporter/internal/collector/remote_fx"
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
"github.com/prometheus/client_golang/prometheus"
)
func BenchmarkCollector(b *testing.B) {
@@ -29,5 +31,8 @@ func BenchmarkCollector(b *testing.B) {
}
func TestCollector(t *testing.T) {
testutils.TestCollector(t, remote_fx.New, nil)
metrics := testutils.TestCollector(t, remote_fx.New, nil)
labels := prometheus.Labels{"session_name": os.Getenv("WINDOWS_EXPORTER_TEST_RDP_SESSION")}
testutils.RequireFixtureMetric(t, metrics, remote_fx.Name, "windows_remote_fx_net_sent_bytes_total", labels)
testutils.RequireFixtureMetric(t, metrics, remote_fx.Name, "windows_remote_fx_gfx_output_frames_total", labels)
}
+16 -1
View File
@@ -18,10 +18,14 @@
package smtp_test
import (
"net"
netsmtp "net/smtp"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/collector/smtp"
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
"github.com/stretchr/testify/require"
)
func BenchmarkCollector(b *testing.B) {
@@ -29,5 +33,16 @@ func BenchmarkCollector(b *testing.B) {
}
func TestCollector(t *testing.T) {
testutils.TestCollector(t, smtp.New, nil)
metrics := testutils.TestCollector(t, smtp.New, nil)
testutils.RequireFixtureMetric(t, metrics, smtp.Name, "windows_smtp_inbound_connections_total", nil)
// Exercise the configured virtual server, including its SMTP greeting.
dialer := net.Dialer{Timeout: 5 * time.Second}
conn, err := dialer.DialContext(t.Context(), "tcp", "127.0.0.1:25")
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
require.NoError(t, conn.SetDeadline(time.Now().Add(5*time.Second)))
client, err := netsmtp.NewClient(conn, "localhost")
require.NoError(t, err)
require.NoError(t, client.Quit())
}
@@ -18,10 +18,13 @@
package terminal_services_test
import (
"os"
"testing"
"github.com/prometheus-community/windows_exporter/internal/collector/terminal_services"
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
func BenchmarkCollector(b *testing.B) {
@@ -29,5 +32,23 @@ func BenchmarkCollector(b *testing.B) {
}
func TestCollector(t *testing.T) {
testutils.TestCollector(t, terminal_services.New, nil)
metrics := testutils.TestCollector(t, terminal_services.New, nil)
userName := os.Getenv("WINDOWS_EXPORTER_TEST_RDP_USER")
if userName == "" {
return
}
sessionName := os.Getenv("WINDOWS_EXPORTER_TEST_RDP_SESSION")
require.NotEmpty(t, sessionName, "RDP fixture did not reach an authenticated active session")
info := testutils.RequireFixtureMetric(t, metrics, terminal_services.Name, "windows_terminal_services_session_info", prometheus.Labels{
"user": os.Getenv("COMPUTERNAME") + `\` + userName,
"session_name": sessionName,
"state": "active",
})
require.NotNil(t, info)
require.InDelta(t, 1, info.GetGauge().GetValue(), 0, "RDP fixture is no longer active")
handles := testutils.RequireFixtureMetric(t, metrics, terminal_services.Name, "windows_terminal_services_handles", prometheus.Labels{"session_name": sessionName})
require.NotNil(t, handles)
require.Positive(t, handles.GetGauge().GetValue(), "RDP fixture has no session processes")
}
+27 -29
View File
@@ -250,6 +250,14 @@ func Test_MI_Query_Unmarshal(t *testing.T) {
}
func Test_MI_FD_Leak(t *testing.T) {
// A leak of one handle per query grows far beyond this; MI and the Go
// runtime may still open a few handles for worker threads.
const (
warmupQueries = 20
queries = 300
maxHandleIncrease = 50
)
application, err := mi.ApplicationInitialize()
require.NoError(t, err)
require.NotEmpty(t, application)
@@ -258,46 +266,34 @@ func Test_MI_FD_Leak(t *testing.T) {
require.NoError(t, err)
require.NotEmpty(t, session)
currentFileHandle, err := testutils.GetProcessHandleCount(windows.CurrentProcess())
require.NoError(t, err)
t.Log("Current File Handle Count: ", currentFileHandle)
queryPrinter, err := mi.NewQuery("SELECT Name FROM Win32_Process")
require.NoError(t, err)
for range 300 {
query := func() {
var processes []win32Process
err := session.Query(&processes, mi.NamespaceRootCIMv2, queryPrinter, -1)
require.NoError(t, err)
currentFileHandle, err = testutils.GetProcessHandleCount(windows.CurrentProcess())
require.NoError(t, err)
t.Log("Current File Handle Count: ", currentFileHandle)
require.NoError(t, session.Query(&processes, mi.NamespaceRootCIMv2, queryPrinter, -1))
}
currentFileHandle, err = testutils.GetProcessHandleCount(windows.CurrentProcess())
// The first queries initialize MI caches and thread pools.
for range warmupQueries {
query()
}
baseline, err := testutils.GetProcessHandleCount(windows.CurrentProcess())
require.NoError(t, err)
t.Log("Current File Handle Count: ", currentFileHandle)
for range queries {
query()
}
err = session.Close()
current, err := testutils.GetProcessHandleCount(windows.CurrentProcess())
require.NoError(t, err)
require.LessOrEqual(t, int64(current), int64(baseline)+maxHandleIncrease,
"handle count grew from %d to %d after %d queries", baseline, current, queries)
currentFileHandle, err = testutils.GetProcessHandleCount(windows.CurrentProcess())
require.NoError(t, err)
t.Log("Current File Handle Count: ", currentFileHandle)
err = application.Close()
require.NoError(t, err)
currentFileHandle, err = testutils.GetProcessHandleCount(windows.CurrentProcess())
require.NoError(t, err)
t.Log("Current File Handle Count: ", currentFileHandle)
require.NoError(t, session.Close())
require.NoError(t, application.Close())
}
func Test_MI_QueryTimeout(t *testing.T) {
@@ -326,7 +322,9 @@ func Test_MI_QueryTimeout(t *testing.T) {
err = operationOptions.SetTimeout(1 * time.Millisecond)
require.NoError(t, err)
operation, err := session.QueryInstances(mi.OperationFlagsStandardRTTI, operationOptions, mi.NamespaceRootCIMv2, mi.QueryDialectWQL, "select Name from win32_process where handle = 0")
// A query for a single process can finish within the timeout; reading every
// property of every process cannot.
operation, err := session.QueryInstances(mi.OperationFlagsStandardRTTI, operationOptions, mi.NamespaceRootCIMv2, mi.QueryDialectWQL, "select * from win32_process")
require.NoError(t, err)
require.NotEmpty(t, operation)
+6 -4
View File
@@ -78,7 +78,7 @@ func FuncBenchmarkCollector[C collector.Collector](b *testing.B, name string, co
func TestCollector[C collector.Collector, V any](t *testing.T, fn func(*V) C, conf *V) map[string]*dto.MetricFamily {
t.Helper()
logger := slog.New(slog.DiscardHandler)
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
c := fn(conf)
required := slices.Contains(strings.Split(os.Getenv("WINDOWS_EXPORTER_TEST_COLLECTORS"), ","), c.GetName())
@@ -209,11 +209,11 @@ func (m collectedMetrics) Collect(ch chan<- prometheus.Metric) {
// RequireFixtureMetric checks a known CI fixture without requiring it on a
// developer's machine. Labels match exactly, ignoring case for Windows names.
func RequireFixtureMetric(t *testing.T, families map[string]*dto.MetricFamily, collectorName, metricName string, labels prometheus.Labels) {
func RequireFixtureMetric(t *testing.T, families map[string]*dto.MetricFamily, collectorName, metricName string, labels prometheus.Labels) *dto.Metric {
t.Helper()
if !slices.Contains(strings.Split(os.Getenv("WINDOWS_EXPORTER_TEST_COLLECTORS"), ","), collectorName) {
return
return nil
}
require.Contains(t, families, metricName)
@@ -236,11 +236,13 @@ func RequireFixtureMetric(t *testing.T, families map[string]*dto.MetricFamily, c
}
if matched {
return
return metric
}
}
t.Fatalf("metric %s with fixture labels %v was not emitted; got %s", metricName, labels, families[metricName])
return nil
}
func noDataOnly(err error) bool {
+96
View File
@@ -0,0 +1,96 @@
# Install a real Database Engine instance; LocalDB does not expose server counters.
$ErrorActionPreference = "Stop"
$mediaDir = Join-Path $env:RUNNER_TEMP "sql-server"
New-Item -ItemType Directory -Force -Path $mediaDir | Out-Null
$setupDir = Join-Path $mediaDir "setup"
# CI caches the extracted setup files; download and extract only on a cache miss.
if (-not (Test-Path (Join-Path $setupDir "setup.exe"))) {
$installer = Join-Path $mediaDir "SQL2025-SSEI-Expr.exe"
# Microsoft download endpoints occasionally reset hosted runner connections.
curl.exe --fail --location --retry 5 --retry-all-errors --silent --show-error --output $installer `
"https://download.microsoft.com/download/ffd82b4c-9955-47c0-8efe-6290f7795cf6/SQL2025-SSEI-Expr.exe"
if ($LASTEXITCODE -ne 0) { throw "SQL Server bootstrapper download failed" }
$expectedHash = "fa7e1fabc9a2e9c9cdab0d1512bcb30d2949133147db057ac530f510e5270680"
if ((Get-FileHash $installer -Algorithm SHA256).Hash -ne $expectedHash) {
throw "SQL Server bootstrapper SHA256 mismatch"
}
$media = Join-Path $mediaDir "SQLEXPR_x64_ENU.exe"
foreach ($attempt in 1..3) {
$download = Start-Process $installer -Wait -PassThru `
-ArgumentList "/ACTION=Download", "/MEDIATYPE=Core", "/QUIET", "/MEDIAPATH=$mediaDir"
if ($download.ExitCode -eq 0 -and (Test-Path $media)) { break }
if ($attempt -eq 3) { throw "SQL Server media download failed: $($download.ExitCode)" }
Write-Warning "SQL Server media download attempt $attempt failed: $($download.ExitCode)"
Start-Sleep -Seconds 10
}
$extract = Start-Process $media -Wait -PassThru `
-ArgumentList "/Q", "/X:$setupDir"
if ($extract.ExitCode -ne 0) {
throw "SQL Server media extraction failed: $($extract.ExitCode)"
}
}
# Azure runners can expose sectors larger than SQL Server's supported 4 KB.
# Keep every instance file on a disk with a known, compatible sector size.
Import-Module Hyper-V
$diskPath = Join-Path $env:RUNNER_TEMP "sql-server.vhdx"
New-VHD -Path $diskPath -Dynamic -SizeBytes 8GB `
-LogicalSectorSizeBytes 512 -PhysicalSectorSizeBytes 4096 | Out-Null
. (Join-Path $PSScriptRoot "vhd-lock.ps1")
$volume = Invoke-WithVhdLock {
$disk = Mount-VHD -Path $diskPath -PassThru | Get-Disk
if ($disk.PhysicalSectorSize -gt 4096) {
throw "SQL Server fixture disk exposes unsupported physical sectors"
}
$disk | Initialize-Disk -PartitionStyle GPT -PassThru |
New-Partition -UseMaximumSize -AssignDriveLetter |
Format-Volume -FileSystem NTFS -NewFileSystemLabel CISQL -Confirm:$false
}
$instanceDir = "$($volume.DriveLetter):\SQLServer"
fsutil.exe fsinfo sectorinfo "$($volume.DriveLetter):"
$setup = Start-Process (Join-Path $setupDir "setup.exe") -Wait -PassThru -ArgumentList @(
"/Q"
"/IACCEPTSQLSERVERLICENSETERMS"
"/ACTION=Install"
"/FEATURES=SQLEngine"
"/INSTANCENAME=CISQL"
"/INSTANCEDIR=$instanceDir"
"/INSTALLSQLDATADIR=$instanceDir"
'/SQLSYSADMINACCOUNTS="BUILTIN\Administrators"'
"/UPDATEENABLED=False"
"/TCPENABLED=0"
"/NPENABLED=0"
)
$summaryLog = Join-Path $env:RUNNER_TEMP "sql-server-summary.txt"
Copy-Item "C:\Program Files\Microsoft SQL Server\170\Setup Bootstrap\Log\Summary.txt" `
$summaryLog -ErrorAction SilentlyContinue
if ($setup.ExitCode -ne 0) {
Get-Content $summaryLog -ErrorAction SilentlyContinue
throw "SQL Server setup failed or requires a restart: $($setup.ExitCode)"
}
$connection = New-Object System.Data.SqlClient.SqlConnection
$connection.ConnectionString = "Data Source=lpc:.\CISQL;Integrated Security=True;Connect Timeout=30"
try {
$connection.Open()
$command = $connection.CreateCommand()
$command.CommandText = "CREATE DATABASE CIWindowsExporter"
$command.ExecuteNonQuery() | Out-Null
# Express can close idle databases and remove their performance counter instances.
$command.CommandText = "ALTER DATABASE CIWindowsExporter SET AUTO_CLOSE OFF"
$command.ExecuteNonQuery() | Out-Null
$connection.ChangeDatabase("CIWindowsExporter")
$command.CommandText = @'
CREATE TABLE dbo.Fixture (ID int NOT NULL, Data char(8000) NOT NULL);
INSERT INTO dbo.Fixture SELECT TOP (1000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)), 'fixture' FROM sys.all_objects;
CHECKPOINT;
'@
$command.ExecuteNonQuery() | Out-Null
} finally {
$connection.Dispose()
}
Get-Service 'MSSQL$CISQL' | Format-Table Name, Status
+64
View File
@@ -0,0 +1,64 @@
param([int]$TestExitCode)
$ErrorActionPreference = "Stop"
$prefix = "github.com/prometheus-community/windows_exporter/internal/collector/"
$required = @($env:WINDOWS_EXPORTER_TEST_COLLECTORS.Split(','))
$results = @{}
$text = [System.Text.StringBuilder]::new()
$lines = if (Test-Path test-results.jsonl) { Get-Content test-results.jsonl } else { @() }
foreach ($line in $lines) {
try {
$event = $line | ConvertFrom-Json -ErrorAction Stop
} catch {
[void]$text.AppendLine($line)
continue
}
if ($event.Output) { [void]$text.Append($event.Output) }
if (-not $event.Package -or -not $event.Package.StartsWith($prefix) -or $event.Test -ne "TestCollector") {
continue
}
$name = $event.Package.Substring($prefix.Length)
if (-not $results.ContainsKey($name)) {
$results[$name] = @{ State = "not completed"; Notes = @() }
}
if ($event.Action -in @("pass", "skip", "fail")) {
$results[$name].State = $event.Action
}
if ($event.Output -match 'collector \S+ (?:is not supported|has empty optional instance groups): (.+)') {
$results[$name].Notes += $Matches[1].Trim()
}
}
$text.ToString() | Set-Content test-output.txt -NoNewline -Encoding utf8
# Aborted tests must leave required fixtures visible as not run.
foreach ($name in $required) {
if (-not $results.ContainsKey($name)) {
$results[$name] = @{ State = "not run"; Notes = @() }
}
}
$testState = if (-not $lines) { "not run" } elseif ($TestExitCode -eq 0) { "passed" } else { "failed" }
$summary = @(
"### Windows feature tests: $env:WINDOWS_TEST_OS"
""
"Go suite: **$testState**. Required fixtures cannot skip. Empty optional instance groups remain listed below."
""
"| Collector | Required | Live test | Unavailable features / notes |"
"| --- | --- | --- | --- |"
)
foreach ($name in $results.Keys | Sort-Object) {
$fixture = if ($name -in $required) { "yes" } else { "no" }
$state = switch ($results[$name].State) {
"pass" { "passed" }
"skip" { "unavailable" }
"fail" { "failed" }
default { $results[$name].State }
}
$notes = ($results[$name].Notes | Sort-Object -Unique) -join '; '
$notes = $notes.Replace('|', '\|')
$summary += "| $name | $fixture | $state | $notes |"
}
$summary | Set-Content feature-summary.md -Encoding utf8
if ($env:GITHUB_STEP_SUMMARY) {
$summary | Add-Content $env:GITHUB_STEP_SUMMARY
}
+20
View File
@@ -0,0 +1,20 @@
# Background CI steps attach virtual disks at the same time, which makes
# Mount-VHD fail with 0x800703E3. Serialize attaching and formatting them.
function Invoke-WithVhdLock([scriptblock]$ScriptBlock) {
$mutex = [System.Threading.Mutex]::new($false, "Global\windows-exporter-ci-vhd")
try {
try {
[void]$mutex.WaitOne()
} catch [System.Threading.AbandonedMutexException] {
# The previous owner exited without releasing; the lock is ours now.
}
try {
& $ScriptBlock
} finally {
$mutex.ReleaseMutex()
}
} finally {
$mutex.Dispose()
}
}