From d25d2328f2431088ab88c48643f7448d4b09f3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Sat, 10 Oct 2026 18:15:14 +0200 Subject: [PATCH] ci: build Go test binaries while fixtures are provisioned (#2656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 Signed-off-by: Jan-Otto Kröpke --- .github/workflows/ci.yml | 86 ++++++++++++++++++++-------------------- docs/ci-testing.md | 22 +++++----- tools/build-go-tests.ps1 | 51 ++++++++++++++++++++++++ tools/run-go-tests.ps1 | 49 +++++++++++++++++++++++ tools/test-summary.ps1 | 64 ------------------------------ 5 files changed, 155 insertions(+), 117 deletions(-) create mode 100644 tools/build-go-tests.ps1 create mode 100644 tools/run-go-tests.ps1 delete mode 100644 tools/test-summary.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87e95abf..37ef562a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI -# Windows tests are gated on Go and workflow changes for pull requests and branch pushes. +# Windows tests are gated on Go, workflow and Go test script changes for pull requests and branch pushes. on: push: branches: @@ -56,7 +56,7 @@ jobs: exit 0 fi - changed_files=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- '*.go' '.github/workflows/ci.yml') + changed_files=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- '*.go' '.github/workflows/ci.yml' 'tools/build-go-tests.ps1' 'tools/run-go-tests.ps1') if [[ -n "$changed_files" ]]; then echo "changed=true" >> "$GITHUB_OUTPUT" else @@ -403,6 +403,14 @@ jobs: path: ${{ runner.temp }}/sql-server/setup key: sql-server-2025-express-fa7e1fabc9a2-${{ runner.os }} + # Its PATH changes apply only after a wait step includes it. + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + id: setup-go + background: true + with: + # renovate: golang=go + go-version: '1.27.2' + # Steps that do not register services run in the background while Windows features install. - name: Download SQL Server media id: download-mssql @@ -584,11 +592,15 @@ jobs: - name: Wait for downloads wait: [download-mssql, install-freerdp, download-container-prereqs] - # Install every Windows feature in one foreground step. Steps that register - # services, such as SQL Server setup, New-Cluster, and containerd, start only - # afterwards: changing service configuration during the install aborts its - # registry transaction, and the rollback then requires a restart. + # Install every Windows feature in one step. Steps that register services, + # such as SQL Server setup, New-Cluster, and containerd, start only after + # "Wait for Windows features": changing service configuration during the + # install aborts its registry transaction, and the rollback then requires + # a restart. - name: Install Windows features + id: install-windows-features + # Setting up Go and building the tests register no services, so they overlap the install. + background: true shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -627,6 +639,23 @@ jobs: Get-WindowsFeature -Name $features | Format-Table DisplayName, Name, InstallState + - name: Wait for Go + wait: [setup-go] + + # Linking the race-enabled test binaries takes minutes; do it while the + # fixtures below are provisioned instead of in the Go tests step. + - name: Build Go test binaries + id: build-go-tests + background: true + shell: pwsh + run: | + ./tools/build-go-tests.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP "go-tests") + go build -trimpath -o windows_exporter.exe ./cmd/windows_exporter + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Wait for Windows features + wait: [install-windows-features] + - name: Setup SQL Server Express id: setup-mssql background: true @@ -1566,14 +1595,9 @@ jobs: } - # The foreground waits for SQL Server here anyway, so Go setup costs nothing. - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - # renovate: golang=go - go-version: '1.27.2' - - name: Wait for background setup wait: + - build-go-tests - pull-hostprocess - pull-nanoserver - setup-bitlocker @@ -1609,43 +1633,20 @@ jobs: if ($LASTEXITCODE -ne 0) { throw "Failed to synchronize WMI performance counters" } - name: Go tests - id: go-tests shell: pwsh - env: - # Temporary: log raw ClusAPI property lists for parser fixtures. - WINDOWS_EXPORTER_TEST_CLUSAPI_DUMP: "1" - run: | - go test -json -count=1 -race -timeout=10m ./... 2>&1 | Tee-Object test-results.jsonl | - ForEach-Object { - $line = $_.ToString() - try { - $event = $line | ConvertFrom-Json -ErrorAction Stop - if ($event.Output) { [Console]::Write($event.Output) } - } catch { - Write-Host $line - } - } - $testExitCode = $LASTEXITCODE - "exit_code=$testExitCode" | Add-Content $env:GITHUB_OUTPUT - exit $testExitCode + run: ./tools/run-go-tests.ps1 -BinaryDirectory (Join-Path $env:RUNNER_TEMP "go-tests") -Timeout 10m - - name: Summarize Windows feature tests + - name: Smoke test exporter + shell: pwsh + run: ./tools/end-to-end-test.ps1 + + - name: Collect Windows setup logs if: ${{ always() }} shell: pwsh - env: - TEST_EXIT_CODE: ${{ steps.go-tests.outputs.exit_code || '1' }} run: | Copy-Item "$env:windir/Logs/DISM/dism.log" (Join-Path $env:RUNNER_TEMP "dism.log") -ErrorAction SilentlyContinue # CBS.log names the files that make role installation request a restart. Copy-Item "$env:windir/Logs/CBS/CBS.log" (Join-Path $env:RUNNER_TEMP "cbs.log") -ErrorAction SilentlyContinue - ./tools/test-summary.ps1 -TestExitCode ([int]$env:TEST_EXIT_CODE) - - - name: Build and smoke test exporter - shell: pwsh - run: | - 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() }} @@ -1653,9 +1654,6 @@ jobs: with: name: windows-test-results-${{ matrix.os }} path: | - test-output.txt - test-results.jsonl - feature-summary.md ${{ runner.temp }}/windows_exporter.*.log ${{ runner.temp }}/kubelet.*.log ${{ runner.temp }}/sql-server-summary.txt diff --git a/docs/ci-testing.md b/docs/ci-testing.md index e1298951..47849e7a 100644 --- a/docs/ci-testing.md +++ b/docs/ci-testing.md @@ -1,9 +1,15 @@ # Windows feature tests -The CI Windows job prepares real Windows roles and workloads before running -`go test -json -count=1 -race -timeout=10m ./...`. Collector tests validate the -metrics from those workloads. A final exporter smoke check builds and starts the -binary, requests `/metrics` once, and checks for a nonempty HTTP 200 response. +The CI Windows job prepares real Windows roles and workloads, then runs the Go +tests with the race detector. While the fixtures are provisioned, a background +step builds every test binary and the exporter with +[`tools/build-go-tests.ps1`](../tools/build-go-tests.ps1); linking the race test +binaries would otherwise take minutes after provisioning. +[`tools/run-go-tests.ps1`](../tools/run-go-tests.ps1) then runs the binaries +verbosely in parallel, each in its package directory, and prints each +package's output as one block. Collector tests validate the metrics +from those workloads. A final exporter smoke check starts the binary, requests +`/metrics` once, and checks for a nonempty HTTP 200 response. The job runs on Windows Server 2022; Server 2025 runners take much longer to provision the same fixtures. It provisions Containers, Hyper-V, SQL Server @@ -23,8 +29,6 @@ when setup is missing or collection fails instead of skipping. Fixture assertion check known instances, including the active RDP user's session and SQL database. RemoteFX is required when both network and graphics instances are available. -Each Windows job publishes a feature table in its Actions summary and a -`windows-test-results-` artifact containing `feature-summary.md`, structured -Go test events, readable test output, and setup diagnostics. The table distinguishes -required fixtures, unavailable collectors, and empty optional instance groups. -This records Windows feature availability rather than Go statement coverage. +Each Windows job uploads a `windows-test-results-` artifact containing +setup diagnostics. Unavailable optional collectors show up in the Go tests log +as skipped `TestCollector` tests with the reason in their output. diff --git a/tools/build-go-tests.ps1 b/tools/build-go-tests.ps1 new file mode 100644 index 00000000..4ade4714 --- /dev/null +++ b/tools/build-go-tests.ps1 @@ -0,0 +1,51 @@ +# Builds the race-enabled test binaries ahead of time, so CI can link them in +# the background while Windows fixtures are provisioned. run-go-tests.ps1 runs +# the binaries listed in manifest.json. +param( + [Parameter(Mandatory)] + [string]$OutputDirectory +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3 + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +$OutputDirectory = (Resolve-Path $OutputDirectory).Path + +$listing = go list -f '{{if or .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{"\t"}}{{.Dir}}{{end}}' ./... +if ($LASTEXITCODE -ne 0) { throw "go list failed with exit code $LASTEXITCODE" } + +$packages = @($listing | Where-Object { $_ } | ForEach-Object { + $importPath, $dir = $_ -split "`t", 2 + [pscustomobject]@{ + ImportPath = $importPath + Dir = $dir + Base = $importPath.Split('/')[-1] + Binary = $null + } +}) + +# `go test -c -o dir/` names binaries after the last import path element, so +# packages sharing that name (internal/collector, pkg/collector) build separately. +$groups = $packages | Group-Object Base +$shared = @($groups | Where-Object Count -eq 1 | ForEach-Object { $_.Group[0] }) +$clashing = @($groups | Where-Object Count -gt 1 | ForEach-Object { $_.Group }) + +foreach ($package in $shared) { + $package.Binary = "$($package.Base).test.exe" +} + +go test -race -c -o "$OutputDirectory/" @($shared.ImportPath) +if ($LASTEXITCODE -ne 0) { throw "go test -c failed with exit code $LASTEXITCODE" } + +foreach ($package in $clashing) { + $package.Binary = ($package.ImportPath -replace '[/.]', '_') + '.test.exe' + go test -race -c -o (Join-Path $OutputDirectory $package.Binary) $package.ImportPath + if ($LASTEXITCODE -ne 0) { throw "go test -c $($package.ImportPath) failed with exit code $LASTEXITCODE" } +} + +$packages | Select-Object ImportPath, Dir, Binary | + ConvertTo-Json -AsArray | + Set-Content -Encoding utf8 (Join-Path $OutputDirectory 'manifest.json') + +Write-Host "Built $($packages.Count) test binaries in $OutputDirectory" diff --git a/tools/run-go-tests.ps1 b/tools/run-go-tests.ps1 new file mode 100644 index 00000000..3ce9d444 --- /dev/null +++ b/tools/run-go-tests.ps1 @@ -0,0 +1,49 @@ +# Runs the test binaries built by build-go-tests.ps1 like `go test -v ./...`: +# each binary runs in its package directory and packages run in parallel. +param( + [Parameter(Mandatory)] + [string]$BinaryDirectory, + [string]$Timeout = '10m', + [int]$ThrottleLimit = [Environment]::ProcessorCount +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version 3 + +$BinaryDirectory = (Resolve-Path $BinaryDirectory).Path +$manifest = Get-Content (Join-Path $BinaryDirectory 'manifest.json') -Raw | ConvertFrom-Json + +$runs = $manifest | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel { + $package = $_ + $timeout = $using:Timeout + $binary = Join-Path $using:BinaryDirectory $package.Binary + + # Native commands start in the runspace location, so tests see their + # package directory as the working directory, as with `go test`. + Set-Location $package.Dir + $started = Get-Date + $output = & $binary '-test.v' "-test.timeout=$timeout" 2>&1 | + ForEach-Object { $_.ToString() } + $exitCode = $LASTEXITCODE + $elapsed = ((Get-Date) - $started).TotalSeconds + + $status = if ($exitCode -eq 0) { 'ok ' } else { 'FAIL' } + [pscustomobject]@{ + ImportPath = $package.ImportPath + ExitCode = $exitCode + # Print each package as one block so parallel output does not interleave. + Text = (@($output) + ("{0}`t{1}`t{2:N3}s" -f $status, $package.ImportPath, $elapsed)) -join "`n" + } +} | ForEach-Object { + Write-Host $_.Text + $_ +} + +$failed = @($runs | Where-Object ExitCode -ne 0) +if ($failed.Count -gt 0) { + Write-Host "Failed packages:" + $failed | ForEach-Object { Write-Host " $($_.ImportPath) (exit code $($_.ExitCode))" } + exit 1 +} + +exit 0 diff --git a/tools/test-summary.ps1 b/tools/test-summary.ps1 deleted file mode 100644 index 2f7b7130..00000000 --- a/tools/test-summary.ps1 +++ /dev/null @@ -1,64 +0,0 @@ -param([int]$TestExitCode) - -$ErrorActionPreference = "Stop" -$prefix = "github.com/prometheus-community/windows_exporter/internal/collector/" -$required = @($env:WINDOWS_EXPORTER_TEST_COLLECTORS.Split(',')) -$results = @{} -$text = [System.Text.StringBuilder]::new() - -$lines = if (Test-Path test-results.jsonl) { Get-Content test-results.jsonl } else { @() } -foreach ($line in $lines) { - try { - $event = $line | ConvertFrom-Json -ErrorAction Stop - } catch { - [void]$text.AppendLine($line) - continue - } - if ($event.Output) { [void]$text.Append($event.Output) } - if (-not $event.Package -or -not $event.Package.StartsWith($prefix) -or $event.Test -ne "TestCollector") { - continue - } - $name = $event.Package.Substring($prefix.Length) - if (-not $results.ContainsKey($name)) { - $results[$name] = @{ State = "not completed"; Notes = @() } - } - if ($event.Action -in @("pass", "skip", "fail")) { - $results[$name].State = $event.Action - } - if ($event.Output -match 'collector \S+ (?:is not supported|has empty optional instance groups): (.+)') { - $results[$name].Notes += $Matches[1].Trim() - } -} -$text.ToString() | Set-Content test-output.txt -NoNewline -Encoding utf8 - -# Aborted tests must leave required fixtures visible as not run. -foreach ($name in $required) { - if (-not $results.ContainsKey($name)) { - $results[$name] = @{ State = "not run"; Notes = @() } - } -} -$testState = if (-not $lines) { "not run" } elseif ($TestExitCode -eq 0) { "passed" } else { "failed" } -$summary = @( - "### Windows feature tests: $env:WINDOWS_TEST_OS" - "" - "Go suite: **$testState**. Required fixtures cannot skip. Empty optional instance groups remain listed below." - "" - "| Collector | Required | Live test | Unavailable features / notes |" - "| --- | --- | --- | --- |" -) -foreach ($name in $results.Keys | Sort-Object) { - $fixture = if ($name -in $required) { "yes" } else { "no" } - $state = switch ($results[$name].State) { - "pass" { "passed" } - "skip" { "unavailable" } - "fail" { "failed" } - default { $results[$name].State } - } - $notes = ($results[$name].Notes | Sort-Object -Unique) -join '; ' - $notes = $notes.Replace('|', '\|') - $summary += "| $name | $fixture | $state | $notes |" -} -$summary | Set-Content feature-summary.md -Encoding utf8 -if ($env:GITHUB_STEP_SUMMARY) { - $summary | Add-Content $env:GITHUB_STEP_SUMMARY -}