From 56137ffdc1888a3877afb0ad1590b04d20a9adce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Wed, 7 Oct 2026 09:15:58 +0200 Subject: [PATCH] ci: expand Windows feature fixtures and test Server 2022 (#2503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 Signed-off-by: Jan-Otto Kröpke --- .github/workflows/ci.yml | 767 +++++++++++------- cmd/windows_exporter/main_test.go | 10 +- docs/ci-testing.md | 23 + internal/collector/hyperv/hyperv_test.go | 1 + .../collector/logical_disk/logical_disk.go | 92 ++- .../logical_disk/logical_disk_test.go | 26 + .../collector/mscluster/mscluster_test.go | 1 + internal/collector/mssql/mssql_test.go | 5 +- .../collector/remote_fx/remote_fx_test.go | 7 +- internal/collector/smtp/smtp_test.go | 17 +- .../terminal_services_test.go | 23 +- internal/mi/mi_test.go | 56 +- internal/utils/testutils/testutils.go | 10 +- tools/setup-sql-server.ps1 | 96 +++ tools/test-summary.ps1 | 64 ++ tools/vhd-lock.ps1 | 20 + 16 files changed, 862 insertions(+), 356 deletions(-) create mode 100644 docs/ci-testing.md create mode 100644 tools/setup-sql-server.ps1 create mode 100644 tools/test-summary.ps1 create mode 100644 tools/vhd-lock.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ce8b430..b4fe4f06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/cmd/windows_exporter/main_test.go b/cmd/windows_exporter/main_test.go index 76d63e1c..ff9049e7 100644 --- a/cmd/windows_exporter/main_test.go +++ b/cmd/windows_exporter/main_test.go @@ -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 { diff --git a/docs/ci-testing.md b/docs/ci-testing.md new file mode 100644 index 00000000..fa1e0193 --- /dev/null +++ b/docs/ci-testing.md @@ -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-` 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. diff --git a/internal/collector/hyperv/hyperv_test.go b/internal/collector/hyperv/hyperv_test.go index d34f4923..01806b2b 100644 --- a/internal/collector/hyperv/hyperv_test.go +++ b/internal/collector/hyperv/hyperv_test.go @@ -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"}) } diff --git a/internal/collector/logical_disk/logical_disk.go b/internal/collector/logical_disk/logical_disk.go index 849efbb8..29e84b7d 100644 --- a/internal/collector/logical_disk/logical_disk.go +++ b/internal/collector/logical_disk/logical_disk.go @@ -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: diff --git a/internal/collector/logical_disk/logical_disk_test.go b/internal/collector/logical_disk/logical_disk_test.go index 230005f6..8a98138d 100644 --- a/internal/collector/logical_disk/logical_disk_test.go +++ b/internal/collector/logical_disk/logical_disk_test.go @@ -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() diff --git a/internal/collector/mscluster/mscluster_test.go b/internal/collector/mscluster/mscluster_test.go index 49f0e2ef..93d9e966 100644 --- a/internal/collector/mscluster/mscluster_test.go +++ b/internal/collector/mscluster/mscluster_test.go @@ -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"}) } diff --git a/internal/collector/mssql/mssql_test.go b/internal/collector/mssql/mssql_test.go index d0ed417a..94a74c05 100644 --- a/internal/collector/mssql/mssql_test.go +++ b/internal/collector/mssql/mssql_test.go @@ -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"}) } diff --git a/internal/collector/remote_fx/remote_fx_test.go b/internal/collector/remote_fx/remote_fx_test.go index b1254304..7a93ae50 100644 --- a/internal/collector/remote_fx/remote_fx_test.go +++ b/internal/collector/remote_fx/remote_fx_test.go @@ -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) } diff --git a/internal/collector/smtp/smtp_test.go b/internal/collector/smtp/smtp_test.go index 3ab2aa45..d3c2b7e5 100644 --- a/internal/collector/smtp/smtp_test.go +++ b/internal/collector/smtp/smtp_test.go @@ -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()) } diff --git a/internal/collector/terminal_services/terminal_services_test.go b/internal/collector/terminal_services/terminal_services_test.go index cfc645bc..fe7f1afe 100644 --- a/internal/collector/terminal_services/terminal_services_test.go +++ b/internal/collector/terminal_services/terminal_services_test.go @@ -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") } diff --git a/internal/mi/mi_test.go b/internal/mi/mi_test.go index d33ebf55..a9b9c105 100644 --- a/internal/mi/mi_test.go +++ b/internal/mi/mi_test.go @@ -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) diff --git a/internal/utils/testutils/testutils.go b/internal/utils/testutils/testutils.go index 126ea127..9f8fdf46 100644 --- a/internal/utils/testutils/testutils.go +++ b/internal/utils/testutils/testutils.go @@ -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 { diff --git a/tools/setup-sql-server.ps1 b/tools/setup-sql-server.ps1 new file mode 100644 index 00000000..8a31b135 --- /dev/null +++ b/tools/setup-sql-server.ps1 @@ -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 diff --git a/tools/test-summary.ps1 b/tools/test-summary.ps1 new file mode 100644 index 00000000..2f7b7130 --- /dev/null +++ b/tools/test-summary.ps1 @@ -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 +} diff --git a/tools/vhd-lock.ps1 b/tools/vhd-lock.ps1 new file mode 100644 index 00000000..43bf7823 --- /dev/null +++ b/tools/vhd-lock.ps1 @@ -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() + } +}