diff --git a/.github/linters/.codespellrc b/.github/linters/.codespellrc new file mode 100644 index 00000000..0afbbc39 --- /dev/null +++ b/.github/linters/.codespellrc @@ -0,0 +1,2 @@ +[codespell] +ignore-words-list = notin diff --git a/.github/linters/zizmor.yml b/.github/linters/zizmor.yml new file mode 100644 index 00000000..869f3826 --- /dev/null +++ b/.github/linters/zizmor.yml @@ -0,0 +1,5 @@ +rules: + cache-poisoning: + disable: true + secrets-outside-env: + disable: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..8ce8b430 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,1334 @@ +name: CI + +# Trigger on pull requests and pushes to master branch where Go-related files +# have been changed. +on: + push: + branches: + - master + - next + - main + - "0.*" + - "1.*" + tags: + - "v*" + pull_request: + workflow_dispatch: + release: + types: + - published + +permissions: {} + +jobs: + build: + name: Build Windows binaries + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: 'false' + fetch-depth: '0' + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + # renovate: golang=go + go-version: '1.27.1' + + - name: Generate Windows resources + working-directory: cmd/windows_exporter + run: go run github.com/tc-hib/go-winres@v0.3.3 make --product-version=git-tag --file-version=git-tag --arch=amd64,arm64 + + - name: Build snapshot + if: ${{ !startsWith(github.ref, 'refs/tags/') }} + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + # renovate: github=goreleaser/goreleaser + version: v2.18.2 + args: build --snapshot --clean + + - name: Build release + if: ${{ startsWith(github.ref, 'refs/tags/') }} + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + # renovate: github=goreleaser/goreleaser + version: v2.18.2 + args: build --clean + + - name: Stage build artifacts + run: | + mkdir -p output + + version="$(git describe --tags --always)" + version="${version#v}" + version="${version//+/_}" + printf '%s\n' "$version" | tee output/VERSION + + cp dist/windows_exporter-amd64.exe output/ + cp dist/windows_exporter-arm64.exe output/ + + - name: Upload unsigned binaries + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows_exporter_unsigned + path: output/ + + package: + name: Package Windows artifacts + runs-on: windows-2025 + needs: + - build + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: 'false' + + - name: Download unsigned binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: windows_exporter_unsigned + path: output + + - name: Add version to binary names + run: | + $ErrorActionPreference = "Stop" + $Version = Get-Content output\VERSION + + foreach($Arch in "amd64", "arm64") { + Move-Item output\windows_exporter-$Arch.exe output\windows_exporter-$Version-$Arch.exe + } + + Get-ChildItem -Path output + + - name: Sign build artifacts + if: ${{ (github.event_name != 'pull_request' && github.repository == 'prometheus-community/windows_exporter') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'prometheus-community/windows_exporter') }} + run: | + $ErrorActionPreference = "Stop" + $Version = Get-Content output\VERSION + + $b64 = $env:CODE_SIGN_KEY + $filename = 'windows_exporter_CodeSign.pfx' + + $bytes = [Convert]::FromBase64String($b64) + [IO.File]::WriteAllBytes($filename, $bytes) + + $basePath = "C:\Program Files (x86)\Windows Kits\10\bin" + $latestSigntool = Get-ChildItem -Path $basePath -Directory | + Where-Object { $_.Name -match "^\d+\.\d+\.\d+\.\d+$" } | + Sort-Object { [Version]$_.Name } -Descending | + Select-Object -First 1 | + ForEach-Object { Join-Path $_.FullName "x64\signtool.exe" } + + if (Test-Path $latestSigntool) { + Write-Output $latestSigntool + } else { + throw "signtool.exe not found" + } + + foreach($Arch in "amd64", "arm64") { + & $latestSigntool sign /v /tr "http://timestamp.digicert.com" /d "Prometheus exporter for Windows machines" /td SHA256 /fd SHA256 /a /f "windows_exporter_CodeSign.pfx" /p $env:CODE_SIGN_PASSWORD "output\windows_exporter-$Version-$Arch.exe" + } + + Remove-Item windows_exporter_CodeSign.pfx + env: + CODE_SIGN_KEY: ${{ secrets.CODE_SIGN_KEY }} + CODE_SIGN_PASSWORD: ${{ secrets.CODE_SIGN_PASSWORD }} + + - name: Install WiX + run: dotnet tool install --global wix --version 5.0.2 + + - name: Install WiX extensions + run: | + wix extension add -g WixToolset.Util.wixext/5.0.2 + wix extension add -g WixToolset.Ui.wixext/5.0.2 + wix extension add -g WixToolset.Firewall.wixext/5.0.2 + + - name: Build installer artifacts + run: | + $ErrorActionPreference = "Stop" + $Version = Get-Content output\VERSION + + foreach($Arch in "amd64", "arm64") { + Write-Host "Building windows_exporter $Version msi for $Arch" + .\installer\build.ps1 -PathToExecutable .\output\windows_exporter-$Version-$Arch.exe -Version $Version -Arch "$Arch" + } + + Move-Item installer\*.msi output\ + Get-ChildItem -Path output\ + + - name: Sign installer artifacts + if: ${{ (github.event_name != 'pull_request' && github.repository == 'prometheus-community/windows_exporter') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'prometheus-community/windows_exporter') }} + run: | + $ErrorActionPreference = "Stop" + $Version = Get-Content output\VERSION + + $b64 = $env:CODE_SIGN_KEY + $filename = 'windows_exporter_CodeSign.pfx' + + $bytes = [Convert]::FromBase64String($b64) + [IO.File]::WriteAllBytes($filename, $bytes) + + $basePath = "C:\Program Files (x86)\Windows Kits\10\bin" + $latestSigntool = Get-ChildItem -Path $basePath -Directory | + Where-Object { $_.Name -match "^\d+\.\d+\.\d+\.\d+$" } | + Sort-Object { [Version]$_.Name } -Descending | + Select-Object -First 1 | + ForEach-Object { Join-Path $_.FullName "x64\signtool.exe" } + + if (Test-Path $latestSigntool) { + Write-Output $latestSigntool + } else { + throw "signtool.exe not found" + } + + foreach($Arch in "amd64", "arm64") { + & $latestSigntool sign /v /tr "http://timestamp.digicert.com" /d "Prometheus exporter for Windows machines" /td SHA256 /fd SHA256 /a /f "windows_exporter_CodeSign.pfx" /p $env:CODE_SIGN_PASSWORD "output\windows_exporter-$Version-$Arch.msi" + } + + Remove-Item windows_exporter_CodeSign.pfx + env: + CODE_SIGN_KEY: ${{ secrets.CODE_SIGN_KEY }} + CODE_SIGN_PASSWORD: ${{ secrets.CODE_SIGN_PASSWORD }} + + - name: Generate checksums + run: | + $ErrorActionPreference = "Stop" + + $checksums = Get-ChildItem -Path output -File | + Where-Object { $_.Name -match '^windows_exporter-.*\.(exe|msi)$' } | + Sort-Object Name | + ForEach-Object { + $hash = (Get-FileHash -Algorithm SHA256 $_.FullName).Hash.ToLowerInvariant() + "$hash $($_.Name)" + } + + $checksums | Set-Content output\sha256sums.txt -Encoding ascii + Get-Content output\sha256sums.txt + + - name: Upload artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows_exporter_binaries + path: | + output\windows_exporter-*.exe + output\windows_exporter-*.msi + output\sha256sums.txt + + docker: + name: Build docker images + runs-on: ubuntu-24.04 + needs: + - package + permissions: + contents: read + packages: write + env: + DOCKER_BUILD_SUMMARY: false + DOCKER_BUILD_RECORD_UPLOAD: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: 'false' + fetch-depth: '0' + + - name: Download Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: windows_exporter_binaries + + - name: Login to Docker Hub + if: ${{ github.repository == 'prometheus-community/windows_exporter' && github.event_name != 'pull_request' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKER_HUB_LOGIN }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + + - name: Login to quay.io + if: ${{ github.repository == 'prometheus-community/windows_exporter' && github.event_name != 'pull_request' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: quay.io + username: ${{ secrets.QUAY_IO_LOGIN }} + password: ${{ secrets.QUAY_IO_PASSWORD }} + + - name: Login to GitHub container registry + if: ${{ github.repository == 'prometheus-community/windows_exporter' && github.event_name != 'pull_request' }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker meta + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: | + ghcr.io/prometheus-community/windows-exporter + docker.io/prometheuscommunity/windows-exporter + quay.io/prometheuscommunity/windows-exporter + tags: | + type=semver,pattern={{version}} + type=ref,event=branch + type=ref,event=pr + labels: | + org.opencontainers.image.title=windows_exporter + org.opencontainers.image.description=A Prometheus exporter for Windows machines. + org.opencontainers.image.vendor=The Prometheus Community + org.opencontainers.image.licenses=MIT + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + + - name: Build and push + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + push: ${{ github.repository == 'prometheus-community/windows_exporter' && github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: windows/amd64 + annotations: ${{ steps.meta.outputs.labels }} + + publish: + if: github.repository == 'prometheus-community/windows_exporter' && startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-24.04 + needs: + - package + - test + permissions: + contents: write + steps: + - name: Download Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: windows_exporter_binaries + + - name: Publish Artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "$GITHUB_REF_NAME" \ + windows_exporter-*.exe \ + windows_exporter-*.msi \ + sha256sums.txt + + test: + runs-on: windows-2025 + timeout-minutes: 25 + permissions: + contents: read + env: + CNI_VERSION: "1.9.1" + CONTAINERD_VERSION: "2.4.1" + CRI_ENDPOINT: "npipe:////./pipe/containerd-containerd" + CRITOOLS_VERSION: "1.37.0" + KUBERNETES_VERSION: "v1.37.1" + # 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" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: 'false' + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + # renovate: golang=go + go-version: '1.27.1' + + - parallel: + - name: Install Containers feature + shell: pwsh + run: | + $ErrorActionPreference = "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 + background: true + 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)) + + 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 + } + + 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" + } + + Write-Host "RDP session created: $rdpSession" + + $remoteFxNetwork = Get-Counter -ListSet "RemoteFX Network" -ErrorAction Stop + $remoteFxGraphics = Get-Counter -ListSet "RemoteFX Graphics" -ErrorAction Stop + + $networkInstances = @($remoteFxNetwork.PathsWithInstances | + Where-Object { $_ -match "RDP-Tcp#" }) + $graphicsInstances = @($remoteFxGraphics.PathsWithInstances | + Where-Object { $_ -match "RDP-Tcp#" }) + + 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" + } + + - name: Setup MSCluster + id: setup-mscluster + # The Go test requires the cluster and virtual disk. + background: true + shell: powershell + run: | + $ErrorActionPreference = "Stop" + + $deadline = (Get-Date).AddMinutes(3) + do { + $module = Get-Module -ListAvailable FailoverClusters | + Select-Object -First 1 + + if ($null -ne $module) { + break + } + + if ((Get-Date) -ge $deadline) { + throw "FailoverClusters module did not become available" + } + + Start-Sleep -Seconds 2 + } while ($true) + + Import-Module FailoverClusters + + $cluster = Get-Cluster -ErrorAction SilentlyContinue + if ($null -eq $cluster) { + New-Cluster ` + -Name "CICluster" ` + -Node $env:COMPUTERNAME ` + -NoStorage ` + -AdministrativeAccessPoint None ` + -Force | Out-Null + } + + Get-Cluster | Format-List Name, AdministrativeAccessPoint + 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: Prepare container runtime + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + $cacheDir = Join-Path $env:RUNNER_TEMP "container-prereqs" + $containerdInstaller = Join-Path $cacheDir "Install-Containerd.ps1" + $containerdPath = Join-Path $env:ProgramFiles "containerd" + New-Item -ItemType Directory -Force -Path $containerdPath | Out-Null + + Copy-Item (Join-Path $cacheDir "containerd.tar.gz") ` + (Join-Path $containerdPath "containerd.tar.gz") + Copy-Item (Join-Path $cacheDir "crictl.tar.gz") ` + (Join-Path $containerdPath "crictl.tar.gz") + + & $containerdInstaller ` + -ContainerDVersion $env:CONTAINERD_VERSION ` + -crictlVersion $env:CRITOOLS_VERSION + + $containerd = Join-Path $containerdPath "containerd.exe" + $crictl = Join-Path $containerdPath "crictl.exe" + + if (-not (Test-Path $containerd)) { + throw "containerd was not installed" + } + + $cniBinPath = "C:\opt\cni\bin" + $cniConfigPath = "C:\etc\cni\net.d" + New-Item -ItemType Directory -Force -Path $cniBinPath,$cniConfigPath | Out-Null + + $cniArchive = Join-Path $cacheDir "cni-plugins.tgz" + tar.exe -xzf $cniArchive -C $cniBinPath + + @' + { + "cniVersion": "0.3.1", + "name": "ci", + "type": "win-bridge", + "apiVersion": 2, + "ipam": { + "type": "host-local", + "subnet": "192.168.255.0/24", + "rangeStart": "192.168.255.10", + "rangeEnd": "192.168.255.250", + "gateway": "192.168.255.2", + "dataDir": "C:/etc/cni/net.d/ipam" + } + } + '@ | Set-Content ` + -Path (Join-Path $cniConfigPath "10-ci.conf") ` + -Encoding ascii + + $hnsModule = Join-Path $env:RUNNER_TEMP "hns.psm1" + Copy-Item (Join-Path $cacheDir "hns.psm1") $hnsModule + + $kubeDir = "C:\k" + $manifestDir = Join-Path $kubeDir "manifests" + New-Item -ItemType Directory -Force -Path $kubeDir,$manifestDir | Out-Null + + $kubelet = Join-Path $kubeDir "kubelet.exe" + Copy-Item (Join-Path $cacheDir "kubelet.exe") $kubelet + Copy-Item (Join-Path $cacheDir "kubelet.exe.sha256") "$kubelet.sha256" + + $expectedHash = (Get-Content "$kubelet.sha256").Trim().Split()[0].ToLowerInvariant() + $actualHash = (Get-FileHash -Algorithm SHA256 $kubelet).Hash.ToLowerInvariant() + + if ($actualHash -ne $expectedHash) { + throw "kubelet SHA256 mismatch" + } + + @' + apiVersion: kubelet.config.k8s.io/v1beta1 + kind: KubeletConfiguration + authentication: + webhook: + enabled: false + authorization: + mode: AlwaysAllow + enableServer: false + address: 127.0.0.1 + readOnlyPort: 0 + resolvConf: "" + staticPodPath: C:/k/manifests + podLogsDir: C:/k/logs + containerRuntimeEndpoint: npipe:////./pipe/containerd-containerd + '@ | Set-Content ` + -Path (Join-Path $kubeDir "kubelet.yaml") ` + -Encoding ascii + + @' + apiVersion: v1 + kind: Pod + metadata: + name: hostprocess + spec: + hostNetwork: true + securityContext: + windowsOptions: + hostProcess: true + runAsUserName: 'NT AUTHORITY\SYSTEM' + containers: + - name: hostprocess + image: mcr.microsoft.com/oss/kubernetes/windows-host-process-containers-base-image:v1.0.0 + imagePullPolicy: IfNotPresent + command: + - 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' + - -NoLogo + - -NonInteractive + - -Command + - 'while ($true) { Start-Sleep -Seconds 3600 }' + '@ | Set-Content ` + -Path (Join-Path $manifestDir "hostprocess.yaml") ` + -Encoding ascii + + @' + apiVersion: v1 + kind: Pod + metadata: + name: process-isolated + spec: + containers: + - name: nanoserver + image: mcr.microsoft.com/windows/nanoserver:ltsc2025 + imagePullPolicy: IfNotPresent + command: + - ping.exe + - -t + - 127.0.0.1 + '@ | Set-Content ` + -Path (Join-Path $manifestDir "process-isolated.yaml") ` + -Encoding ascii + + Restart-Service containerd + (Get-Service containerd).WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + + & $crictl --runtime-endpoint $env:CRI_ENDPOINT info + if ($LASTEXITCODE -ne 0) { + throw "containerd CRI endpoint is not ready" + } + + - name: Pull HostProcess image + id: pull-hostprocess + shell: pwsh + background: true + run: | + $ErrorActionPreference = "Stop" + + $crictl = Join-Path $env:ProgramFiles "containerd\crictl.exe" + & $crictl ` + --runtime-endpoint $env:CRI_ENDPOINT ` + pull mcr.microsoft.com/oss/kubernetes/windows-host-process-containers-base-image:v1.0.0 + + if ($LASTEXITCODE -ne 0) { + throw "Failed to pull HostProcess base image" + } + + - name: Pull Nano Server image + id: pull-nanoserver + shell: pwsh + background: true + run: | + $ErrorActionPreference = "Stop" + + $crictl = Join-Path $env:ProgramFiles "containerd\crictl.exe" + & $crictl ` + --runtime-endpoint $env:CRI_ENDPOINT ` + pull mcr.microsoft.com/windows/nanoserver:ltsc2025 + + if ($LASTEXITCODE -ne 0) { + throw "Failed to pull Windows Server 2025 Nano Server image" + } + + - name: Setup container network + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + $hnsModule = Join-Path $env:RUNNER_TEMP "hns.psm1" + Import-Module $hnsModule -Force + + $hnsNetwork = Get-HNSNetwork | Where-Object Name -EQ "ci" + if ($null -eq $hnsNetwork) { + $hnsNetwork = New-HNSNetwork ` + -Type L2Bridge ` + -AddressPrefix "192.168.255.0/24" ` + -Gateway "192.168.255.2" ` + -Name "ci" ` + -Verbose + } + + $hnsNetwork | Format-List Name, Type, Id, Subnets + + - name: Start static pods + # Collector tests require both container types and report any missing fixture. + continue-on-error: true + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + $crictl = Join-Path $env:ProgramFiles "containerd\crictl.exe" + $hnsModule = Join-Path $env:RUNNER_TEMP "hns.psm1" + Import-Module $hnsModule -Force + + $deadline = (Get-Date).AddMinutes(2) + do { + $networkReady = $false + try { + $networkReady = $null -ne (Get-HnsNetwork | Where-Object Name -EQ "ci") + } + catch { + $networkReady = $false + } + + $images = & $crictl --runtime-endpoint $env:CRI_ENDPOINT images -o json | ConvertFrom-Json + $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" + + if ($networkReady -and $hostProcessReady -and $nanoServerReady) { + break + } + + if ((Get-Date) -ge $deadline) { + throw "Timed out waiting for HNS network and container images" + } + + Start-Sleep -Seconds 2 + } while ($true) + $kubelet = "C:\k\kubelet.exe" + $kubeletStdout = Join-Path $env:RUNNER_TEMP "kubelet.stdout.log" + $kubeletStderr = Join-Path $env:RUNNER_TEMP "kubelet.stderr.log" + + New-Item -ItemType Directory -Force -Path "C:\k\logs", "C:\k\kubelet" | Out-Null + $kubeletProcess = Start-Process ` + -FilePath $kubelet ` + -ArgumentList "--config=C:\k\kubelet.yaml","--root-dir=C:\k\kubelet","--v=4" ` + -RedirectStandardOutput $kubeletStdout ` + -RedirectStandardError $kubeletStderr ` + -PassThru + + $deadline = (Get-Date).AddMinutes(2) + $running = $false + + do { + if ($kubeletProcess.HasExited) { + Get-Content $kubeletStdout -ErrorAction SilentlyContinue + Get-Content $kubeletStderr -ErrorAction SilentlyContinue + throw "kubelet exited with code $($kubeletProcess.ExitCode)" + } + + try { + $json = & $crictl --runtime-endpoint $env:CRI_ENDPOINT ps -o json + if ($LASTEXITCODE -ne 0) { throw "Failed to query container runtime" } + $containers = ($json -join "`n") | ConvertFrom-Json + $hostProcess = $containers.containers | Where-Object { $_.metadata.name -eq "hostprocess" } + $processIsolated = $containers.containers | Where-Object { $_.metadata.name -eq "nanoserver" } + + if ($hostProcess.state -eq "CONTAINER_RUNNING" -and $processIsolated.state -eq "CONTAINER_RUNNING") { + $running = $true + break + } + } + catch { + Write-Host "Waiting for static pod containers..." + } + + Start-Sleep -Seconds 5 + } while ((Get-Date) -lt $deadline) + + & $crictl --runtime-endpoint $env:CRI_ENDPOINT pods + & $crictl --runtime-endpoint $env:CRI_ENDPOINT ps -a + + if (-not $running) { + Get-Content $kubeletStdout -ErrorAction SilentlyContinue + Get-Content $kubeletStderr -ErrorAction SilentlyContinue + throw "Static pods did not reach Running state" + } + + - name: Wait for Windows roles + wait: [install-windows-roles] + + - parallel: + - name: Setup IIS + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + Start-Service W3SVC + (Get-Service W3SVC).WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + + "windows_exporter integration test" | + Set-Content -Path "C:\inetpub\wwwroot\index.html" -Encoding ascii + + $response = Invoke-WebRequest -Uri "http://127.0.0.1/" -UseBasicParsing + if ($response.StatusCode -ne 200) { + throw "Expected IIS to return HTTP 200, got $($response.StatusCode)" + } + + Get-Service W3SVC,WAS | Format-Table Name, Status, StartType + + - name: Setup MSMQ + shell: powershell + run: | + $ErrorActionPreference = "Stop" + + Start-Service MSMQ + (Get-Service MSMQ).WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + + Import-Module MSMQ + + $queue = Get-MsmqQueue -Name "CIQueue" -QueueType Private -ErrorAction SilentlyContinue + if ($null -eq $queue) { + $queue = New-MsmqQueue -Name "CIQueue" -QueueType Private + } + + $queue | Format-List QueueName, QueueType, MessageCount + + - name: Setup NPS + shell: powershell + run: | + $ErrorActionPreference = "Stop" + + Start-Service IAS + (Get-Service IAS).WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + + Get-Service IAS | + Format-Table Name, Status, StartType + + Get-Counter -ListSet "NPS Authentication Server" -ErrorAction Stop | + Select-Object -ExpandProperty Paths + + Get-Counter -ListSet "NPS Accounting Server" -ErrorAction Stop | + Select-Object -ExpandProperty Paths + + - name: Enable Windows Update + shell: powershell + run: | + $ErrorActionPreference = "Stop" + + Set-Service wuauserv -StartupType Manual + Start-Service wuauserv + (Get-Service wuauserv).WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + + $session = New-Object -ComObject Microsoft.Update.Session + $session.ClientApplicationID = "windows_exporter-ci" + $searcher = $session.CreateUpdateSearcher() + $searcher.Online = $false + + $historyCount = $searcher.GetTotalHistoryCount() + Write-Host "Windows Update history count: $historyCount" + + Get-Service wuauserv | + Format-Table Name, Status, StartType + + - name: Setup printer + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + Start-Service Spooler + (Get-Service Spooler).WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + + $driver = Get-PrinterDriver -ErrorAction SilentlyContinue | + Where-Object Name -EQ "Generic / Text Only" | + Select-Object -First 1 + + if ($null -eq $driver) { + try { + Add-PrinterDriver -Name "Generic / Text Only" -ErrorAction Stop + } + catch { + Write-Host "Generic / Text Only driver is not staged; using an installed driver" + } + + $driver = Get-PrinterDriver -ErrorAction SilentlyContinue | + Where-Object Name -EQ "Generic / Text Only" | + Select-Object -First 1 + } + + if ($null -eq $driver) { + $driver = Get-PrinterDriver -ErrorAction Stop | Select-Object -First 1 + } + + if ($null -eq $driver) { + throw "No printer driver is available" + } + + if ($null -eq (Get-PrinterPort -Name "CIPrinterPort:" -ErrorAction SilentlyContinue)) { + Add-PrinterPort -Name "CIPrinterPort:" + } + + if ($null -eq (Get-Printer -Name "CIPrinter" -ErrorAction SilentlyContinue)) { + Add-Printer ` + -Name "CIPrinter" ` + -DriverName $driver.Name ` + -PortName "CIPrinterPort:" + } + + Get-Printer -Name "CIPrinter" | + Format-List Name, DriverName, PortName, PrinterStatus + + - name: Setup FSRM quota + # The Go test requires the quota, so still run other collector tests on setup failure. + continue-on-error: true + shell: powershell + run: | + $ErrorActionPreference = "Stop" + + Start-Service SrmSvc + (Get-Service SrmSvc).WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + + Import-Module FileServerResourceManager + + $deadline = (Get-Date).AddSeconds(30) + do { + try { + Get-CimClass ` + -Namespace "root/Microsoft/Windows/FSRM" ` + -ClassName "MSFT_FSRMQuota" ` + -ErrorAction Stop | Out-Null + break + } + catch { + if ((Get-Date) -ge $deadline) { + throw + } + + Start-Sleep -Seconds 2 + } + } while ($true) + + # Initialize the quota provider through its native COM API. + $quotaPath = "C:\fsrm-ci" + New-Item -ItemType Directory -Force -Path $quotaPath | Out-Null + + $quota = Get-FsrmQuota -Path $quotaPath -ErrorAction SilentlyContinue + if ($null -eq $quota) { + $manager = New-Object -ComObject Fsrm.FsrmQuotaManager + $quota = $manager.CreateQuota($quotaPath) + $quota.QuotaLimit = 100MB + $quota.Description = "windows_exporter integration test" + $quota.Commit() + } + + "windows_exporter integration test" | + Set-Content -Path (Join-Path $quotaPath "test.txt") -Encoding ascii + + $manager = New-Object -ComObject Fsrm.FsrmQuotaManager + $manager.GetQuota($quotaPath) | Format-List Path, QuotaLimit, QuotaUsed, Description + + Restart-Service SrmSvc + (Get-Service SrmSvc).WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + + $deadline = (Get-Date).AddSeconds(60) + do { + $quotas = @(Get-CimInstance ` + -Namespace "root/Microsoft/Windows/FSRM" ` + -ClassName "MSFT_FSRMQuota" -ErrorAction Stop) + $quotas | Format-List Path, Size, Usage, Description + $cimQuota = $quotas | Where-Object { $_.Path.TrimEnd('\') -eq $quotaPath } + if ($null -ne $cimQuota) { break } + if ((Get-Date) -ge $deadline) { + fltmc.exe filters + fltmc.exe instances + Get-WinEvent -FilterHashtable @{LogName='Application'; StartTime=(Get-Date).AddMinutes(-10)} ` + -ErrorAction SilentlyContinue | Where-Object ProviderName -Match 'SRM|FSRM' | + Select-Object TimeCreated, ProviderName, Message + throw "MSFT_FSRMQuota instance for $quotaPath was not found" + } + Start-Sleep -Seconds 2 + } while ($true) + + $cimQuota | Format-List Path, Size, Usage, PeakUsage, SoftLimit, Disabled, Description + + - name: Setup SMB + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + $sharePath = Join-Path $env:RUNNER_TEMP "smb" + New-Item -ItemType Directory -Force -Path $sharePath | Out-Null + + if ($null -eq (Get-SmbShare -Name "CIShare" -ErrorAction SilentlyContinue)) { + New-SmbShare ` + -Name "CIShare" ` + -Path $sharePath ` + -FullAccess "Everyone" + } + + $unc = "\\$env:COMPUTERNAME\CIShare" + "windows_exporter integration test" | Set-Content "$unc\test.txt" -Encoding ascii + $content = Get-Content "$unc\test.txt" + + if ($content -ne "windows_exporter integration test") { + throw "Unexpected content read back over SMB" + } + + Get-SmbShare -Name "CIShare" + Get-SmbConnection | Format-Table ServerName, ShareName, Dialect, NumOpens + - name: Setup DNS and DHCP + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + Start-Service DNS + Start-Service DHCPServer + + (Get-Service DNS).WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + (Get-Service DHCPServer).WaitForStatus("Running", [TimeSpan]::FromSeconds(30)) + + Get-Service DNS,DHCPServer | + Format-Table Name, Status, StartType + + Add-DnsServerPrimaryZone ` + -Name "ci.contoso.com" ` + -ZoneFile "ci.contoso.com.dns" + + Add-DnsServerResourceRecordA ` + -Name "test" ` + -ZoneName "ci.contoso.com" ` + -IPv4Address "192.0.2.10" + + $zone = Get-DnsServerZone -Name "ci.contoso.com" -ErrorAction Stop + $zone | Format-List ZoneName, ZoneType, IsDsIntegrated + + Get-DnsServerResourceRecord ` + -ZoneName "ci.contoso.com" ` + -Name "test" ` + -RRType A + + Add-DhcpServerv4Scope ` + -Name "GitHubActions" ` + -StartRange "192.0.2.100" ` + -EndRange "192.0.2.200" ` + -SubnetMask "255.255.255.0" ` + -State Active + + $scope = Get-DhcpServerv4Scope ` + -ScopeId "192.0.2.0" ` + -ErrorAction Stop + + $scope | Format-List ScopeId, Name, State, StartRange, EndRange, SubnetMask + + if ($zone.ZoneType -ne "Primary") { + throw "Expected a primary DNS zone" + } + + if ($scope.State -ne "Active") { + throw "Expected an active DHCP scope" + } + + + - name: Wait for background setup + wait: [pull-hostprocess, pull-nanoserver, setup-mscluster, setup-rdp] + + - name: Start .NET Framework and SMB workloads + 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() + } + '@ | Set-Content $workload + $process = Start-Process powershell.exe -PassThru ` + -ArgumentList "-NoProfile", "-File", $workload + $process.Id | Set-Content (Join-Path $env:RUNNER_TEMP "netframework.pid") + winmgmt.exe /resyncperf + if ($LASTEXITCODE -ne 0) { throw "Failed to synchronize WMI performance counters" } + + - name: Go tests + shell: pwsh + run: | + go test -v -count=1 -race -timeout=10m ./... 2>&1 | Tee-Object test-output.txt + $testExitCode = $LASTEXITCODE + Select-String -Path test-output.txt -Pattern '^--- SKIP:' | + ForEach-Object Line | Add-Content $env:GITHUB_STEP_SUMMARY + exit $testExitCode + + - name: Build and smoke test exporter + shell: pwsh + run: | + go build -trimpath -o windows_exporter.exe ./cmd/windows_exporter + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + ./tools/end-to-end-test.ps1 + + - name: Upload test logs + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-test-results + path: | + test-output.txt + ${{ runner.temp }}/windows_exporter.*.log + ${{ runner.temp }}/kubelet.*.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: + contents: read + steps: + # `gofmt` linter run by golangci-lint fails on CRLF line endings (the default for Windows) + - name: Set git to use LF + run: | + git config --global core.autocrlf false + git config --global core.eol lf + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: 'false' + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + # renovate: golang=go + go-version: '1.27.1' + + - name: golangci-lint + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + # renovate: github=golangci/golangci-lint + version: v2.13.2 + args: "--max-same-issues=0" + + super-linter: + name: Super Linter + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Checkout Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + fetch-depth: 0 + fetch-tags: 'false' + + - name: Lint Code Base + uses: super-linter/super-linter/slim@2da136927bd4a73596db63044b504547c62cb854 # v9.0.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MULTI_STATUS: false + LINTER_RULES_PATH: .github/linters + GITHUB_ACTIONS_ZIZMOR_CONFIG_FILE: ../../../github/workspace/.github/linters/zizmor.yml + ENABLE_GITHUB_ACTIONS_STEP_SUMMARY: true + ENABLE_GITHUB_PULL_REQUEST_SUMMARY_COMMENT: false + SAVE_SUPER_LINTER_SUMMARY: true + VALIDATE_ALL_CODEBASE: false + VALIDATE_BASH: true + VALIDATE_BASH_EXEC: true + VALIDATE_EDITORCONFIG: true + VALIDATE_ENV: true + # VALIDATE_GITHUB_ACTIONS: true + VALIDATE_GITHUB_ACTIONS_ZIZMOR: true + VALIDATE_GITLEAKS: true + VALIDATE_GO_RELEASER: true + VALIDATE_HTML: true + VALIDATE_JSON: true + # VALIDATE_MARKDOWN: true + VALIDATE_NATURAL_LANGUAGE: true + VALIDATE_PYTHON: true + VALIDATE_RENOVATE: true + VALIDATE_SHELL_SHFMT: true + VALIDATE_SPELL_CODESPELL: true + # VALIDATE_XML: true + # VALIDATE_YAML: true + diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 49de97d1..00000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,98 +0,0 @@ -name: Linting - -# Trigger on pull requests and pushes to master branch where Go-related files -# have been changed. -on: - push: - branches: - - master - - next - - main - - "0.*" - - "1.*" - pull_request: - -env: - VERSION_PROMU: '0.14.0' - PROMTOOL_VER: '2.43.0' - -jobs: - test: - runs-on: windows-2025 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - # renovate: golang=go - go-version: '1.27.1' - - - name: Test - run: make test - - - name: Install e2e deps - run: | - Invoke-WebRequest -Uri https://github.com/prometheus/promu/releases/download/v$($Env:VERSION_PROMU)/promu-$($Env:VERSION_PROMU).windows-amd64.zip -OutFile promu-$($Env:VERSION_PROMU).windows-amd64.zip - Expand-Archive -Path promu-$($Env:VERSION_PROMU).windows-amd64.zip -DestinationPath . - Copy-Item -Path promu-$($Env:VERSION_PROMU).windows-amd64\promu.exe -Destination "$(go env GOPATH)\bin" - - # GOPATH\bin dir must be appended to PATH else the `promu` command won't be found - echo "$(go env GOPATH)\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - - name: e2e Test - run: make e2e-test - - promtool: - runs-on: windows-2025 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - # renovate: golang=go - go-version: '1.27.1' - - - name: Install promtool - run: | - Invoke-WebRequest -Uri https://github.com/prometheus/prometheus/releases/download/v$($Env:PROMTOOL_VER)/prometheus-$($Env:PROMTOOL_VER).windows-amd64.zip -OutFile prometheus-$($Env:PROMTOOL_VER).windows-amd64.zip - Expand-Archive -Path prometheus-$($Env:PROMTOOL_VER).windows-amd64.zip -DestinationPath . - Copy-Item -Path prometheus-$($Env:PROMTOOL_VER).windows-amd64\promtool.exe -Destination "$(go env GOPATH)\bin" - - Invoke-WebRequest -Uri https://github.com/prometheus/promu/releases/download/v$($Env:VERSION_PROMU)/promu-$($Env:VERSION_PROMU).windows-amd64.zip -OutFile promu-$($Env:VERSION_PROMU).windows-amd64.zip - Expand-Archive -Path promu-$($Env:VERSION_PROMU).windows-amd64.zip -DestinationPath . - Copy-Item -Path promu-$($Env:VERSION_PROMU).windows-amd64\promu.exe -Destination "$(go env GOPATH)\bin" - - # GOPATH\bin dir must be appended to PATH else the `promu` command won't be found - echo "$(go env GOPATH)\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - - name: Promtool - run: make promtool - - - name: Upload windows_exporter.exe - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - if: always() - with: - name: windows_exporter.amd64.exe - path: 'windows_exporter.exe' - retention-days: 7 - if-no-files-found: error - - lint: - runs-on: windows-2025 - steps: - # `gofmt` linter run by golangci-lint fails on CRLF line endings (the default for Windows) - - name: Set git to use LF - run: | - git config --global core.autocrlf false - git config --global core.eol lf - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - # renovate: golang=go - go-version: '1.27.1' - - - name: golangci-lint - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 - with: - # renovate: github=golangci/golangci-lint - version: v2.13.2 - args: "--max-same-issues=0" diff --git a/.github/workflows/pr-check.yaml b/.github/workflows/pr-check.yaml index e7730100..13ce8d11 100644 --- a/.github/workflows/pr-check.yaml +++ b/.github/workflows/pr-check.yaml @@ -8,10 +8,12 @@ on: - labeled - unlabeled +permissions: {} + jobs: required-labels-missing: name: required labels missing - runs-on: ubuntu-latest + runs-on: ubuntu-slim steps: - name: check if: >- @@ -31,9 +33,14 @@ jobs: && exit 1 title: name: check title prefix - runs-on: ubuntu-latest + runs-on: ubuntu-slim + permissions: + contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: 'false' + - name: check run: | PR_TITLE_PREFIX=$(echo "$PR_TITLE" | cut -d':' -f1) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index d2e2655b..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,244 +0,0 @@ -name: Releases - -# Trigger on releases. -on: - push: - branches: - - master - pull_request: - workflow_dispatch: - release: - types: - - published - -permissions: - contents: write - packages: write - -env: - VERSION_PROMU: '0.17.0' - -jobs: - build: - runs-on: windows-2025 - environment: build - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: '0' - - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - # renovate: golang=go - go-version: '1.27.1' - - - name: Install WiX - run: | - dotnet tool install --global wix --version 5.0.2 - - - name: Install WiX extensions - run: | - wix extension add -g WixToolset.Util.wixext/5.0.2 - wix extension add -g WixToolset.Ui.wixext/5.0.2 - wix extension add -g WixToolset.Firewall.wixext/5.0.2 - - - name: Install Build deps - run: | - Invoke-WebRequest -Uri https://github.com/prometheus/promu/releases/download/v$($Env:VERSION_PROMU)/promu-$($Env:VERSION_PROMU).windows-amd64.zip -OutFile promu-$($Env:VERSION_PROMU).windows-amd64.zip - Expand-Archive -Path promu-$($Env:VERSION_PROMU).windows-amd64.zip -DestinationPath . - Copy-Item -Path promu-$($Env:VERSION_PROMU).windows-amd64\promu.exe -Destination "$(go env GOPATH)\bin" - - # GOPATH\bin dir must be added to PATH else the `promu` commands won't be found - echo "$(go env GOPATH)\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - - name: Build - run: | - $ErrorActionPreference = "Stop" - - $Version = git describe --tags --always - $Version = $Version -replace 'v', '' - # '+' symbols are invalid characters in image tags - $Version = $Version -replace '\+', '_' - $Version | Set-Content VERSION -PassThru - - make build-all - - # GH requires all files to have different names, so add version/arch to differentiate - foreach($Arch in "amd64", "arm64") { - Move-Item output\$Arch\windows_exporter.exe output\windows_exporter-$Version-$Arch.exe - } - - Get-ChildItem -Path output - - - name: Sign build artifacts - if: ${{ (github.event_name != 'pull_request' && github.repository == 'prometheus-community/windows_exporter') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'prometheus-community/windows_exporter') }} - run: | - $ErrorActionPreference = "Stop" - $Version = Get-Content VERSION - - $b64 = $env:CODE_SIGN_KEY - $filename = 'windows_exporter_CodeSign.pfx' - - $bytes = [Convert]::FromBase64String($b64) - [IO.File]::WriteAllBytes($filename, $bytes) - - $basePath = "C:\Program Files (x86)\Windows Kits\10\bin" - $latestSigntool = Get-ChildItem -Path $basePath -Directory | - Where-Object { $_.Name -match "^\d+\.\d+\.\d+\.\d+$" } | - Sort-Object { [Version]$_.Name } -Descending | - Select-Object -First 1 | - ForEach-Object { Join-Path $_.FullName "x64\signtool.exe" } - - if (Test-Path $latestSigntool) { - Write-Output $latestSigntool - } else { - Write-Output "signtool.exe not found" - } - - foreach($Arch in "amd64", "arm64") { - & $latestSigntool sign /v /tr "http://timestamp.digicert.com" /d "Prometheus exporter for Windows machines" /td SHA256 /fd SHA256 /a /f "windows_exporter_CodeSign.pfx" /p $env:CODE_SIGN_PASSWORD "output\windows_exporter-$Version-$Arch.exe" - } - - rm windows_exporter_CodeSign.pfx - env: - CODE_SIGN_KEY: ${{ secrets.CODE_SIGN_KEY }} - CODE_SIGN_PASSWORD: ${{ secrets.CODE_SIGN_PASSWORD }} - - - name: Build Release Artifacts - run: | - $ErrorActionPreference = "Stop" - $Version = Get-Content VERSION - - foreach($Arch in "amd64", "arm64") { - Write-Host "Building windows_exporter $Version msi for $Arch" - .\installer\build.ps1 -PathToExecutable .\output\windows_exporter-$Version-$Arch.exe -Version $Version -Arch "$Arch" - } - - Move-Item installer\*.msi output\ - Get-ChildItem -Path output\ g - - - name: Sign installer artifacts - if: ${{ (github.event_name != 'pull_request' && github.repository == 'prometheus-community/windows_exporter') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'prometheus-community/windows_exporter') }} - run: | - $ErrorActionPreference = "Stop" - $Version = Get-Content VERSION - - $b64 = $env:CODE_SIGN_KEY - $filename = 'windows_exporter_CodeSign.pfx' - - $bytes = [Convert]::FromBase64String($b64) - [IO.File]::WriteAllBytes($filename, $bytes) - - $basePath = "C:\Program Files (x86)\Windows Kits\10\bin" - $latestSigntool = Get-ChildItem -Path $basePath -Directory | - Where-Object { $_.Name -match "^\d+\.\d+\.\d+\.\d+$" } | - Sort-Object { [Version]$_.Name } -Descending | - Select-Object -First 1 | - ForEach-Object { Join-Path $_.FullName "x64\signtool.exe" } - - if (Test-Path $latestSigntool) { - Write-Output $latestSigntool - } else { - Write-Output "signtool.exe not found" - } - - foreach($Arch in "amd64", "arm64") { - & $latestSigntool sign /v /tr "http://timestamp.digicert.com" /d "Prometheus exporter for Windows machines" /td SHA256 /fd SHA256 /a /f "windows_exporter_CodeSign.pfx" /p $env:CODE_SIGN_PASSWORD "output\windows_exporter-$Version-$Arch.msi" - } - - rm windows_exporter_CodeSign.pfx - env: - CODE_SIGN_KEY: ${{ secrets.CODE_SIGN_KEY }} - CODE_SIGN_PASSWORD: ${{ secrets.CODE_SIGN_PASSWORD }} - - - name: Generate checksums - run: | - promu checksum output - cat output\sha256sums.txt - - - name: Upload Artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: windows_exporter_binaries - path: | - output\windows_exporter-*.exe - output\windows_exporter-*.msi - - - name: Release - if: startsWith(github.ref, 'refs/tags/') - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - $TagName = $env:GITHUB_REF -replace 'refs/tags/', '' - Get-ChildItem -Path output\* -Include @('windows_exporter*.msi', 'windows_exporter*.exe', 'sha256sums.txt') | Foreach-Object {gh release upload $TagName $_} - docker: - name: Build docker images - runs-on: ubuntu-latest - needs: - - build - env: - DOCKER_BUILD_SUMMARY: false - DOCKER_BUILD_RECORD_UPLOAD: false - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: '0' - - - name: Download Artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: windows_exporter_binaries - - - name: Login to Docker Hub - if: ${{ github.event_name != 'pull_request' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ${{ secrets.DOCKER_HUB_LOGIN }} - password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - - name: Login to quay.io - if: ${{ github.event_name != 'pull_request' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: quay.io - username: ${{ secrets.QUAY_IO_LOGIN }} - password: ${{ secrets.QUAY_IO_PASSWORD }} - - - name: Login to GitHub container registry - if: ${{ github.event_name != 'pull_request' }} - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Docker meta - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: | - ghcr.io/prometheus-community/windows-exporter - docker.io/prometheuscommunity/windows-exporter - quay.io/prometheuscommunity/windows-exporter - tags: | - type=semver,pattern={{version}} - type=ref,event=branch - type=ref,event=pr - labels: | - org.opencontainers.image.title=windows_exporter - org.opencontainers.image.description=A Prometheus exporter for Windows machines. - org.opencontainers.image.vendor=The Prometheus Community - org.opencontainers.image.licenses=MIT - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - - - name: Build and push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - platforms: windows/amd64 - annotations: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/spelling.yml b/.github/workflows/spelling.yml deleted file mode 100644 index e47ae9dc..00000000 --- a/.github/workflows/spelling.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Spell checking - -# Trigger on pull requests, and pushes to master branch. -on: - push: - branches: - - master - pull_request: - branches: - - master - -env: - VERSION_PROMU: 'v0.14.0' - -jobs: - codespell: - name: Check for spelling errors - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: codespell-project/actions-codespell@e3258cca84ce02b0bb36a3d41f2c18f719a0cc1a # master - with: - check_filenames: true - # When using this Action in other repos, the --skip option below can be removed - skip: ./.git,go.mod,go.sum - ignore_words_list: calle,Entires diff --git a/.gitignore b/.gitignore index 53bb68da..f58aaba8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ VERSION *.swp *.un~ output/ +dist/ .vscode *.syso installer/*.msi diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 00000000..12e4a5b2 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema.json + +version: 2 + +builds: + - id: windows_exporter + main: ./cmd/windows_exporter + binary: windows_exporter-{{ .Arch }} + no_unique_dist_dir: true + goos: + - windows + goarch: + - amd64 + - arm64 + env: + - CGO_ENABLED=0 + flags: + - -trimpath + tags: + - trimpath + ldflags: + - >- + -X github.com/prometheus/common/version.Version={{ .Version }} + -X github.com/prometheus/common/version.Revision={{ .FullCommit }} + -X github.com/prometheus/common/version.Branch={{ .Branch }} + -X github.com/prometheus/common/version.BuildUser=github-actions + -X github.com/prometheus/common/version.BuildDate={{ .Now.Format "20060102-15:04:05" }} + mod_timestamp: '{{ .CommitTimestamp }}' diff --git a/.promu.yml b/.promu.yml deleted file mode 100644 index 9a736cb0..00000000 --- a/.promu.yml +++ /dev/null @@ -1,27 +0,0 @@ -go: - # Whenever the Go version is updated here, - # .github/workflows should also be updated. - version: 1.23 - cgo: false -repository: - path: github.com/prometheus-community/windows_exporter -build: - binaries: - - name: windows_exporter - path: ./cmd/windows_exporter - tags: - all: - - trimpath - ldflags: | - -X github.com/prometheus/common/version.Version={{.Version}} - -X github.com/prometheus/common/version.Revision={{.Revision}} - -X github.com/prometheus/common/version.Branch={{.Branch}} - -X github.com/prometheus/common/version.BuildUser={{user}}@{{host}} - -X github.com/prometheus/common/version.BuildDate={{date "20060102-15:04:05"}} -tarball: - files: - - LICENSE -crossbuild: - platforms: - - windows/amd64 - - windows/arm64 diff --git a/Makefile b/Makefile index c6dbf64c..ac97bf25 100644 --- a/Makefile +++ b/Makefile @@ -16,10 +16,10 @@ ALL_OS ?= ltsc2019 ltsc2022 BASE_IMAGE ?= mcr.microsoft.com/windows/nanoserver .PHONY: build -build: generate windows_exporter.exe +build: windows_exporter.exe -windows_exporter.exe: pkg/**/*.go - promu build -v +windows_exporter.exe: generate + CGO_ENABLED=0 go build -trimpath -tags=trimpath -o $@ ./cmd/windows_exporter .PHONY: generate generate: @@ -38,18 +38,13 @@ lint: e2e-test: windows_exporter.exe powershell -NonInteractive -ExecutionPolicy Bypass -File .\tools\end-to-end-test.ps1 -.PHONY: promtool -promtool: windows_exporter.exe - pwsh -NonInteractive -ExecutionPolicy Bypass -File .\tools\promtool.ps1 - fmt: gofmt -l -w -s . crossbuild: generate - # The prometheus/golang-builder image for promu crossbuild doesn't exist - # on Windows, so for now, we'll just build twice - GOARCH=amd64 promu build --prefix=output/amd64 - GOARCH=arm64 promu build --prefix=output/arm64 + mkdir -p output/amd64 output/arm64 + CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -tags=trimpath -o output/amd64/windows_exporter.exe ./cmd/windows_exporter + CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -trimpath -tags=trimpath -o output/arm64/windows_exporter.exe ./cmd/windows_exporter .PHONY: package package: crossbuild diff --git a/cmd/windows_exporter/main_test.go b/cmd/windows_exporter/main_test.go index 28642391..76d63e1c 100644 --- a/cmd/windows_exporter/main_test.go +++ b/cmd/windows_exporter/main_test.go @@ -96,13 +96,8 @@ func TestRun(t *testing.T) { exitCodeCh := make(chan int) - var stdout string - go func() { - stdout = captureOutput(t, func() { - // Simulate the service control manager signaling that we are done. - exitCodeCh <- run(ctx, tc.args) - }) + exitCodeCh <- run(ctx, tc.args) }() t.Cleanup(func() { @@ -122,13 +117,13 @@ func TestRun(t *testing.T) { require.NoError(t, err) err = waitUntilListening(t, "tcp", uri.Host) - require.NoError(t, err, "LOGS:\n%s", stdout) + require.NoError(t, err) req, err := http.NewRequestWithContext(ctx, http.MethodGet, tc.metricsEndpoint, nil) require.NoError(t, err) resp, err := http.DefaultClient.Do(req) - require.NoError(t, err, "LOGS:\n%s", stdout) + require.NoError(t, err) require.Equal(t, http.StatusOK, resp.StatusCode) body, err := io.ReadAll(resp.Body) @@ -145,24 +140,6 @@ func TestRun(t *testing.T) { } } -func captureOutput(tb testing.TB, f func()) string { - tb.Helper() - - orig := os.Stdout - r, w, _ := os.Pipe() - os.Stdout = w - - f() - - os.Stdout = orig - - _ = w.Close() - - out, _ := io.ReadAll(r) - - return string(out) -} - func waitUntilListening(tb testing.TB, network, address string) error { tb.Helper() @@ -173,7 +150,9 @@ func waitUntilListening(tb testing.TB, network, address string) error { dialer := &net.Dialer{Timeout: 100 * time.Millisecond} - for range 20 { + deadline := time.Now().Add(30 * time.Second) + + for time.Now().Before(deadline) { conn, err = dialer.DialContext(tb.Context(), network, address) if err == nil { _ = conn.Close() diff --git a/internal/collector/adcs/adcs.go b/internal/collector/adcs/adcs.go index e38a3508..c14c9857 100644 --- a/internal/collector/adcs/adcs.go +++ b/internal/collector/adcs/adcs.go @@ -85,6 +85,19 @@ func (c *Collector) Close() error { } func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { + c.buildDescriptors() + + var err error + + c.perfDataCollector, err = pdh.NewCollector[perfDataCounterValues](logger.With(slog.String("collector", Name)), pdh.CounterTypeRaw, "Certification Authority", pdh.InstancesAll) + if err != nil { + return fmt.Errorf("failed to create Certification Authority collector: %w", err) + } + + return nil +} + +func (c *Collector) buildDescriptors() { c.requestsPerSecond = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "requests_total"), "Total certificate requests processed", @@ -163,15 +176,6 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { []string{"cert_template"}, nil, ) - - var err error - - c.perfDataCollector, err = pdh.NewCollector[perfDataCounterValues](logger.With(slog.String("collector", Name)), pdh.CounterTypeRaw, "Certification Authority", pdh.InstancesAll) - if err != nil { - return fmt.Errorf("failed to create Certification Authority collector: %w", err) - } - - return nil } func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error { @@ -180,7 +184,13 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error return fmt.Errorf("failed to collect Certification Authority (ADCS) metrics: %w", err) } - for _, data := range c.perfDataObject { + c.collectMetrics(ch, c.perfDataObject) + + return nil +} + +func (c *Collector) collectMetrics(ch chan<- prometheus.Metric, rows []perfDataCounterValues) { + for _, data := range rows { ch <- prometheus.MustNewConstMetric( c.requestsPerSecond, prometheus.CounterValue, @@ -272,6 +282,4 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error data.Name, ) } - - return nil } diff --git a/internal/collector/adcs/adcs_internal_test.go b/internal/collector/adcs/adcs_internal_test.go new file mode 100644 index 00000000..eda96dd4 --- /dev/null +++ b/internal/collector/adcs/adcs_internal_test.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package adcs + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/require" +) + +func TestCollectMetrics(t *testing.T) { + t.Parallel() + + values := perfDataCounterValues{ + Name: "WebServer", + RequestsPerSecond: 17, + RequestProcessingTime: 1500, + RetrievalsPerSecond: 19, + RetrievalProcessingTime: 2250, + FailedRequestsPerSecond: 3, + IssuedRequestsPerSecond: 11, + PendingRequestsPerSecond: 2, + RequestCryptographicSigningTime: 500, + RequestPolicyModuleProcessingTime: 750, + ChallengeResponsesPerSecond: 13, + ChallengeResponseProcessingTime: 1250, + SignedCertificateTimestampListsPerSecond: 23, + SignedCertificateTimestampListProcessingTime: 1750, + } + expected := map[string]struct { + metricType dto.MetricType + value float64 + }{ + "windows_adcs_requests_total": {metricType: dto.MetricType_COUNTER, value: 17}, + "windows_adcs_request_processing_time_seconds": {metricType: dto.MetricType_GAUGE, value: 1.5}, + "windows_adcs_retrievals_total": {metricType: dto.MetricType_COUNTER, value: 19}, + "windows_adcs_retrievals_processing_time_seconds": {metricType: dto.MetricType_GAUGE, value: 2.25}, + "windows_adcs_failed_requests_total": {metricType: dto.MetricType_COUNTER, value: 3}, + "windows_adcs_issued_requests_total": {metricType: dto.MetricType_COUNTER, value: 11}, + "windows_adcs_pending_requests_total": {metricType: dto.MetricType_COUNTER, value: 2}, + "windows_adcs_request_cryptographic_signing_time_seconds": {metricType: dto.MetricType_GAUGE, value: 0.5}, + "windows_adcs_request_policy_module_processing_time_seconds": {metricType: dto.MetricType_GAUGE, value: 0.75}, + "windows_adcs_challenge_responses_total": {metricType: dto.MetricType_COUNTER, value: 13}, + "windows_adcs_challenge_response_processing_time_seconds": {metricType: dto.MetricType_GAUGE, value: 1.25}, + "windows_adcs_signed_certificate_timestamp_lists_total": {metricType: dto.MetricType_COUNTER, value: 23}, + "windows_adcs_signed_certificate_timestamp_list_processing_time_seconds": {metricType: dto.MetricType_GAUGE, value: 1.75}, + } + + for _, tc := range []struct { + name string + rows []perfDataCounterValues + }{ + {name: "no templates"}, + {name: "zero counters", rows: []perfDataCounterValues{{Name: "ZeroTemplate"}}}, + {name: "independent templates", rows: []perfDataCounterValues{values, {Name: "ZeroTemplate"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + c := New(nil) + c.buildDescriptors() + + registry := prometheus.NewPedanticRegistry() + require.NoError(t, registry.Register(metricCollector{collector: c, rows: tc.rows})) + families, err := registry.Gather() + require.NoError(t, err) + + if len(tc.rows) == 0 { + require.Empty(t, families) + + return + } + + require.Len(t, families, len(expected)) + + for _, family := range families { + want, ok := expected[family.GetName()] + require.True(t, ok, "unexpected metric %s", family.GetName()) + require.Equal(t, want.metricType, family.GetType()) + require.Len(t, family.GetMetric(), len(tc.rows)) + templates := make(map[string]bool, len(tc.rows)) + + for _, metric := range family.GetMetric() { + require.Len(t, metric.GetLabel(), 1) + label := metric.GetLabel()[0] + require.Equal(t, "cert_template", label.GetName()) + require.False(t, templates[label.GetValue()], "duplicate template sample") + templates[label.GetValue()] = true + + expectedValue := 0.0 + if label.GetValue() == values.Name { + expectedValue = want.value + } else { + require.Equal(t, "ZeroTemplate", label.GetValue()) + } + + actual := metric.GetGauge().GetValue() + if want.metricType == dto.MetricType_COUNTER { + actual = metric.GetCounter().GetValue() + } + + require.InDelta(t, expectedValue, actual, 0, "%s for template %s", family.GetName(), label.GetValue()) + } + } + }) + } +} + +type metricCollector struct { + collector *Collector + rows []perfDataCounterValues +} + +func (c metricCollector) Describe(ch chan<- *prometheus.Desc) { + prometheus.DescribeByCollect(c, ch) +} + +func (c metricCollector) Collect(ch chan<- prometheus.Metric) { + c.collector.collectMetrics(ch, c.rows) +} diff --git a/internal/collector/container/container_test.go b/internal/collector/container/container_test.go index bda2edbe..ae4bccc3 100644 --- a/internal/collector/container/container_test.go +++ b/internal/collector/container/container_test.go @@ -22,6 +22,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/container" "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, container.New, nil) + metrics := testutils.TestCollector(t, container.New, nil) + testutils.RequireFixtureMetric(t, metrics, container.Name, "windows_container_available", prometheus.Labels{"container": "hostprocess", "hostprocess": "true"}) + testutils.RequireFixtureMetric(t, metrics, container.Name, "windows_container_available", prometheus.Labels{"container": "nanoserver", "hostprocess": "false"}) } diff --git a/internal/collector/dhcp/dhcp_test.go b/internal/collector/dhcp/dhcp_test.go index 55708ca1..d8704363 100644 --- a/internal/collector/dhcp/dhcp_test.go +++ b/internal/collector/dhcp/dhcp_test.go @@ -22,6 +22,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/dhcp" "github.com/prometheus-community/windows_exporter/internal/utils/testutils" + "github.com/prometheus/client_golang/prometheus" ) func BenchmarkCollector(b *testing.B) { @@ -29,5 +30,6 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - testutils.TestCollector(t, dhcp.New, nil) + metrics := testutils.TestCollector(t, dhcp.New, nil) + testutils.RequireFixtureMetric(t, metrics, dhcp.Name, "windows_dhcp_scope_info", prometheus.Labels{"name": "GitHubActions", "scope": "192.0.2.0/24"}) } diff --git a/internal/collector/file/file_test.go b/internal/collector/file/file_test.go index 40c97298..5e7487c8 100644 --- a/internal/collector/file/file_test.go +++ b/internal/collector/file/file_test.go @@ -18,10 +18,13 @@ package file_test import ( + "os" + "path/filepath" "testing" "github.com/prometheus-community/windows_exporter/internal/collector/file" "github.com/prometheus-community/windows_exporter/internal/utils/testutils" + "github.com/stretchr/testify/require" ) func BenchmarkCollector(b *testing.B) { @@ -29,7 +32,51 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - testutils.TestCollector(t, file.New, &file.Config{ - FilePatterns: []string{"*.*"}, + path := filepath.Join(t.TempDir(), "test.txt") + content := []byte("windows_exporter test") + require.NoError(t, os.WriteFile(path, content, 0o600)) + + metrics := testutils.TestCollector(t, file.New, &file.Config{ + FilePatterns: []string{path}, }) + require.Contains(t, metrics, "windows_file_size_bytes") + require.Len(t, metrics["windows_file_size_bytes"].GetMetric(), 1) + require.InDelta(t, len(content), metrics["windows_file_size_bytes"].GetMetric()[0].GetGauge().GetValue(), 0) + require.Contains(t, metrics, "windows_file_mtime_timestamp_seconds") + require.Positive(t, metrics["windows_file_mtime_timestamp_seconds"].GetMetric()[0].GetGauge().GetValue()) +} + +func TestCollectorPatterns(t *testing.T) { + t.Parallel() + + directory := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(directory, "first.txt"), []byte("first"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(directory, "second.log"), []byte("second"), 0o600)) + + for _, tc := range []struct { + name string + pattern string + count int + }{ + {name: "all files", pattern: "*", count: 2}, + {name: "extension", pattern: "*.txt", count: 1}, + {name: "case insensitive", pattern: "FIRST.TXT", count: 1}, + {name: "missing file", pattern: "missing.txt", count: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + metrics := testutils.TestCollector(t, file.New, &file.Config{ + FilePatterns: []string{filepath.Join(directory, tc.pattern)}, + }) + if tc.count == 0 { + require.Empty(t, metrics) + + return + } + + require.Contains(t, metrics, "windows_file_size_bytes") + require.Len(t, metrics["windows_file_size_bytes"].GetMetric(), tc.count) + }) + } } diff --git a/internal/collector/fsrmquota/fsrmquota_test.go b/internal/collector/fsrmquota/fsrmquota_test.go index d40d9f30..8c752f73 100644 --- a/internal/collector/fsrmquota/fsrmquota_test.go +++ b/internal/collector/fsrmquota/fsrmquota_test.go @@ -22,6 +22,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/fsrmquota" "github.com/prometheus-community/windows_exporter/internal/utils/testutils" + "github.com/prometheus/client_golang/prometheus" ) func BenchmarkCollector(b *testing.B) { @@ -29,5 +30,6 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - testutils.TestCollector(t, fsrmquota.New, nil) + metrics := testutils.TestCollector(t, fsrmquota.New, nil) + testutils.RequireFixtureMetric(t, metrics, fsrmquota.Name, "windows_fsrmquota_size_bytes", prometheus.Labels{"path": `C:\fsrm-ci`}) } diff --git a/internal/collector/hyperv/hyperv_test.go b/internal/collector/hyperv/hyperv_test.go index 2f73234e..d34f4923 100644 --- a/internal/collector/hyperv/hyperv_test.go +++ b/internal/collector/hyperv/hyperv_test.go @@ -22,6 +22,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/hyperv" "github.com/prometheus-community/windows_exporter/internal/utils/testutils" + "github.com/prometheus/client_golang/prometheus" ) func BenchmarkCollector(b *testing.B) { @@ -29,5 +30,6 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - testutils.TestCollector(t, hyperv.New, nil) + metrics := testutils.TestCollector(t, hyperv.New, nil) + testutils.RequireFixtureMetric(t, metrics, hyperv.Name, "windows_hyperv_dynamic_memory_vm_physical_bytes", prometheus.Labels{"vm": "GitHubActions"}) } diff --git a/internal/collector/iis/iis_bench_test.go b/internal/collector/iis/iis_bench_test.go index 1763ae4a..984012f2 100644 --- a/internal/collector/iis/iis_bench_test.go +++ b/internal/collector/iis/iis_bench_test.go @@ -22,6 +22,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/iis" "github.com/prometheus-community/windows_exporter/internal/utils/testutils" + "github.com/prometheus/client_golang/prometheus" ) func BenchmarkCollector(b *testing.B) { @@ -29,5 +30,6 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - testutils.TestCollector(t, iis.New, nil) + metrics := testutils.TestCollector(t, iis.New, nil) + testutils.RequireFixtureMetric(t, metrics, iis.Name, "windows_iis_requests_total", prometheus.Labels{"site": "Default Web Site", "method": "get"}) } diff --git a/internal/collector/logical_disk/logical_disk_test.go b/internal/collector/logical_disk/logical_disk_test.go index 876de33d..230005f6 100644 --- a/internal/collector/logical_disk/logical_disk_test.go +++ b/internal/collector/logical_disk/logical_disk_test.go @@ -18,12 +18,15 @@ package logical_disk_test import ( + "os" + "regexp" "testing" "github.com/alecthomas/kingpin/v2" "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/stretchr/testify/require" ) func BenchmarkCollector(b *testing.B) { @@ -37,6 +40,46 @@ func BenchmarkCollector(b *testing.B) { func TestCollector(t *testing.T) { testutils.TestCollector(t, logical_disk.New, &logical_disk.Config{ - VolumeInclude: types.RegExpAny, + CollectorsEnabled: logical_disk.ConfigDefaults.CollectorsEnabled, + VolumeInclude: types.RegExpAny, }) } + +func TestCollectorVolumeFilters(t *testing.T) { + t.Parallel() + + systemDrive := os.Getenv("SystemDrive") + require.NotEmpty(t, systemDrive) + matchDrive := regexp.MustCompile("^" + regexp.QuoteMeta(systemDrive) + "$") + + for _, tc := range []struct { + name string + config logical_disk.Config + included bool + }{ + {name: "include system drive", config: logical_disk.Config{CollectorsEnabled: logical_disk.ConfigDefaults.CollectorsEnabled, VolumeInclude: matchDrive}, included: true}, + {name: "exclude system drive", config: logical_disk.Config{CollectorsEnabled: logical_disk.ConfigDefaults.CollectorsEnabled, VolumeInclude: types.RegExpAny, VolumeExclude: matchDrive}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + metrics := testutils.TestCollector(t, logical_disk.New, &tc.config) + if tc.included { + require.Contains(t, metrics, "windows_logical_disk_size_bytes") + require.Len(t, metrics["windows_logical_disk_size_bytes"].GetMetric(), 1) + } + + for _, metric := range metrics["windows_logical_disk_size_bytes"].GetMetric() { + for _, label := range metric.GetLabel() { + if label.GetName() == "volume" { + if tc.included { + require.Equal(t, systemDrive, label.GetValue()) + } else { + require.NotEqual(t, systemDrive, label.GetValue()) + } + } + } + } + }) + } +} diff --git a/internal/collector/mscluster/mscluster_test.go b/internal/collector/mscluster/mscluster_test.go index 64b41d7d..49f0e2ef 100644 --- a/internal/collector/mscluster/mscluster_test.go +++ b/internal/collector/mscluster/mscluster_test.go @@ -22,6 +22,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/mscluster" "github.com/prometheus-community/windows_exporter/internal/utils/testutils" + "github.com/prometheus/client_golang/prometheus" ) func BenchmarkCollector(b *testing.B) { @@ -29,5 +30,6 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - testutils.TestCollector(t, mscluster.New, nil) + metrics := testutils.TestCollector(t, mscluster.New, nil) + testutils.RequireFixtureMetric(t, metrics, mscluster.Name, "windows_mscluster_virtualdisk_size_bytes", prometheus.Labels{"name": "CIVirtualDisk"}) } diff --git a/internal/collector/netframework/netframework_test.go b/internal/collector/netframework/netframework_test.go index 81402629..28fc5582 100644 --- a/internal/collector/netframework/netframework_test.go +++ b/internal/collector/netframework/netframework_test.go @@ -30,7 +30,5 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - t.Skip("Skipping test as it requires WMI data") - testutils.TestCollector(t, netframework.New, nil) } diff --git a/internal/collector/os/os_test.go b/internal/collector/os/os_test.go index e00f5607..a7ea4cff 100644 --- a/internal/collector/os/os_test.go +++ b/internal/collector/os/os_test.go @@ -29,7 +29,5 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - t.Skip() - testutils.TestCollector(t, os.New, nil) } diff --git a/internal/collector/performancecounter/performancecounter.go b/internal/collector/performancecounter/performancecounter.go index 40b896b6..b9bf998e 100644 --- a/internal/collector/performancecounter/performancecounter.go +++ b/internal/collector/performancecounter/performancecounter.go @@ -119,7 +119,7 @@ func (c *Collector) GetName() string { } func (c *Collector) Close() error { - for _, object := range c.config.Objects { + for _, object := range c.objects { object.collector.Close() } diff --git a/internal/collector/performancecounter/performancecounter_test_test.go b/internal/collector/performancecounter/performancecounter_test_test.go index acd7c178..dfdd77cd 100644 --- a/internal/collector/performancecounter/performancecounter_test_test.go +++ b/internal/collector/performancecounter/performancecounter_test_test.go @@ -188,6 +188,8 @@ windows_performancecounter_processor_information_processor_time\{core="0,0",stat }, }) + t.Cleanup(func() { require.NoError(t, perfDataCollector.Close()) }) + logger := slog.New(slog.DiscardHandler) err := perfDataCollector.Build(logger, nil) @@ -203,7 +205,8 @@ windows_performancecounter_processor_information_processor_time\{core="0,0",stat registry.MustRegister(collectorAdapter{*perfDataCollector}) rw := httptest.NewRecorder() - promhttp.HandlerFor(registry, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError}).ServeHTTP(rw, &http.Request{}) + promhttp.HandlerFor(registry, promhttp.HandlerOpts{}).ServeHTTP(rw, &http.Request{}) + require.Equal(t, http.StatusOK, rw.Code) got := rw.Body.String() require.NotEmpty(t, got) @@ -212,3 +215,23 @@ windows_performancecounter_processor_information_processor_time\{core="0,0",stat }) } } + +func TestCollectorClose(t *testing.T) { + t.Parallel() + + c := performancecounter.New(&performancecounter.Config{ + Objects: []performancecounter.Object{{ + Name: "memory", Object: "Memory", + Counters: []performancecounter.Counter{{Name: "Available Bytes", Type: "gauge"}}, + }}, + }) + require.NoError(t, c.Build(slog.New(slog.DiscardHandler), nil)) + t.Cleanup(func() { require.NoError(t, c.Close()) }) + + metrics := make(chan prometheus.Metric, 10) + require.NoError(t, c.Collect(metrics, 0)) + require.NoError(t, c.Close()) + + // A closed collector must no longer hold usable native counter queries. + require.ErrorIs(t, c.Collect(metrics, 0), pdh.ErrPerformanceCounterNotInitialized) +} diff --git a/internal/collector/printer/printer_test.go b/internal/collector/printer/printer_test.go index dac5c1cc..85d4ff14 100644 --- a/internal/collector/printer/printer_test.go +++ b/internal/collector/printer/printer_test.go @@ -23,6 +23,7 @@ import ( "github.com/alecthomas/kingpin/v2" "github.com/prometheus-community/windows_exporter/internal/collector/printer" "github.com/prometheus-community/windows_exporter/internal/utils/testutils" + "github.com/prometheus/client_golang/prometheus" ) func BenchmarkCollector(b *testing.B) { @@ -35,5 +36,6 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - testutils.TestCollector(t, printer.New, nil) + metrics := testutils.TestCollector(t, printer.New, nil) + testutils.RequireFixtureMetric(t, metrics, printer.Name, "windows_printer_job_count", prometheus.Labels{"printer": "CIPrinter"}) } diff --git a/internal/collector/smb/smb_test.go b/internal/collector/smb/smb_test.go index b8747969..66858884 100644 --- a/internal/collector/smb/smb_test.go +++ b/internal/collector/smb/smb_test.go @@ -22,6 +22,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/smb" "github.com/prometheus-community/windows_exporter/internal/utils/testutils" + "github.com/prometheus/client_golang/prometheus" ) func BenchmarkCollector(b *testing.B) { @@ -29,5 +30,6 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - testutils.TestCollector(t, smb.New, nil) + metrics := testutils.TestCollector(t, smb.New, nil) + testutils.RequireFixtureMetric(t, metrics, smb.Name, "windows_smb_server_shares_received_bytes_total", prometheus.Labels{"share": `\\*\CIShare`}) } diff --git a/internal/collector/smbclient/smbclient_test.go b/internal/collector/smbclient/smbclient_test.go index 1b0b9040..3d446202 100644 --- a/internal/collector/smbclient/smbclient_test.go +++ b/internal/collector/smbclient/smbclient_test.go @@ -22,6 +22,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/smbclient" "github.com/prometheus-community/windows_exporter/internal/utils/testutils" + "github.com/prometheus/client_golang/prometheus" ) func BenchmarkCollector(b *testing.B) { @@ -29,5 +30,6 @@ func BenchmarkCollector(b *testing.B) { } func TestCollector(t *testing.T) { - testutils.TestCollector(t, smbclient.New, nil) + metrics := testutils.TestCollector(t, smbclient.New, nil) + testutils.RequireFixtureMetric(t, metrics, smbclient.Name, "windows_smbclient_data_queue_seconds_total", prometheus.Labels{"share": "CIShare"}) } diff --git a/internal/collector/update/update.go b/internal/collector/update/update.go index 95f45833..89a7360a 100644 --- a/internal/collector/update/update.go +++ b/internal/collector/update/update.go @@ -111,17 +111,6 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { c.logger.Info("update collector is in an experimental state! The configuration and metrics may change in future. Please report any issues.") - ctx, cancel := context.WithCancel(context.Background()) - - initErrCh := make(chan error, 1) - go c.scheduleUpdateStatus(ctx, logger, initErrCh, c.config.Online) - - c.ctxCancelFn = cancel - - if err := <-initErrCh; err != nil { - return fmt.Errorf("failed to initialize Windows Update collector: %w", err) - } - c.pendingUpdate = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "pending_info"), "Expose information for a single pending update item", @@ -150,6 +139,17 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { nil, ) + ctx, cancel := context.WithCancel(context.Background()) + + initErrCh := make(chan error, 1) + go c.scheduleUpdateStatus(ctx, logger, initErrCh, c.config.Online) + + c.ctxCancelFn = cancel + + if err := <-initErrCh; err != nil { + return fmt.Errorf("failed to initialize Windows Update collector: %w", err) + } + return nil } @@ -279,14 +279,13 @@ func (c *Collector) scheduleUpdateStatus(ctx context.Context, logger *slog.Logge c.mu.Lock() c.metricsBuf = nil c.mu.Unlock() - - continue + } else { + c.mu.Lock() + c.metricsBuf = metricsBuf + c.mu.Unlock() } - c.mu.Lock() - c.metricsBuf = metricsBuf - c.mu.Unlock() - + // Failed searches also observe the interval and cancellation. select { case <-time.After(c.config.ScrapeInterval): case <-ctx.Done(): diff --git a/internal/config/config.go b/internal/config/config.go index 6f7c99c4..0cb10ce4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -104,12 +104,12 @@ func ParseConfigFile(args []string) string { return configFile } - if strings.HasSuffix(cliFlag, "-config.file") { - if len(os.Args) <= i+1 { + if cliFlag == "--config.file" || cliFlag == "-config.file" { + if len(args) <= i+1 { return "" } - return os.Args[i+1] + return args[i+1] } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 00000000..d4a11974 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/alecthomas/kingpin/v2" + "github.com/prometheus-community/windows_exporter/internal/config" + "github.com/stretchr/testify/require" +) + +func TestParseConfigFile(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + args []string + want string + }{ + {name: "absent"}, + {name: "long joined", args: []string{"--config.file=test.yaml"}, want: "test.yaml"}, + {name: "short joined", args: []string{"-config.file=test.yaml"}, want: "test.yaml"}, + {name: "long separate", args: []string{"--config.file", "test.yaml"}, want: "test.yaml"}, + {name: "short separate", args: []string{"-config.file", "test.yaml"}, want: "test.yaml"}, + {name: "after other flags", args: []string{"--log.level=debug", "--config.file", "test.yaml"}, want: "test.yaml"}, + {name: "missing value", args: []string{"--config.file"}}, + {name: "empty value", args: []string{"--config.file="}}, + {name: "unrelated suffix", args: []string{"--other-config.file", "test.yaml"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, config.ParseConfigFile(tc.args)) + }) + } +} + +func TestNewConfigFileResolver(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + contents string + errorText string + }{ + {name: "empty"}, + {name: "valid", contents: "log:\n level: debug\nweb:\n listen-address: ['127.0.0.1:9182', '[::1]:9182']\n"}, + {name: "unknown key", contents: "unknown: value", errorText: "configuration file validation error"}, + {name: "unknown collector option", contents: "collector:\n cpu:\n unknown: true\n", errorText: "configuration file validation error"}, + {name: "malformed YAML", contents: "log: [", errorText: "configuration file validation error"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(tc.contents), 0o600)) + + resolver, err := config.NewConfigFileResolver(path) + if tc.errorText != "" { + require.ErrorContains(t, err, tc.errorText) + + return + } + + require.NoError(t, err) + require.NotNil(t, resolver) + }) + } + + t.Run("missing file", func(t *testing.T) { + t.Parallel() + _, err := config.NewConfigFileResolver(filepath.Join(t.TempDir(), "missing.yaml")) + require.ErrorContains(t, err, "failed to open configuration file") + }) +} + +func TestParse(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + extraArgs []string + want string + errorText string + }{ + {name: "config defaults", want: "debug"}, + {name: "CLI overrides config", extraArgs: []string{"--log.level=error"}, want: "error"}, + {name: "invalid CLI flag", extraArgs: []string{"--unknown"}, errorText: "failed to bind configuration"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("log:\n level: debug\n"), 0o600)) + + app := kingpin.New("test", "test") + app.Flag("config.file", "config").String() + level := app.Flag("log.level", "level").Default("info").String() + + args := append([]string{"--config.file=" + path}, tc.extraArgs...) + + err := config.Parse(app, args) + if tc.errorText != "" { + require.ErrorContains(t, err, tc.errorText) + + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, *level) + }) + } +} diff --git a/internal/httphandler/health_test.go b/internal/httphandler/health_test.go new file mode 100644 index 00000000..bd447098 --- /dev/null +++ b/internal/httphandler/health_test.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package httphandler_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/prometheus-community/windows_exporter/internal/httphandler" + "github.com/stretchr/testify/require" +) + +func TestHealthHandler(t *testing.T) { + t.Parallel() + + response := httptest.NewRecorder() + httphandler.NewHealthHandler().ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/health", nil)) + require.Equal(t, http.StatusOK, response.Code) + require.Equal(t, "application/json", response.Header().Get("Content-Type")) + require.JSONEq(t, `{"status":"ok"}`, response.Body.String()) +} diff --git a/internal/httphandler/httphandler_test.go b/internal/httphandler/httphandler_test.go new file mode 100644 index 00000000..3f1be0bf --- /dev/null +++ b/internal/httphandler/httphandler_test.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package httphandler_test + +import ( + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "testing/synctest" + "time" + + "github.com/prometheus-community/windows_exporter/internal/httphandler" + "github.com/prometheus-community/windows_exporter/internal/mi" + "github.com/prometheus-community/windows_exporter/internal/types" + "github.com/prometheus-community/windows_exporter/pkg/collector" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" +) + +func TestMetricsHTTPHandler(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + query string + options *httphandler.Options + status int + included []string + excluded []string + }{ + {name: "default", status: http.StatusOK, included: []string{"windows_test_first 42", "windows_test_second 42", "go_goroutines", "windows_exporter_build_info"}}, + {name: "disable exporter metrics", options: &httphandler.Options{DisableExporterMetrics: true, TimeoutMargin: 0.5}, status: http.StatusOK, included: []string{"windows_test_first 42", "windows_exporter_build_info"}, excluded: []string{"go_goroutines", "process_cpu_seconds_total"}}, + {name: "filter", query: "?collect[]=first", status: http.StatusOK, included: []string{"windows_test_first 42"}, excluded: []string{"windows_test_second"}}, + {name: "multiple collectors", query: "?collect[]=first&collect[]=second", status: http.StatusOK, included: []string{"windows_test_first 42", "windows_test_second 42"}}, + {name: "unknown collector", query: "?collect[]=missing", status: http.StatusBadRequest, included: []string{"unknown collector missing"}}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + collection := collector.New(collector.Map{ + "first": &testCollector{name: "first"}, + "second": &testCollector{name: "second"}, + }) + handler := httphandler.New(slog.New(slog.DiscardHandler), collection, tc.options) + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics"+tc.query, nil) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + require.Equal(t, tc.status, response.Code) + + for _, value := range tc.included { + require.Contains(t, response.Body.String(), value) + } + + for _, value := range tc.excluded { + require.NotContains(t, response.Body.String(), value) + } + + // Filtering one request must not remove collectors from later requests. + if tc.query == "?collect[]=first" { + response = httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics", nil)) + require.Equal(t, http.StatusOK, response.Code) + require.Contains(t, response.Body.String(), "windows_test_second 42") + } + }) + } +} + +func TestMetricsHTTPHandlerCollectorStatus(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + err error + panicValue bool + success string + }{ + {name: "success", success: "1"}, + {name: "failure", err: errors.New("collector failed"), success: "0"}, + {name: "no data", err: types.ErrNoData, success: "1"}, + {name: "panic", panicValue: true, success: "0"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + collection := collector.New(collector.Map{"test": &testCollector{name: "test", err: tc.err, panicValue: tc.panicValue}}) + handler := httphandler.New(slog.New(slog.DiscardHandler), collection, &httphandler.Options{DisableExporterMetrics: true}) + response := httptest.NewRecorder() + handler.ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics", nil)) + + require.Equal(t, http.StatusOK, response.Code) + require.Contains(t, response.Body.String(), `windows_exporter_collector_success{collector="test"} `+tc.success) + require.Contains(t, response.Body.String(), `windows_exporter_collector_timeout{collector="test"} 0`) + }) + } +} + +func TestMetricsHTTPHandlerScrapeTimeout(t *testing.T) { + for _, tc := range []struct { + name string + header string + timeout bool + }{ + {name: "missing header"}, + {name: "invalid header", header: "invalid"}, + {name: "zero header", header: "0"}, + {name: "sufficient timeout", header: "2"}, + {name: "timeout", header: "0.1", timeout: true}, + } { + t.Run(tc.name, func(t *testing.T) { + // The collector's delay and deadline use synthetic time. + synctest.Test(t, func(t *testing.T) { + collection := collector.New(collector.Map{"test": &testCollector{name: "test", delay: time.Second}}) + handler := httphandler.New(slog.New(slog.DiscardHandler), collection, &httphandler.Options{DisableExporterMetrics: true}) + request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics", nil) + request.Header.Set("X-Prometheus-Scrape-Timeout-Seconds", tc.header) + + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + + require.Equal(t, http.StatusOK, response.Code) + + if tc.timeout { + require.Contains(t, response.Body.String(), `windows_exporter_collector_timeout{collector="test"} 1`) + require.NotContains(t, response.Body.String(), "windows_test_test 42") + + // Finish the delayed collector and its drain goroutine before leaving the bubble. + time.Sleep(time.Second) + synctest.Wait() + } else { + require.Contains(t, response.Body.String(), `windows_exporter_collector_timeout{collector="test"} 0`) + require.Contains(t, response.Body.String(), "windows_test_test 42") + } + }) + }) + } +} + +type testCollector struct { + name string + err error + panicValue bool + delay time.Duration +} + +func (c *testCollector) GetName() string { return c.name } +func (c *testCollector) Build(_ *slog.Logger, _ *mi.Session) error { return nil } +func (c *testCollector) Close() error { return nil } +func (c *testCollector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error { + if c.panicValue { + panic("test collector panic") + } + + time.Sleep(c.delay) + + if c.err != nil { + return c.err + } + + ch <- prometheus.MustNewConstMetric(prometheus.NewDesc("windows_test_"+c.name, "Test metric", nil, nil), prometheus.GaugeValue, 42) + + return nil +} diff --git a/internal/httphandler/version_test.go b/internal/httphandler/version_test.go new file mode 100644 index 00000000..d7ccfed6 --- /dev/null +++ b/internal/httphandler/version_test.go @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package httphandler_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/prometheus-community/windows_exporter/internal/httphandler" + "github.com/prometheus/common/version" + "github.com/stretchr/testify/require" +) + +func TestVersionHandler(t *testing.T) { + t.Parallel() + + response := httptest.NewRecorder() + httphandler.NewVersionHandler().ServeHTTP(response, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/version", nil)) + require.Equal(t, http.StatusOK, response.Code) + + var got map[string]string + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &got)) + require.Equal(t, map[string]string{ + "version": version.Version, "revision": version.Revision, + "branch": version.Branch, "buildUser": version.BuildUser, + "buildDate": version.BuildDate, "goVersion": version.GoVersion, + }, got) +} diff --git a/internal/utils/testutils/testutils.go b/internal/utils/testutils/testutils.go index be522fba..126ea127 100644 --- a/internal/utils/testutils/testutils.go +++ b/internal/utils/testutils/testutils.go @@ -21,6 +21,8 @@ import ( "errors" "log/slog" "os" + "slices" + "strings" "sync" "testing" "time" @@ -29,8 +31,11 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/update" "github.com/prometheus-community/windows_exporter/internal/mi" "github.com/prometheus-community/windows_exporter/internal/pdh" + "github.com/prometheus-community/windows_exporter/internal/types" "github.com/prometheus-community/windows_exporter/pkg/collector" "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sys/windows" ) @@ -52,81 +57,206 @@ func FuncBenchmarkCollector[C collector.Collector](b *testing.B, name string, co metrics := make(chan prometheus.Metric) - go func() { - for { - <-metrics + var wg sync.WaitGroup + wg.Go(func() { + for range metrics { } - }() + }) + b.Cleanup(func() { + close(metrics) + wg.Wait() + assert.NoError(b, collectors.Close()) + }) for b.Loop() { require.NoError(b, c.Collect(metrics, 0)) } - - require.NoError(b, collectors.Close()) } -func TestCollector[C collector.Collector, V any](t *testing.T, fn func(*V) C, conf *V) { +// TestCollector validates real Windows collector output. CI lists provisioned +// collectors in WINDOWS_EXPORTER_TEST_COLLECTORS so setup failures cannot skip. +func TestCollector[C collector.Collector, V any](t *testing.T, fn func(*V) C, conf *V) map[string]*dto.MetricFamily { t.Helper() - var ( - metrics []prometheus.Metric - err error - ) - logger := slog.New(slog.DiscardHandler) c := fn(conf) - ch := make(chan prometheus.Metric, 10000) + required := slices.Contains(strings.Split(os.Getenv("WINDOWS_EXPORTER_TEST_COLLECTORS"), ","), c.GetName()) miApp, err := mi.ApplicationInitialize() require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, miApp.Close()) }) miSession, err := miApp.NewSession(nil) require.NoError(t, err) + t.Cleanup(func() { assert.NoError(t, miSession.Close()) }) + t.Cleanup(func() { assert.NoError(t, c.Close()) }) - t.Cleanup(func() { - require.NoError(t, c.Close()) - require.NoError(t, miSession.Close()) - require.NoError(t, miApp.Close()) - }) + if err := c.Build(logger, miSession); err != nil { + if !required && unsupportedCollector(err) { + t.Skipf("collector %s is not supported: %v", c.GetName(), err) + } - wg := sync.WaitGroup{} + require.NoError(t, err, "build %s", c.GetName()) + } + + // PDH rate counters need a second sample after initialization. + time.Sleep(time.Second) + + var families map[string]*dto.MetricFamily + for scrape := range 2 { + families = collectMetrics(t, c, required) + t.Logf("%s scrape %d: %d metric families", c.GetName(), scrape+1, len(families)) + } + + return families +} + +func unsupportedCollector(err error) bool { + return errors.Is(err, mi.MI_RESULT_INVALID_NAMESPACE) || + errors.Is(err, mi.MI_RESULT_INVALID_QUERY) || + errors.Is(err, pdh.NewPdhError(pdh.CstatusNoCounter)) || + errors.Is(err, pdh.NewPdhError(pdh.CstatusNoObject)) || + errors.Is(err, pdh.ErrPerformanceCounterNotInitialized) || + errors.Is(err, pdh.ErrNoData) || + errors.Is(err, update.ErrUpdateServiceDisabled) || + errors.Is(err, os.ErrNotExist) || + errors.Is(err, windows.Errno(2151088411)) +} + +func collectMetrics(t *testing.T, c collector.Collector, required bool) map[string]*dto.MetricFamily { + t.Helper() + + var ( + metrics collectedMetrics + collectErr error + ) + + ch := make(chan prometheus.Metric) + + var wg sync.WaitGroup wg.Go(func() { for metric := range ch { metrics = append(metrics, metric) } }) - err = c.Build(logger, miSession) + // Stop the receiver even if Collect panics or an assertion ends the test. + func() { + defer func() { + close(ch) + wg.Wait() + }() - switch { - case err == nil: - case errors.Is(err, mi.MI_RESULT_INVALID_NAMESPACE), - errors.Is(err, pdh.NewPdhError(pdh.CstatusNoCounter)), - errors.Is(err, pdh.NewPdhError(pdh.CstatusNoObject)), - errors.Is(err, update.ErrUpdateServiceDisabled), - errors.Is(err, os.ErrNotExist): - default: - require.NoError(t, err) + err := c.Collect(ch, 30*time.Second) + if errors.Is(err, update.ErrNoUpdates) && required { + deadline := time.Now().Add(time.Minute) + for errors.Is(err, update.ErrNoUpdates) && time.Now().Before(deadline) { + time.Sleep(100 * time.Millisecond) + + err = c.Collect(ch, 30*time.Second) + } + } + + collectErr = err + }() + + // Empty optional instance groups may report no data alongside valid metrics. + // Inspect every joined error so an unrelated failure cannot be hidden. + if noDataOnly(collectErr) && len(metrics) > 0 { + t.Logf("collector %s has empty optional instance groups: %v", c.GetName(), collectErr) + collectErr = nil } - time.Sleep(1 * time.Second) - - err = c.Collect(ch, 0) - - switch { - // container collector - case errors.Is(err, windows.Errno(2151088411)), - errors.Is(err, pdh.ErrPerformanceCounterNotInitialized), - errors.Is(err, pdh.ErrNoData), - errors.Is(err, mi.MI_RESULT_INVALID_NAMESPACE), - errors.Is(err, mi.MI_RESULT_INVALID_QUERY), - errors.Is(err, update.ErrNoUpdates): - t.Skip("collector not supported on this system") - default: - require.NoError(t, err) + if !required && (unsupportedCollector(collectErr) || errors.Is(collectErr, update.ErrNoUpdates)) { + t.Skipf("collector %s is not supported: %v", c.GetName(), collectErr) } - close(ch) + require.NoError(t, collectErr, "collect %s", c.GetName()) - wg.Wait() + if required { + require.NotEmpty(t, metrics, "provisioned collector %s emitted no metrics", c.GetName()) + } + + registry := prometheus.NewPedanticRegistry() + require.NoError(t, registry.Register(metrics)) + gathered, err := registry.Gather() + require.NoError(t, err, "invalid metrics from %s", c.GetName()) + + families := make(map[string]*dto.MetricFamily, len(gathered)) + for _, family := range gathered { + families[family.GetName()] = family + if strings.HasSuffix(family.GetName(), "_collector_success") { + for _, metric := range family.GetMetric() { + require.InDelta(t, 1, metric.GetGauge().GetValue(), 0, "failed child collector: %s", metric) + } + } + } + + return families +} + +type collectedMetrics []prometheus.Metric + +func (m collectedMetrics) Describe(ch chan<- *prometheus.Desc) { + prometheus.DescribeByCollect(m, ch) +} + +func (m collectedMetrics) Collect(ch chan<- prometheus.Metric) { + for _, metric := range m { + ch <- 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) { + t.Helper() + + if !slices.Contains(strings.Split(os.Getenv("WINDOWS_EXPORTER_TEST_COLLECTORS"), ","), collectorName) { + return + } + + require.Contains(t, families, metricName) + + for _, metric := range families[metricName].GetMetric() { + matched := true + + for name, value := range labels { + found := false + + for _, label := range metric.GetLabel() { + if label.GetName() == name && strings.EqualFold(label.GetValue(), value) { + found = true + + break + } + } + + matched = matched && found + } + + if matched { + return + } + } + + t.Fatalf("metric %s with fixture labels %v was not emitted; got %s", metricName, labels, families[metricName]) +} + +func noDataOnly(err error) bool { + if joined, ok := err.(interface{ Unwrap() []error }); ok { + for _, cause := range joined.Unwrap() { + if !noDataOnly(cause) { + return false + } + } + + return true + } + + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + return noDataOnly(wrapped.Unwrap()) + } + + return errors.Is(err, pdh.ErrNoData) || errors.Is(err, types.ErrNoData) } diff --git a/internal/utils/testutils/testutils_test.go b/internal/utils/testutils/testutils_test.go new file mode 100644 index 00000000..bf400462 --- /dev/null +++ b/internal/utils/testutils/testutils_test.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package testutils + +import ( + "errors" + "fmt" + "testing" + + "github.com/prometheus-community/windows_exporter/internal/pdh" + "github.com/prometheus-community/windows_exporter/internal/types" + "github.com/stretchr/testify/require" +) + +func TestNoDataOnly(t *testing.T) { + t.Parallel() + + failure := errors.New("collector failed") + for _, tc := range []struct { + name string + err error + want bool + }{ + {name: "nil"}, + {name: "PDH no data", err: pdh.ErrNoData, want: true}, + {name: "collector no data", err: types.ErrNoData, want: true}, + {name: "wrapped", err: fmt.Errorf("empty instances: %w", pdh.ErrNoData), want: true}, + {name: "joined empty groups", err: errors.Join(pdh.ErrNoData, types.ErrNoData), want: true}, + {name: "failure", err: failure}, + {name: "mixed failures", err: errors.Join(pdh.ErrNoData, failure)}, + {name: "nested mixed failures", err: fmt.Errorf("child: %w", errors.Join(pdh.ErrNoData, failure))}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, noDataOnly(tc.err)) + }) + } +} diff --git a/pkg/collector/collection_test.go b/pkg/collector/collection_test.go new file mode 100644 index 00000000..56e80bf9 --- /dev/null +++ b/pkg/collector/collection_test.go @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package collector_test + +import ( + "errors" + "log/slog" + "testing" + "time" + + "github.com/alecthomas/kingpin/v2" + "github.com/prometheus-community/windows_exporter/internal/mi" + "github.com/prometheus-community/windows_exporter/internal/pdh" + "github.com/prometheus-community/windows_exporter/pkg/collector" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" +) + +func TestCollectionConstructors(t *testing.T) { + t.Parallel() + + t.Run("config includes every collector", func(t *testing.T) { + t.Parallel() + + collection := collector.NewWithConfig(collector.Config{}) + _, err := collection.NewHandler(time.Second, slog.New(slog.DiscardHandler), collector.Available()) + require.NoError(t, err) + }) + + t.Run("flags include every collector", func(t *testing.T) { + t.Parallel() + + app := kingpin.New("test", "test") + collection := collector.NewWithFlags(app) + _, err := app.Parse(nil) + require.NoError(t, err) + _, err = collection.NewHandler(time.Second, slog.New(slog.DiscardHandler), collector.Available()) + require.NoError(t, err) + }) +} + +func TestCollectionBuildAndClose(t *testing.T) { + t.Parallel() + + buildFailure := errors.New("build failed") + + closeFailure := errors.New("close failed") + for _, tc := range []struct { + name string + buildErr error + closeErr error + wantBuildErr error + }{ + {name: "success"}, + {name: "build failure", buildErr: buildFailure, wantBuildErr: buildFailure}, + {name: "optional counter missing", buildErr: pdh.ErrNoData}, + {name: "close failure", closeErr: closeFailure}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + test := &lifecycleCollector{name: "test", buildErr: tc.buildErr, closeErr: tc.closeErr} + collection := collector.New(collector.Map{"test": test}) + err := collection.Build(t.Context(), slog.New(slog.DiscardHandler)) + t.Cleanup(func() { + err := collection.Close() + if tc.closeErr != nil { + require.ErrorIs(t, err, tc.closeErr) + } else { + require.NoError(t, err) + } + + require.True(t, test.closed) + }) + + if tc.wantBuildErr != nil { + require.ErrorIs(t, err, tc.wantBuildErr) + } else { + require.NoError(t, err) + } + }) + } +} + +type lifecycleCollector struct { + name string + buildErr error + closeErr error + closed bool +} + +func (c *lifecycleCollector) GetName() string { return c.name } + +func (c *lifecycleCollector) Build(_ *slog.Logger, _ *mi.Session) error { return c.buildErr } + +func (c *lifecycleCollector) Collect(_ chan<- prometheus.Metric, _ time.Duration) error { return nil } + +func (c *lifecycleCollector) Close() error { + c.closed = true + + return c.closeErr +} diff --git a/renovate.json b/renovate.json index 03f452c9..5e06f799 100644 --- a/renovate.json +++ b/renovate.json @@ -15,23 +15,108 @@ "ignorePaths": ["**/workflows/container_description.yml","**/workflows/stale.yml","**/workflows/govulncheck.yml"], "packageRules": [ { - "matchPackageNames": ["https://github.com/yaml/go-yaml"], - "matchUpdateTypes": ["major"], + "enabled": true, + "matchDatasources": ["golang-version"], + "rangeStrategy": "bump" + }, + { + "enabled": true, + "matchDepNames": ["go", "golang"], + "groupName": "go version" + }, + { + "enabled": true, + "matchCategories": ["go"], + "matchUpdateTypes": ["digest"] + }, + { + "groupName": "golangci-lint", + "matchPackageNames": [ + "github.com/golangci/golangci-lint/cmd/golangci-lint", + "golangci/golangci-lint", + "golangci/golangci-lint-action" + ], + "addLabels": ["chore"] + }, + { + "matchPackageNames": ["gopkg.in/yaml"], "enabled": false }, { + "matchManagers": ["gomod"], + "matchDepTypes": ["indirect"], + "major": {"enabled": false}, + "enabled": true + }, + { + "groupName": "GitHub Actions", + "matchManagers": ["github-actions"], + "matchDatasources": ["!/github-runners$/"], + "semanticCommitScope": "github-actions", + "separateMajorMinor": false, + "addLabels": ["chore"] + }, + { + "groupName": "GitHub Runners", + "matchManagers": ["github-actions"], + "matchDatasources": ["github-runners"], + "semanticCommitScope": "github-actions", + "separateMajorMinor": false, + "addLabels": ["chore"] + }, + { + "groupName": "moby monorepo", + "matchManagers": ["gomod"], + "matchPackageNames": ["github.com/moby/moby", "github.com/moby/moby/**"] + }, + { + "groupName": "golang.org/x", + "matchPackageNames": ["golang.org/x/**"] + }, + { + "groupName": "googleapis", + "matchPackageNames": ["google.golang.org/genproto/googleapis/**"] + }, + { + "groupName": "Go tools", + "matchFileNames": ["tools/**", "Makefile"], + "matchPackageNames": ["*"], + "addLabels": ["chore"] + }, + { + "matchPackageNames": ["golang.org/x/net"], + "sourceUrl": "https://github.com/golang/net" + }, + { + "matchPackageNames": ["golang.org/x/oauth2"], + "sourceUrl": "https://github.com/golang/oauth2" + }, + { + "matchPackageNames": ["golang.org/x/sys"], + "sourceUrl": "https://github.com/golang/sys" + }, + { + "matchPackageNames": ["golang.org/x/text"], + "sourceUrl": "https://github.com/golang/text" + }, + { + "enabled": false, + "matchPackageNames": ["https://github.com/yaml/go-yaml"], + "matchUpdateTypes": ["major"] + }, + { + "enabled": false, "matchBaseBranches": ["/^[0-9]\\.[0-9][0-9]$/"], "matchUpdateTypes": [ "major" - ], - "enabled": false + ] }, { + "enabled": false, "matchBaseBranches": ["/^[0-9]\\.[0-9][0-9]$/"], "matchManagers": [ "github-actions" - ], - "enabled": false + ] }, { "enabled": false, @@ -41,45 +126,6 @@ "golangci/golangci-lint", "golangci/golangci-lint-action" ] - }, - { - "groupName": "golangci-lint", - "matchPackageNames": [ - "github.com/golangci/golangci-lint/cmd/golangci-lint", - "golangci/golangci-lint", - "golangci/golangci-lint-action" - ] - }, - { - "matchManagers": [ - "gomod" - ], - "matchDepTypes": [ - "indirect" - ], - "enabled": true - }, - { - "groupName": "GitHub Actions", - "matchManagers": [ - "github-actions" - ] - }, - { - "groupName": "golang.org/x/", - "matchPackageNames": [ - "golang.org/x/*" - ] - }, - { - "groupName": "Go tools", - "matchFileNames": [ - "tools/**", - "Makefile" - ], - "matchPackageNames": [ - "*" - ] } ], "postUpdateOptions": [ diff --git a/tools/end-to-end-test.ps1 b/tools/end-to-end-test.ps1 index f70030f7..454c79bc 100644 --- a/tools/end-to-end-test.ps1 +++ b/tools/end-to-end-test.ps1 @@ -1,85 +1,41 @@ $ErrorActionPreference = 'Stop' Set-StrictMode -Version 3 -# cd to location of script -$script_path = $MyInvocation.MyCommand.Path -$working_dir = Split-Path $script_path -Push-Location $working_dir +$binary = Join-Path $PSScriptRoot '..\windows_exporter.exe' +$logDir = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP } +$stdout = Join-Path $logDir 'windows_exporter.stdout.log' +$stderr = Join-Path $logDir 'windows_exporter.stderr.log' +$exporter = Start-Process -FilePath $binary -PassThru ` + -ArgumentList '--web.listen-address=127.0.0.1:9182' ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr -if (-not (Test-Path -Path '..\windows_exporter.exe')) { - Write-Output "..\windows_exporter.exe not found. Consider running \`go build\` first" -} +try { + $deadline = (Get-Date).AddSeconds(60) + do { + if ($exporter.HasExited) { + throw "windows_exporter exited with code $($exporter.ExitCode)" + } -$temp_dir = Join-Path $env:TEMP $([guid]::newguid()) | ForEach-Object { mkdir $_ } + $client = [System.Net.Sockets.TcpClient]::new() + try { + $client.Connect('127.0.0.1', 9182) + break + } catch { + if ((Get-Date) -ge $deadline) { throw 'windows_exporter did not start within 60 seconds' } + Start-Sleep -Milliseconds 200 + } finally { + $client.Dispose() + } + } while ($true) -# Create temporary directory for textfile collector -$textfile_dir = "$($temp_dir)/textfile" -mkdir $textfile_dir | Out-Null -Copy-Item 'e2e-textfile.prom' -Destination "$($textfile_dir)/e2e-textfile.prom" - -# Omit dynamic collector information that will change after each run -$skip_re = "^(go_|windows_exporter_build_info|windows_exporter_collector_duration_seconds|windows_exporter_scrape_duration_seconds|process_|windows_textfile_mtime_seconds|windows_cpu|windows_cache|windows_pagefile|windows_logical_disk|windows_physical_disk|windows_memory|windows_net|windows_os|windows_process|windows_service_process|windows_printer|windows_udp|windows_tcp|windows_system|windows_time|windows_session|windows_performancecounter|windows_performancecounter|windows_textfile_mtime_seconds)" - -# Start process in background, awaiting HTTP requests. -# Use default collectors, port and address: http://localhost:9182/metrics -$exporter_proc = Start-Process ` - -PassThru ` - -FilePath ..\windows_exporter.exe ` - -ArgumentList "--log.level=debug","--web.disable-exporter-metrics","--collectors.enabled=[defaults],cpu_info,textfile,process,pagefile,performancecounter,scheduled_task,tcp,udp,time,system,service,logical_disk,os,net,memory,cache","--collector.process.include=explorer.exe","--collector.scheduled_task.include=.*GAEvents","--collector.service.include=Themes","--collector.textfile.directories=$($textfile_dir)",@" ---collector.performancecounter.objects="[{\"name\":\"cpu\",\"object\":\"Processor Information\",\"instances\":[\"*\"],\"instance_label\":\"core\",\"counters\":[{\"name\":\"% Processor Time\",\"metric\":\"windows_performancecounter_processor_information_processor_time\",\"labels\":{\"state\":\"active\"}},{\"name\":\"% Idle Time\",\"metric\":\"windows_performancecounter_processor_information_processor_time\",\"labels\":{\"state\":\"idle\"}}]},{\"name\":\"memory\",\"object\":\"Memory\",\"counters\":[{\"name\":\"Cache Faults/sec\",\"type\":\"counter\"}]}]" -"@ ` - -WindowStyle Hidden ` - -RedirectStandardOutput "$($temp_dir)/windows_exporter.log" ` - -RedirectStandardError "$($temp_dir)/windows_exporter_error.log" - -# Exporter can take some time to start -for ($i=1; $i -le 1; $i++) { - Start-Sleep 10 - - $netstat_output = netstat -anp tcp | Select-String 'listening' - if ($netstat_output -like '*:9182*') { - break + # Metric validation belongs in Go tests. This is one binary smoke request. + $response = Invoke-WebRequest -UseBasicParsing -Uri 'http://127.0.0.1:9182/metrics' -TimeoutSec 30 + if ($response.StatusCode -ne 200 -or [string]::IsNullOrWhiteSpace($response.Content)) { + throw 'windows_exporter returned an unsuccessful or empty metrics response' } - Write-Host "Waiting for exporter to start" -} - -try { - $response = Invoke-WebRequest -UseBasicParsing -URI http://127.0.0.1:9182/metrics -} catch { - Write-Host "STDOUT" - Get-Content "$($temp_dir)/windows_exporter.log" - Write-Host "STDERR" - Get-Content "$($temp_dir)/windows_exporter_error.log" - - throw $_ -} -# Response output must be split and saved as UTF-8. -$response.content -split "[`r`n]"| Select-String -NotMatch $skip_re | Set-Content -Encoding utf8 "$($temp_dir)/e2e-output.txt" -try { - Stop-Process -Id $exporter_proc.Id -} catch { - Write-Host "STDOUT" - Get-Content "$($temp_dir)/windows_exporter.log" - Write-Host "STDERR" - Get-Content "$($temp_dir)/windows_exporter_error.log" - - throw $_ -} - -# Compare the expected and actual output -$output_diff = Compare-Object (Get-Content 'e2e-output.txt' | Where-Object { $_ -ne "" }) (Get-Content "$($temp_dir)/e2e-output.txt" | Where-Object { $_ -ne "" }) - -# Fail if differences in output are detected -if (-not ($null -eq $output_diff)) { - $output_diff | Format-Table -AutoSize | Out-String -Width 10000 - - Write-Host "STDOUT" - Get-Content "$($temp_dir)/windows_exporter.log" - Write-Host "----------------------------------------" - Write-Host "STDERR" - Get-Content "$($temp_dir)/windows_exporter_error.log" - - (Get-Content "$($temp_dir)/e2e-output.txt") | Set-Content -Encoding utf8 "e2e-output.txt" - - exit 1 + Write-Host "windows_exporter returned HTTP $($response.StatusCode)" +} finally { + if (-not $exporter.HasExited) { Stop-Process -Id $exporter.Id } + Get-Content $stdout -ErrorAction SilentlyContinue + Get-Content $stderr -ErrorAction SilentlyContinue } diff --git a/tools/promtool.ps1 b/tools/promtool.ps1 deleted file mode 100644 index c4657f52..00000000 --- a/tools/promtool.ps1 +++ /dev/null @@ -1,141 +0,0 @@ -$ErrorActionPreference = 'Stop' -Set-StrictMode -Version 3 - -if (-not (Test-Path -Path '.\windows_exporter.exe')) { - Write-Output ".\windows_exporter.exe not found. Consider running \`go build\` first" -} - -# Powershell pipes & Get-Content command rather unhelpfully add a carriage return at the end of the string, so -# passing the string as bytes is a messy but necessary workaround for processes that are sensitive to -# line endings, like promtool. -function Start-RawProcess { - param( - # String to pass to $CommandName via STDIN - [Parameter(Mandatory=$true)][String]$InputVar, - # Command to run - [Parameter(Mandatory=$true)][String]$CommandName, - # Arguments provided to $CommandName - [Parameter(Mandatory=$false)][String[]]$CommandArgs - ) - # Buffer & initial size of MemoryStream - $BufferSize = 4096 - - # Convert text to bytes and write to MemoryStream - [byte[]]$InputBytes = [Text.Encoding]::UTF8.GetBytes($InputVar) - $MemStream = New-Object -TypeName System.IO.MemoryStream -ArgumentList $BufferSize - $MemStream.Write($InputBytes, 0, $InputBytes.Length) - [Void]$MemStream.Seek(0, 'Begin') - - # Setup stdin\stdout redirection for our process - if ($CommandArgs) { - $StartInfo = New-Object -TypeName System.Diagnostics.ProcessStartInfo -Property @{ - FileName = $CommandName - UseShellExecute = $false - RedirectStandardInput = $true - RedirectStandardError = $true - Arguments = $CommandArgs - } - } else { - $StartInfo = New-Object -TypeName System.Diagnostics.ProcessStartInfo -Property @{ - FileName = $CommandName - UseShellExecute = $false - RedirectStandardInput = $true - RedirectStandardError = $true - } - } - - # Create new process - $Process = New-Object -TypeName System.Diagnostics.Process - - # Assign previously created StartInfo properties - $Process.StartInfo = $StartInfo - # Start process - [void]$Process.Start() - - # Pipe data - $Buffer = New-Object -TypeName byte[] -ArgumentList $BufferSize - $StdinStream = $Process.StandardInput.BaseStream - - try { - do { - $ReadCount = $MemStream.Read($Buffer, 0, $Buffer.Length) - $StdinStream.Write($Buffer, 0, $ReadCount) - $StdinStream.Flush() - } - while($ReadCount -gt 0) - } - catch - { - throw 'Error streaming buffer to STDIN' - } finally { - # Close streams - $StdinStream.Close() - $MemStream.Close() - } - $Process.WaitForExit() - if ($Process.ExitCode -ne 0) { - Write-Host $Process.StandardError.ReadToEnd() - } - - return $Process.ExitCode -} - -# cd to location of script -$script_path = $MyInvocation.MyCommand.Path -$working_dir = Split-Path $script_path -Push-Location $working_dir - -$temp_dir = Join-Path $env:TEMP $([guid]::newguid()) | ForEach-Object { mkdir $_ } - -# Start process in background, awaiting HTTP requests. -# Listen on 9183/TCP, preventing conflicts with 9182/TCP used by end-to-end-test.ps1 -# Not an issue when run individually, but will cause failures when run concurrently in CI. -$exporter_proc = Start-Process ` - -PassThru ` - -FilePath ..\windows_exporter.exe ` - -ArgumentList '--web.listen-address="127.0.0.1:9183" --log.level=debug --collectors.enabled=[defaults],cpu_info,textfile,process,scheduled_task'` - -WindowStyle Hidden ` - -RedirectStandardOutput "$($temp_dir)/windows_exporter.log" ` - -RedirectStandardError "$($temp_dir)/windows_exporter_error.log" - -# Exporter can take some time to start -for ($i=1; $i -le 5; $i++) { - Start-Sleep 10 - - $netstat_output = netstat -anp tcp | Select-String 'listening' - if ($netstat_output -like '*:9183*') { - break - } - Write-Host "Waiting for exporter to start" -} - -# Omit metrics from client_golang library; we're not responsible for these -# windows_memory_pool_nonpaged_allocs_total is wrong for years. It's not a gauge, but a counter. -$skip_re = "^([#]?\s*(HELP|TYPE)?\s*go_|windows_memory_pool_nonpaged_allocs_total)" - -try { - # Need to remove carriage returns, as promtool expects LF line endings - $output = ((Invoke-WebRequest -UseBasicParsing -URI http://127.0.0.1:9183/metrics).Content) -Split "`r?`n" | Select-String -NotMatch $skip_re | Join-String -Separator "`n" - # $output = (((Invoke-WebRequest -UseBasicParsing -URI http://127.0.0.1:9183/metrics).Content) -Split "`r?`n" | Select-String -NotMatch $skip_re) -join "`n" - # Join the split lines back to a single String (with LF line endings!) - $output = $output -Join "`n" - - Stop-Process -Id $exporter_proc.Id -} catch { - Write-Host "STDOUT" - Get-Content "$($temp_dir)/windows_exporter.log" - Write-Host "STDERR" - Get-Content "$($temp_dir)/windows_exporter_error.log" - - throw $_ -} - -$ExitCode = Start-RawProcess -InputVar $output -CommandName promtool.exe -CommandArgs @("check metrics") -if ($ExitCode -ne 0) { - Write-Host "OUTPUT" - - Write-Host $output - - Write-Host "Promtool command returned exit code $($ExitCode). See output for details." - EXIT 1 -}