chore: consolidate CI and expand Windows test coverage (#2498)

Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-06 17:47:21 +02:00
committed by GitHub
parent 824fdb1d00
commit 72e10af92d
41 changed files with 2576 additions and 794 deletions
+20 -12
View File
@@ -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
}
@@ -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)
}
@@ -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"})
}
+3 -1
View File
@@ -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"})
}
+49 -2
View File
@@ -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)
})
}
}
@@ -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`})
}
+3 -1
View File
@@ -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"})
}
+3 -1
View File
@@ -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"})
}
@@ -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())
}
}
}
}
})
}
}
@@ -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"})
}
@@ -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)
}
-2
View File
@@ -29,7 +29,5 @@ func BenchmarkCollector(b *testing.B) {
}
func TestCollector(t *testing.T) {
t.Skip()
testutils.TestCollector(t, os.New, nil)
}
@@ -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()
}
@@ -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)
}
+3 -1
View File
@@ -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"})
}
+3 -1
View File
@@ -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`})
}
@@ -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"})
}
+16 -17
View File
@@ -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():
+3 -3
View File
@@ -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]
}
}
+130
View File
@@ -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)
})
}
}
+37
View File
@@ -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())
}
+181
View File
@@ -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
}
+45
View File
@@ -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)
}
+176 -46
View File
@@ -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)
}
@@ -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))
})
}
}