mscluster: collect metrics through ClusAPI (#2634)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-10 17:52:05 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f292a6fbcf
commit 1302cf6899
44 changed files with 7050 additions and 1036 deletions
+121 -1
View File
@@ -635,7 +635,7 @@ jobs:
- name: Setup MSCluster
id: setup-mscluster
# The Go test requires the cluster and virtual disk.
# The Go tests require the cluster and its resource fixtures.
background: true
shell: powershell
run: |
@@ -659,6 +659,24 @@ jobs:
Import-Module FailoverClusters
# A private network lets an IP Address resource come online without
# adding addresses to the runner's primary adapter.
$networkSwitch = "CIClusterSwitch"
$networkAddress = "198.51.100.0"
Start-Service vmms
if ($null -eq (Get-VMSwitch -Name $networkSwitch -ErrorAction SilentlyContinue)) {
New-VMSwitch -Name $networkSwitch -SwitchType Internal | Out-Null
}
$adapter = Get-NetAdapter -Name "vEthernet ($networkSwitch)"
if ($null -eq (Get-NetIPAddress -InterfaceIndex $adapter.ifIndex -IPAddress "198.51.100.1" -ErrorAction SilentlyContinue)) {
New-NetIPAddress -InterfaceIndex $adapter.ifIndex -IPAddress "198.51.100.1" -PrefixLength 24 | Out-Null
}
# A Cluster Name needs a static address on a routed client network and a
# DNS registration; the DHCP-only runner network provides neither. Keep
# the cluster without an administrative access point and add workload
# resources instead.
$cluster = Get-Cluster -ErrorAction SilentlyContinue
if ($null -eq $cluster) {
New-Cluster `
@@ -669,9 +687,108 @@ jobs:
-Force | Out-Null
}
$deadline = (Get-Date).AddMinutes(2)
do {
$network = Get-ClusterNetwork | Where-Object Address -EQ $networkAddress
if ($null -ne $network) {
break
}
if ((Get-Date) -ge $deadline) {
Get-ClusterNetwork | Format-Table Name, State, Role, Address
throw "Cluster network $networkAddress did not appear"
}
Start-Sleep -Seconds 2
} while ($true)
# Networks without a default gateway default to cluster-only traffic,
# which cannot host IP Address resources. 3 = ClusterAndClient.
$network.Role = 3
# Resource fixtures for the resource and resourcegroup sub-collectors:
# CIResources: an online Generic Service that depends on an online IP
# Address, plus an IP Address that stays offline.
# CIFailedResources: a Generic Service whose binary is missing.
if ($null -eq (Get-ClusterGroup -Name "CIResources" -ErrorAction SilentlyContinue)) {
Add-ClusterGroup -Name "CIResources" | Out-Null
Add-ClusterResource -Name "CI IP Address" -Group "CIResources" -ResourceType "IP Address" |
Set-ClusterParameter -Multiple @{
Address = "198.51.100.10"
SubnetMask = "255.255.255.0"
Network = $network.Name
EnableDhcp = 0
}
Add-ClusterResource -Name "CI Offline IP Address" -Group "CIResources" -ResourceType "IP Address" |
Set-ClusterParameter -Multiple @{
Address = "198.51.100.11"
SubnetMask = "255.255.255.0"
Network = $network.Name
EnableDhcp = 0
}
# Windows Event Collector is unused on the runner and stays running.
Add-ClusterResource -Name "CI Generic Service" -Group "CIResources" -ResourceType "Generic Service" |
Set-ClusterParameter -Name ServiceName -Value "Wecsvc"
Add-ClusterResourceDependency -Resource "CI Generic Service" -Provider "CI IP Address" | Out-Null
}
if ($null -eq (Get-ClusterGroup -Name "CIFailedResources" -ErrorAction SilentlyContinue)) {
New-Service `
-Name "WindowsExporterCIFailing" `
-BinaryPathName "$env:SystemRoot\System32\windows-exporter-ci-missing.exe" `
-StartupType Manual | Out-Null
Add-ClusterGroup -Name "CIFailedResources" | Out-Null
$failing = Add-ClusterResource -Name "CI Failed Service" -Group "CIFailedResources" -ResourceType "Generic Service"
$failing | Set-ClusterParameter -Name ServiceName -Value "WindowsExporterCIFailing"
# 0 = ClusterResourceDontRestart: keep the resource failed.
$failing.RestartAction = 0
}
Start-ClusterResource -Name "CI Generic Service" -Wait 60 | Out-Null
try {
Start-ClusterResource -Name "CI Failed Service" -Wait 60 | Out-Null
}
catch {
Write-Host "CI Failed Service failed to start as expected: $_"
}
$expected = @{
"CI IP Address" = "Online"
"CI Generic Service" = "Online"
"CI Offline IP Address" = "Offline"
"CI Failed Service" = "Failed"
}
$deadline = (Get-Date).AddMinutes(1)
do {
$resources = @(Get-ClusterResource)
$mismatched = @($expected.GetEnumerator() | Where-Object {
$name = $_.Key
"$(($resources | Where-Object Name -EQ $name).State)" -ne $_.Value
})
if ($mismatched.Count -eq 0) {
break
}
if ((Get-Date) -ge $deadline) {
$resources | Format-Table Name, State, OwnerGroup, ResourceType
throw "Cluster resources did not reach their expected states: $($mismatched.Key -join ', ')"
}
Start-Sleep -Seconds 2
} while ($true)
Get-Cluster | Format-List Name, AdministrativeAccessPoint
Get-ClusterNode | Format-Table Name, State, NodeWeight, DynamicWeight
Get-ClusterNetwork | Format-Table Name, State, Role, Address
Get-ClusterGroup | Format-Table Name, State, OwnerNode, GroupType
Get-ClusterResource | Format-Table Name, State, OwnerGroup, ResourceType
- name: Prepare container runtime
shell: pwsh
@@ -1494,6 +1611,9 @@ jobs:
- 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 {
+7
View File
@@ -11,6 +11,13 @@ Express, IIS, SMTP, MSMQ, NPS, FSRM, SMB, DNS, DHCP, a workgroup failover
cluster, printer and storage fixtures, unlocked and locked BitLocker volumes,
and an authenticated RDP session.
The failover cluster has no administrative access point: the DHCP-only runner
network cannot host a Cluster Name. An internal Hyper-V switch provides a private
client network for its resource fixtures. The `CIResources` group contains an
online Generic Service that depends on an online IP Address, plus an offline IP
Address. The `CIFailedResources` group contains a Generic Service whose binary is
missing, so it stays failed. Native and WMI parity tests require these resources.
`WINDOWS_EXPORTER_TEST_COLLECTORS` lists required collectors. Their tests fail
when setup is missing or collection fails instead of skipping. Fixture assertions
check known instances, including the active RDP user's session and SQL database.
+8 -2
View File
@@ -1,11 +1,17 @@
# mscluster_cluster collector
The MSCluster_Cluster class is a dynamic WMI class that represents a cluster.
The collector exposes Windows failover cluster metrics. All subcollectors read through the read-only Windows Cluster API (ClusAPI) instead of the `root\MSCluster` WMI provider. `shared_volumes` reads the partitions of every storage class resource with `CLUSCTL_RESOURCE_STORAGE_GET_DISK_INFO_EX`, the source of the former `MSCluster_DiskPartition` class, and keeps its whole-megabyte resolution. Metric names, types, labels and units are unchanged.
Each subcollector opens its cluster handle on its first scrape so a transient cluster-service failure is reported during collection; a handle whose RPC binding broke (for example after a cluster service restart) is reopened on the next scrape. ClusAPI calls cannot be cancelled while they are running. The subcollectors check the remaining scrape budget before starting each native call and stop waiting when the budget is spent. A call that is still running then makes the next scrapes of the same subcollector fail fast until it returns, while the other subcollectors keep publishing.
If a property of the cluster or of a network, node, resource or group cannot be read, its sample is omitted and the scrape reports an error; valid properties and other objects are retained. Invalid resource identities are not published. Missing native values are not substituted with zero. Objects deleted during a scrape are skipped. Names decode like the WMI strings did: up to the first NUL, with invalid UTF-16 replaced by U+FFFD. Values that the WMI provider synthesized are reproduced, so the series stay unchanged: for example `mscluster_resource_subclass` is always 0, because `MSCluster_Resource.Subclass` is NULL for every resource.
The ClusAPI functions in use are available since Windows Server 2008 R2. The `cold_start_setting` and `resiliency_period` group properties and the `status_information` node property exist since Windows Server 2016; on older versions these samples are omitted without an error. Cluster properties that Microsoft documents as added in Windows Server 2016 (for example `s2d_enabled` or `cluster_functional_level`) are omitted the same way. As before, `mscluster_node_detected_cloud_platform` is 0 before Windows Server 2022 unless the node reports the property, and the cluster metrics `detect_managed_events`, `detect_managed_events_threshold`, `security_level_for_storage`, `max_number_of_nodes` and `detected_cloud_platform` are published only on Windows Server 2022 and newer.
|||
-|-
Metric name prefix | `mscluster`
Classes | `MSCluster_Cluster`,`MSCluster_Network`,`MSCluster_Node`,`MSCluster_Resource`,`MSCluster_ResourceGroup`,`MSCluster_DiskPartition`
Windows API | ClusAPI (`clusapi.dll`)
Enabled by default? | No
Storage pool and virtual disk metrics (for example of Storage Spaces Direct) are exposed by the
+40
View File
@@ -0,0 +1,40 @@
# mscluster agent notes
- All subcollectors read through ClusAPI
([`internal/headers/clusapi`](../../headers/clusapi)) instead of the
`root\MSCluster` WMI provider (ClusWMI.dll). The WMI provider is a thin layer
over ClusAPI: most WMI properties are common properties from
`CLUSCTL_<OBJECT>_GET_RO_COMMON_PROPERTIES` and
`CLUSCTL_<OBJECT>_GET_COMMON_PROPERTIES`; `Characteristics`, `Flags` and
`State` come from their own control codes and `GetCluster<Object>State`.
Some WMI values are synthesized or never filled (published as 0 from NULL);
the parity tests show which ones, and the collector reproduces them.
- Keep WMI types when publishing: `sint32` properties (for example
`FailbackWindowStart`) are converted from the raw 32-bit value with
`int32`, `uint32` properties keep the unsigned bit pattern. Unknown states
(-1) are values, as in WMI, not errors.
- Each subcollector owns its own `clusapi.Cluster`. A `Cluster` runs one call
at a time without holding its lock: ClusAPI RPCs cannot be cancelled, so a
second call fails with `clusapi.ErrBusy`, and `Close` leaves the handle to
the running call. `callSource` stops waiting at the scrape budget, so one
stuck RPC does not hold `Collect` and the other subcollectors.
- `closeSources` drops a source even when its `Close` fails, so `Build` can
recreate it. Grafana Alloy calls `Build`/`Close` repeatedly.
- Every subcollector has a fixture test that checks gathered metric families
and a parity test against its WMI class. The CI cluster (`setup-mscluster`
in `.github/workflows/ci.yml`) is a single node without storage or
administrative access point. It has online, offline and failed resources in
two groups (`CIResources`, `CIFailedResources`) on a private network of an
internal Hyper-V switch; the parity tests require them. It has no clustered
disks, so shared volume parity still compares empty sets.
- Property lists returned by the cluster service end with an extra
`CLUSPROP_SYNTAX_ENDMARK` after the last value list; fixtures built only from
the documentation miss this. The parser validates only the structure and
accepts zero padding. `TestDumpPropertyLists` logs real lists as hex when
`WINDOWS_EXPORTER_TEST_CLUSAPI_DUMP` is set on a cluster node.
- `clusapi.Open` reports an unconfigured cluster or a missing ClusAPI with
`errors.ErrUnsupported`; the shared collector test treats that as an
unsupported role on hosts where `mscluster` is not a required fixture.
- Use `ClusterOpenEnum` and the `Open*Ex` functions (Windows Server 2008 R2+),
not `ClusterOpenEnumEx` (Windows Server 2016+), to keep Windows Server
2012 R2 support.
+149 -11
View File
@@ -18,11 +18,14 @@
package mscluster
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"slices"
"strings"
"sync"
"time"
"github.com/alecthomas/kingpin/v2"
@@ -42,6 +45,11 @@ const (
subCollectorSharedVolumes = "shared_volumes"
)
// nativeSubCollectors read through ClusAPI and do not need an MI session.
//
//nolint:gochecknoglobals
var nativeSubCollectors = []string{subCollectorCluster, subCollectorNetwork, subCollectorNode, subCollectorResource, subCollectorResourceGroup, subCollectorSharedVolumes}
type Config struct {
CollectorsEnabled []string `yaml:"enabled"`
}
@@ -67,8 +75,9 @@ type Collector struct {
collectorResourceGroup
collectorSharedVolumes
config Config
miSession *mi.Session
lifecycleMu sync.Mutex
config Config
miSession *mi.Session
}
func New(config *Config) *Collector {
@@ -114,12 +123,122 @@ func (c *Collector) GetName() string {
}
func (c *Collector) Close() error {
return nil
c.lifecycleMu.Lock()
defer c.lifecycleMu.Unlock()
return c.closeSources()
}
// closeSources releases the ClusAPI sources. A source whose Close fails is
// dropped as well: the clusapi.Cluster has already released its handle, and a
// later Build must be able to create new sources (Grafana Alloy rebuilds
// collectors in place).
func (c *Collector) closeSources() error {
var errs []error
closeSource := func(source io.Closer, release func()) {
if source == nil {
return
}
if err := source.Close(); err != nil {
errs = append(errs, err)
}
release()
}
closeSource(c.clusterSource, func() { c.clusterSource = nil })
closeSource(c.networkSource, func() { c.networkSource = nil })
closeSource(c.nodeSource, func() { c.nodeSource = nil })
closeSource(c.resourceSource, func() { c.resourceSource = nil })
closeSource(c.resourceGroupSource, func() { c.resourceGroupSource = nil })
closeSource(c.sharedVolumesSource, func() { c.sharedVolumesSource = nil })
return errors.Join(errs...)
}
// errNotBuilt reports a sub-collector without a source, for example when a
// library consumer calls Collect after a failed Build.
func errNotBuilt(name string) error {
return fmt.Errorf("%s collector is not built", name)
}
// callSource runs a ClusAPI read in its own goroutine and waits at most for
// the scrape budget. ClusAPI RPCs cannot be cancelled: a read that outlives
// the budget keeps running, and its source refuses new reads with
// clusapi.ErrBusy until the RPC returns. Returning here keeps one stuck
// sub-collector from holding Collect, and with it every other sub-collector.
// Late results are dropped; the read never writes to the metric channel.
//
//nolint:ireturn // T is the concrete result type of each source.
func callSource[T any](budget time.Duration, read func(deadline time.Time) (T, error)) (T, error) {
var deadline time.Time
if budget > 0 {
deadline = time.Now().Add(budget)
}
type result struct {
value T
err error
}
done := make(chan result, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- result{err: fmt.Errorf("panic in ClusAPI read: %v", r)}
}
}()
value, err := read(deadline)
done <- result{value: value, err: err}
}()
if budget <= 0 {
r := <-done
return r.value, r.err
}
timer := time.NewTimer(budget)
defer timer.Stop()
select {
case r := <-done:
return r.value, r.err
case <-timer.C:
var zero T
return zero, fmt.Errorf("ClusAPI read did not return within %s: %w", budget, context.DeadlineExceeded)
}
}
// remainingBudget returns the part of the scrape budget that is left for a
// sub-collector started after another one. Zero means no limit.
func remainingBudget(maxScrapeDuration time.Duration, scrapeStarted time.Time) (time.Duration, error) {
if maxScrapeDuration <= 0 {
return maxScrapeDuration, nil
}
budget := maxScrapeDuration - time.Since(scrapeStarted)
if budget <= 0 {
return 0, context.DeadlineExceeded
}
return budget, nil
}
func (c *Collector) Build(_ *slog.Logger, miSession *mi.Session) error {
c.lifecycleMu.Lock()
defer c.lifecycleMu.Unlock()
// A failed close is reported, but the old sources are dropped and rebuilt.
closeErr := c.closeSources()
if len(c.config.CollectorsEnabled) == 0 {
return nil
return closeErr
}
subCollectors := []string{
@@ -133,14 +252,14 @@ func (c *Collector) Build(_ *slog.Logger, miSession *mi.Session) error {
for _, name := range c.config.CollectorsEnabled {
if !slices.Contains(subCollectors, name) {
return fmt.Errorf("unknown sub collector: %s. Possible values: %s", name,
return errors.Join(closeErr, fmt.Errorf("unknown sub collector: %s. Possible values: %s", name,
strings.Join(subCollectors, ", "),
)
))
}
}
if miSession == nil {
return errors.New("miSession is nil")
if miSession == nil && slices.ContainsFunc(c.config.CollectorsEnabled, func(name string) bool { return !slices.Contains(nativeSubCollectors, name) }) {
return errors.Join(closeErr, errors.New("miSession is nil"))
}
c.miSession = miSession
@@ -183,12 +302,21 @@ func (c *Collector) Build(_ *slog.Logger, miSession *mi.Session) error {
}
}
return errors.Join(errs...)
if len(errs) != 0 {
errs = append(errs, c.closeSources())
}
return errors.Join(append(errs, closeErr)...)
}
// Collect sends the metric values for each metric
// to the provided prometheus Metric channel.
func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
c.lifecycleMu.Lock()
defer c.lifecycleMu.Unlock()
scrapeStarted := time.Now()
if len(c.config.CollectorsEnabled) == 0 {
return nil
}
@@ -231,7 +359,12 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
if slices.Contains(c.config.CollectorsEnabled, subCollectorResource) {
g.Go(func() error {
if err := c.collectResource(ch, maxScrapeDuration, nodeNames); err != nil {
budget, err := remainingBudget(maxScrapeDuration, scrapeStarted)
if err != nil {
return fmt.Errorf("failed to collect resource metrics: %w", err)
}
if err := c.collectResource(ch, budget, nodeNames); err != nil {
return fmt.Errorf("failed to collect resource metrics: %w", err)
}
@@ -241,7 +374,12 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.
if slices.Contains(c.config.CollectorsEnabled, subCollectorResourceGroup) {
g.Go(func() error {
if err := c.collectResourceGroup(ch, maxScrapeDuration, nodeNames); err != nil {
budget, err := remainingBudget(maxScrapeDuration, scrapeStarted)
if err != nil {
return fmt.Errorf("failed to collect resource group metrics: %w", err)
}
if err := c.collectResourceGroup(ch, budget, nodeNames); err != nil {
return fmt.Errorf("failed to collect resource group metrics: %w", err)
}
+119 -563
View File
@@ -18,10 +18,9 @@
package mscluster
import (
"fmt"
"time"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/osversion"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus/client_golang/prometheus"
@@ -29,8 +28,13 @@ import (
const nameCluster = Name + "_cluster"
type clusterSource interface {
Properties(deadline time.Time) (clusapi.Object, error)
Close() error
}
type collectorCluster struct {
clusterMIQuery mi.Query
clusterSource clusterSource
clusterAddEvictDelay *prometheus.Desc
clusterAdminAccessPoint *prometheus.Desc
@@ -111,7 +115,8 @@ type collectorCluster struct {
clusterWitnessRestartInterval *prometheus.Desc
}
// msClusterCluster represents the MSCluster_Cluster WMI class
// msClusterCluster represents the MSCluster_Cluster WMI class. The collector
// reads ClusAPI; the parity test compares against this WMI model.
// - https://docs.microsoft.com/en-us/previous-versions/windows/desktop/cluswmi/mscluster-cluster
type msClusterCluster struct {
Name string `mi:"Name"`
@@ -196,20 +201,18 @@ type msClusterCluster struct {
}
func (c *Collector) buildCluster() error {
buildNumber := osversion.Build()
wmiSelect := "AddEvictDelay,AdminAccessPoint,AutoAssignNodeSite,AutoBalancerLevel,AutoBalancerMode,BackupInProgress,BlockCacheSize,ClusSvcHangTimeout,ClusSvcRegroupOpeningTimeout,ClusSvcRegroupPruningTimeout,ClusSvcRegroupStageTimeout,ClusSvcRegroupTickInMilliseconds,ClusterEnforcedAntiAffinity,ClusterFunctionalLevel,ClusterGroupWaitDelay,ClusterLogLevel,ClusterLogSize,ClusterUpgradeVersion,CrossSiteDelay,CrossSiteThreshold,CrossSubnetDelay,CrossSubnetThreshold,CsvBalancer,DatabaseReadWriteMode,DefaultNetworkRole,DisableGroupPreferredOwnerRandomization,DrainOnShutdown,DynamicQuorumEnabled,EnableSharedVolumes,FixQuorum,GracePeriodEnabled,GracePeriodTimeout,GroupDependencyTimeout,HangRecoveryAction,IgnorePersistentStateOnStartup,LogResourceControls,LowerQuorumPriorityNodeId,MessageBufferLength,MinimumNeverPreemptPriority,MinimumPreemptorPriority,NetftIPSecEnabled,PlacementOptions,PlumbAllCrossSubnetRoutes,PreventQuorum,QuarantineDuration,QuarantineThreshold,QuorumArbitrationTimeMax,QuorumArbitrationTimeMin,QuorumLogFileSize,QuorumTypeValue,RequestReplyTimeout,ResiliencyDefaultPeriod,ResiliencyLevel,ResourceDllDeadlockPeriod,RootMemoryReserved,RouteHistoryLength,S2DBusTypes,S2DCacheDesiredState,S2DCacheFlashReservePercent,S2DCachePageSizeKBytes,S2DEnabled,S2DIOLatencyThreshold,S2DOptimizations,SameSubnetDelay,SameSubnetThreshold,SecurityLevel,SharedVolumeVssWriterOperationTimeout,ShutdownTimeoutInMinutes,UseClientAccessNetworksForSharedVolumes,WitnessDatabaseWriteTimeout,WitnessDynamicWeight,WitnessRestartInterval"
if buildNumber >= osversion.LTSC2022 {
wmiSelect += ",DetectManagedEvents,SecurityLevelForStorage,MaxNumberOfNodes,DetectManagedEventsThreshold,DetectedCloudPlatform"
}
clusterMIQuery, err := mi.NewQuery(fmt.Sprintf("SELECT %s FROM MSCluster_Cluster", wmiSelect))
source, err := clusapi.Open()
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
return err
}
c.clusterMIQuery = clusterMIQuery
c.clusterSource = source
c.buildClusterDescriptors()
return nil
}
func (c *Collector) buildClusterDescriptors() {
c.clusterAddEvictDelay = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, nameCluster, "add_evict_delay"),
"Provides access to the cluster's AddEvictDelay property, which is the number a seconds that a new node is delayed after an eviction of another node.",
@@ -672,563 +675,116 @@ func (c *Collector) buildCluster() error {
[]string{"name"},
nil,
)
var dst []msClusterCluster
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.clusterMIQuery, 0); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
}
return nil
}
func (c *Collector) collectCluster(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
var dst []msClusterCluster
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.clusterMIQuery, maxScrapeDuration); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
if c.clusterSource == nil {
return errNotBuilt(subCollectorCluster)
}
for _, v := range dst {
ch <- prometheus.MustNewConstMetric(
c.clusterAddEvictDelay,
prometheus.GaugeValue,
float64(v.AddEvictDelay),
v.Name,
)
cluster, resultErr := callSource(maxScrapeDuration, c.clusterSource.Properties)
ch <- prometheus.MustNewConstMetric(
c.clusterAdminAccessPoint,
prometheus.GaugeValue,
float64(v.AdminAccessPoint),
v.Name,
)
return c.publishCluster(ch, cluster, osversion.Build(), resultErr)
}
ch <- prometheus.MustNewConstMetric(
c.clusterAutoAssignNodeSite,
prometheus.GaugeValue,
float64(v.AutoAssignNodeSite),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterAutoBalancerLevel,
prometheus.GaugeValue,
float64(v.AutoBalancerLevel),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterAutoBalancerMode,
prometheus.GaugeValue,
float64(v.AutoBalancerMode),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterBackupInProgress,
prometheus.GaugeValue,
float64(v.BackupInProgress),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterBlockCacheSize,
prometheus.GaugeValue,
float64(v.BlockCacheSize),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusSvcHangTimeout,
prometheus.GaugeValue,
float64(v.ClusSvcHangTimeout),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusSvcRegroupOpeningTimeout,
prometheus.GaugeValue,
float64(v.ClusSvcRegroupOpeningTimeout),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusSvcRegroupPruningTimeout,
prometheus.GaugeValue,
float64(v.ClusSvcRegroupPruningTimeout),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusSvcRegroupStageTimeout,
prometheus.GaugeValue,
float64(v.ClusSvcRegroupStageTimeout),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusSvcRegroupTickInMilliseconds,
prometheus.GaugeValue,
float64(v.ClusSvcRegroupTickInMilliseconds),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusterEnforcedAntiAffinity,
prometheus.GaugeValue,
float64(v.ClusterEnforcedAntiAffinity),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusterFunctionalLevel,
prometheus.GaugeValue,
float64(v.ClusterFunctionalLevel),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusterGroupWaitDelay,
prometheus.GaugeValue,
float64(v.ClusterGroupWaitDelay),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusterLogLevel,
prometheus.GaugeValue,
float64(v.ClusterLogLevel),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusterLogSize,
prometheus.GaugeValue,
float64(v.ClusterLogSize),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterClusterUpgradeVersion,
prometheus.GaugeValue,
float64(v.ClusterUpgradeVersion),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterCrossSiteDelay,
prometheus.GaugeValue,
float64(v.CrossSiteDelay),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterCrossSiteThreshold,
prometheus.GaugeValue,
float64(v.CrossSiteThreshold),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterCrossSubnetDelay,
prometheus.GaugeValue,
float64(v.CrossSubnetDelay),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterCrossSubnetThreshold,
prometheus.GaugeValue,
float64(v.CrossSubnetThreshold),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterCsvBalancer,
prometheus.GaugeValue,
float64(v.CsvBalancer),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterDatabaseReadWriteMode,
prometheus.GaugeValue,
float64(v.DatabaseReadWriteMode),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterDefaultNetworkRole,
prometheus.GaugeValue,
float64(v.DefaultNetworkRole),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterDisableGroupPreferredOwnerRandomization,
prometheus.GaugeValue,
float64(v.DisableGroupPreferredOwnerRandomization),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterDrainOnShutdown,
prometheus.GaugeValue,
float64(v.DrainOnShutdown),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterDynamicQuorumEnabled,
prometheus.GaugeValue,
float64(v.DynamicQuorumEnabled),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterEnableSharedVolumes,
prometheus.GaugeValue,
float64(v.EnableSharedVolumes),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterFixQuorum,
prometheus.GaugeValue,
float64(v.FixQuorum),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterGracePeriodEnabled,
prometheus.GaugeValue,
float64(v.GracePeriodEnabled),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterGracePeriodTimeout,
prometheus.GaugeValue,
float64(v.GracePeriodTimeout),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterGroupDependencyTimeout,
prometheus.GaugeValue,
float64(v.GroupDependencyTimeout),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterHangRecoveryAction,
prometheus.GaugeValue,
float64(v.HangRecoveryAction),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterIgnorePersistentStateOnStartup,
prometheus.GaugeValue,
float64(v.IgnorePersistentStateOnStartup),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterLogResourceControls,
prometheus.GaugeValue,
float64(v.LogResourceControls),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterLowerQuorumPriorityNodeId,
prometheus.GaugeValue,
float64(v.LowerQuorumPriorityNodeId),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterMessageBufferLength,
prometheus.GaugeValue,
float64(v.MessageBufferLength),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterMinimumNeverPreemptPriority,
prometheus.GaugeValue,
float64(v.MinimumNeverPreemptPriority),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterMinimumPreemptorPriority,
prometheus.GaugeValue,
float64(v.MinimumPreemptorPriority),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterNetftIPSecEnabled,
prometheus.GaugeValue,
float64(v.NetftIPSecEnabled),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterPlacementOptions,
prometheus.GaugeValue,
float64(v.PlacementOptions),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterPlumbAllCrossSubnetRoutes,
prometheus.GaugeValue,
float64(v.PlumbAllCrossSubnetRoutes),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterPreventQuorum,
prometheus.GaugeValue,
float64(v.PreventQuorum),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterQuarantineDuration,
prometheus.GaugeValue,
float64(v.QuarantineDuration),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterQuarantineThreshold,
prometheus.GaugeValue,
float64(v.QuarantineThreshold),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterQuorumArbitrationTimeMax,
prometheus.GaugeValue,
float64(v.QuorumArbitrationTimeMax),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterQuorumArbitrationTimeMin,
prometheus.GaugeValue,
float64(v.QuorumArbitrationTimeMin),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterQuorumLogFileSize,
prometheus.GaugeValue,
float64(v.QuorumLogFileSize),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterQuorumTypeValue,
prometheus.GaugeValue,
float64(v.QuorumTypeValue),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterRequestReplyTimeout,
prometheus.GaugeValue,
float64(v.RequestReplyTimeout),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterResiliencyDefaultPeriod,
prometheus.GaugeValue,
float64(v.ResiliencyDefaultPeriod),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterResiliencyLevel,
prometheus.GaugeValue,
float64(v.ResiliencyLevel),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterResourceDllDeadlockPeriod,
prometheus.GaugeValue,
float64(v.ResourceDllDeadlockPeriod),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterRootMemoryReserved,
prometheus.GaugeValue,
float64(v.RootMemoryReserved),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterRouteHistoryLength,
prometheus.GaugeValue,
float64(v.RouteHistoryLength),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterS2DBusTypes,
prometheus.GaugeValue,
float64(v.S2DBusTypes),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterS2DCacheDesiredState,
prometheus.GaugeValue,
float64(v.S2DCacheDesiredState),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterS2DCacheFlashReservePercent,
prometheus.GaugeValue,
float64(v.S2DCacheFlashReservePercent),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterS2DCachePageSizeKBytes,
prometheus.GaugeValue,
float64(v.S2DCachePageSizeKBytes),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterS2DEnabled,
prometheus.GaugeValue,
float64(v.S2DEnabled),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterS2DIOLatencyThreshold,
prometheus.GaugeValue,
float64(v.S2DIOLatencyThreshold),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterS2DOptimizations,
prometheus.GaugeValue,
float64(v.S2DOptimizations),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterSameSubnetDelay,
prometheus.GaugeValue,
float64(v.SameSubnetDelay),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterSameSubnetThreshold,
prometheus.GaugeValue,
float64(v.SameSubnetThreshold),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterSecurityLevel,
prometheus.GaugeValue,
float64(v.SecurityLevel),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterSharedVolumeVssWriterOperationTimeout,
prometheus.GaugeValue,
float64(v.SharedVolumeVssWriterOperationTimeout),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterShutdownTimeoutInMinutes,
prometheus.GaugeValue,
float64(v.ShutdownTimeoutInMinutes),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterUseClientAccessNetworksForSharedVolumes,
prometheus.GaugeValue,
float64(v.UseClientAccessNetworksForSharedVolumes),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterWitnessDatabaseWriteTimeout,
prometheus.GaugeValue,
float64(v.WitnessDatabaseWriteTimeout),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterWitnessDynamicWeight,
prometheus.GaugeValue,
float64(v.WitnessDynamicWeight),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterWitnessRestartInterval,
prometheus.GaugeValue,
float64(v.WitnessRestartInterval),
v.Name,
)
if osversion.Build() >= osversion.LTSC2022 {
ch <- prometheus.MustNewConstMetric(
c.clusterDetectManagedEvents,
prometheus.GaugeValue,
float64(v.DetectManagedEvents),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterDetectManagedEventsThreshold,
prometheus.GaugeValue,
float64(v.DetectManagedEventsThreshold),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterSecurityLevelForStorage,
prometheus.GaugeValue,
float64(v.SecurityLevelForStorage),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.clusterMaxNumberOfNodes,
prometheus.GaugeValue,
float64(v.MaxNumberOfNodes),
v.Name,
)
func (c *Collector) publishCluster(ch chan<- prometheus.Metric, cluster clusapi.Object, build uint16, resultErr error) error {
if cluster.Name == "" {
return resultErr
}
ch <- prometheus.MustNewConstMetric(
c.clusterDetectedCloudPlatform,
prometheus.GaugeValue,
float64(v.DetectedCloudPlatform),
v.Name,
)
}
const (
ws2016 = osversion.LTSC2016
ws2022 = osversion.LTSC2022
)
// The WMI provider reports properties that the cluster service no longer
// has (documented as obsolete or unsupported, or removed in newer
// releases) as NULL, which the previous collector published as 0. It
// reports MaxNumberOfNodes as the 64 node limit of Windows Server failover
// clusters. Both were confirmed against MSCluster_Cluster on Windows Server
// 2022.
zero, maxNodes := new(uint32(0)), new(uint32(64))
fields := []objectField{
{name: "AddEvictDelay", desc: c.clusterAddEvictDelay},
{name: "AdminAccessPoint", desc: c.clusterAdminAccessPoint},
{name: "AutoAssignNodeSite", desc: c.clusterAutoAssignNodeSite},
{name: "AutoBalancerLevel", desc: c.clusterAutoBalancerLevel, minBuild: ws2016},
{name: "AutoBalancerMode", desc: c.clusterAutoBalancerMode, minBuild: ws2016},
{name: "BackupInProgress", desc: c.clusterBackupInProgress},
{name: "BlockCacheSize", desc: c.clusterBlockCacheSize},
{name: "ClusSvcHangTimeout", desc: c.clusterClusSvcHangTimeout},
{name: "ClusSvcRegroupOpeningTimeout", desc: c.clusterClusSvcRegroupOpeningTimeout, absent: zero},
{name: "ClusSvcRegroupPruningTimeout", desc: c.clusterClusSvcRegroupPruningTimeout, absent: zero},
{name: "ClusSvcRegroupStageTimeout", desc: c.clusterClusSvcRegroupStageTimeout},
{name: "ClusSvcRegroupTickInMilliseconds", desc: c.clusterClusSvcRegroupTickInMilliseconds},
{name: "ClusterEnforcedAntiAffinity", desc: c.clusterClusterEnforcedAntiAffinity},
{name: "ClusterFunctionalLevel", desc: c.clusterClusterFunctionalLevel, minBuild: ws2016},
{name: "ClusterGroupWaitDelay", desc: c.clusterClusterGroupWaitDelay},
{name: "ClusterLogLevel", desc: c.clusterClusterLogLevel},
{name: "ClusterLogSize", desc: c.clusterClusterLogSize},
{name: "ClusterUpgradeVersion", desc: c.clusterClusterUpgradeVersion, minBuild: ws2016},
{name: "CrossSiteDelay", desc: c.clusterCrossSiteDelay, minBuild: ws2016},
{name: "CrossSiteThreshold", desc: c.clusterCrossSiteThreshold, minBuild: ws2016},
{name: "CrossSubnetDelay", desc: c.clusterCrossSubnetDelay},
{name: "CrossSubnetThreshold", desc: c.clusterCrossSubnetThreshold},
{name: "CsvBalancer", desc: c.clusterCsvBalancer},
{name: "DatabaseReadWriteMode", desc: c.clusterDatabaseReadWriteMode},
{name: "DefaultNetworkRole", desc: c.clusterDefaultNetworkRole},
{name: "DisableGroupPreferredOwnerRandomization", desc: c.clusterDisableGroupPreferredOwnerRandomization, absent: zero},
{name: "DrainOnShutdown", desc: c.clusterDrainOnShutdown},
{name: "DynamicQuorumEnabled", desc: c.clusterDynamicQuorumEnabled},
{name: "EnableSharedVolumes", desc: c.clusterEnableSharedVolumes},
{name: "FixQuorum", desc: c.clusterFixQuorum},
{name: "GracePeriodEnabled", desc: c.clusterGracePeriodEnabled, minBuild: ws2016, absent: zero},
{name: "GracePeriodTimeout", desc: c.clusterGracePeriodTimeout, minBuild: ws2016, absent: zero},
{name: "GroupDependencyTimeout", desc: c.clusterGroupDependencyTimeout, minBuild: ws2016},
{name: "HangRecoveryAction", desc: c.clusterHangRecoveryAction},
{name: "IgnorePersistentStateOnStartup", desc: c.clusterIgnorePersistentStateOnStartup},
{name: "LogResourceControls", desc: c.clusterLogResourceControls},
{name: "LowerQuorumPriorityNodeId", desc: c.clusterLowerQuorumPriorityNodeId},
{name: "MessageBufferLength", desc: c.clusterMessageBufferLength},
{name: "MinimumNeverPreemptPriority", desc: c.clusterMinimumNeverPreemptPriority},
{name: "MinimumPreemptorPriority", desc: c.clusterMinimumPreemptorPriority},
{name: "NetftIPSecEnabled", desc: c.clusterNetftIPSecEnabled},
{name: "PlacementOptions", desc: c.clusterPlacementOptions, minBuild: ws2016},
{name: "PlumbAllCrossSubnetRoutes", desc: c.clusterPlumbAllCrossSubnetRoutes},
{name: "PreventQuorum", desc: c.clusterPreventQuorum},
{name: "QuarantineDuration", desc: c.clusterQuarantineDuration, minBuild: ws2016},
{name: "QuarantineThreshold", desc: c.clusterQuarantineThreshold, minBuild: ws2016},
{name: "QuorumArbitrationTimeMax", desc: c.clusterQuorumArbitrationTimeMax},
{name: "QuorumArbitrationTimeMin", desc: c.clusterQuorumArbitrationTimeMin, absent: zero},
{name: "QuorumLogFileSize", desc: c.clusterQuorumLogFileSize},
{name: "QuorumTypeValue", desc: c.clusterQuorumTypeValue},
{name: "RequestReplyTimeout", desc: c.clusterRequestReplyTimeout},
{name: "ResiliencyDefaultPeriod", desc: c.clusterResiliencyDefaultPeriod, minBuild: ws2016},
{name: "ResiliencyLevel", desc: c.clusterResiliencyLevel, minBuild: ws2016},
{name: "ResourceDllDeadlockPeriod", desc: c.clusterResourceDllDeadlockPeriod, absent: zero},
{name: "RootMemoryReserved", desc: c.clusterRootMemoryReserved, absent: zero},
{name: "RouteHistoryLength", desc: c.clusterRouteHistoryLength},
{name: "S2DBusTypes", desc: c.clusterS2DBusTypes, minBuild: ws2016},
{name: "S2DCacheDesiredState", desc: c.clusterS2DCacheDesiredState, minBuild: ws2016},
{name: "S2DCacheFlashReservePercent", desc: c.clusterS2DCacheFlashReservePercent, minBuild: ws2016},
{name: "S2DCachePageSizeKBytes", desc: c.clusterS2DCachePageSizeKBytes, minBuild: ws2016},
{name: "S2DEnabled", desc: c.clusterS2DEnabled, minBuild: ws2016},
{name: "S2DIOLatencyThreshold", desc: c.clusterS2DIOLatencyThreshold, minBuild: ws2016},
{name: "S2DOptimizations", desc: c.clusterS2DOptimizations, minBuild: ws2016},
{name: "SameSubnetDelay", desc: c.clusterSameSubnetDelay},
{name: "SameSubnetThreshold", desc: c.clusterSameSubnetThreshold},
{name: "SecurityLevel", desc: c.clusterSecurityLevel},
{name: "SharedVolumeVssWriterOperationTimeout", desc: c.clusterSharedVolumeVssWriterOperationTimeout},
{name: "ShutdownTimeoutInMinutes", desc: c.clusterShutdownTimeoutInMinutes},
{name: "UseClientAccessNetworksForSharedVolumes", desc: c.clusterUseClientAccessNetworksForSharedVolumes},
{name: "WitnessDatabaseWriteTimeout", desc: c.clusterWitnessDatabaseWriteTimeout},
{name: "WitnessDynamicWeight", desc: c.clusterWitnessDynamicWeight},
{name: "WitnessRestartInterval", desc: c.clusterWitnessRestartInterval},
// These were published only on Windows Server 2022 and newer.
{name: "DetectManagedEvents", desc: c.clusterDetectManagedEvents, minBuild: ws2022, older: neverPublish},
{name: "DetectManagedEventsThreshold", desc: c.clusterDetectManagedEventsThreshold, minBuild: ws2022, older: neverPublish},
{name: "SecurityLevelForStorage", desc: c.clusterSecurityLevelForStorage, minBuild: ws2022, older: neverPublish},
{name: "MaxNumberOfNodes", desc: c.clusterMaxNumberOfNodes, minBuild: ws2022, older: neverPublish, absent: maxNodes},
{name: "DetectedCloudPlatform", desc: c.clusterDetectedCloudPlatform, minBuild: ws2022, older: neverPublish},
}
return nil
return publishObjectFields(ch, "cluster", cluster, fields, build, resultErr)
}
@@ -0,0 +1,301 @@
// 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 mscluster
import (
"errors"
"log/slog"
"maps"
"reflect"
"strings"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/osversion"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
)
type clusterFixtureSource struct {
cluster clusapi.Object
err error
started chan struct{}
release chan struct{}
closeCount int
}
func (s *clusterFixtureSource) Properties(time.Time) (clusapi.Object, error) {
if s.started != nil {
close(s.started)
<-s.release
}
return s.cluster, s.err
}
func (s *clusterFixtureSource) Close() error {
s.closeCount++
return nil
}
// clusterFieldNames lists the uint32 properties of the WMI model in order.
func clusterFieldNames() []string {
model := reflect.TypeFor[msClusterCluster]()
names := make([]string, 0, model.NumField())
for field := range model.Fields() {
if field.Name != "Name" {
names = append(names, field.Name)
}
}
return names
}
// clusterFixture assigns each property a distinct value: its index + 1.
func clusterFixture() clusapi.Object {
cluster := clusapi.Object{Name: "CICluster", Values: make(map[string]uint32)}
for index, name := range clusterFieldNames() {
cluster.Values[name] = uint32(index + 1)
}
cluster.Values["QuorumTypeValue"] = ^uint32(0)
return cluster
}
func gatherCluster(t *testing.T, cluster clusapi.Object, inputErr error, build uint16) (map[string]*dto.MetricFamily, error) {
t.Helper()
c := New(&Config{CollectorsEnabled: []string{subCollectorCluster}})
c.buildClusterDescriptors()
return gatherPublished(t, func(ch chan<- prometheus.Metric) error {
return c.publishCluster(ch, cluster, build, inputErr)
})
}
func TestClusterNativeMetricContract(t *testing.T) {
cluster := clusterFixture()
families, err := gatherCluster(t, cluster, nil, osversion.LTSC2022)
if err != nil {
t.Fatal(err)
}
if len(families) != len(cluster.Values) {
t.Fatalf("families = %d, want %d", len(families), len(cluster.Values))
}
for name, family := range families {
if family.GetType() != dto.MetricType_GAUGE || len(family.GetMetric()) != 1 {
t.Fatalf("invalid family %s", name)
}
if !maps.Equal(metricLabels(family.GetMetric()[0]), map[string]string{"name": "CICluster"}) {
t.Fatalf("%s labels = %v", name, metricLabels(family.GetMetric()[0]))
}
}
for metric, property := range map[string]string{
"add_evict_delay": "AddEvictDelay",
"s2dio_latency_threshold": "S2DIOLatencyThreshold",
"s2d_cache_page_size_k_bytes": "S2DCachePageSizeKBytes",
"netft_ip_sec_enabled": "NetftIPSecEnabled",
"detected_cloud_platform": "DetectedCloudPlatform",
"lower_quorum_priority_node_id": "LowerQuorumPriorityNodeId",
"detect_managed_events_threshold": "DetectManagedEventsThreshold",
"quorum_type_value": "QuorumTypeValue",
} {
got := families["windows_mscluster_cluster_"+metric].GetMetric()[0].GetGauge().GetValue()
if want := float64(cluster.Values[property]); got != want {
t.Errorf("%s = %v, want %v", metric, got, want)
}
}
}
func TestClusterVersionDependentFields(t *testing.T) {
cluster := clusterFixture()
// The five Windows Server 2022 properties were not published before.
families, err := gatherCluster(t, cluster, nil, osversion.LTSC2019)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"detect_managed_events", "detect_managed_events_threshold", "security_level_for_storage", "max_number_of_nodes", "detected_cloud_platform"} {
if _, exists := families["windows_mscluster_cluster_"+name]; exists {
t.Errorf("published %s before Windows Server 2022", name)
}
}
// Windows Server 2016 properties are omitted on Windows Server 2012 R2.
delete(cluster.Values, "S2DEnabled")
delete(cluster.Values, "ClusterFunctionalLevel")
if _, err := gatherCluster(t, cluster, nil, osversion.LTSC2016-1); err != nil {
t.Fatalf("missing Windows Server 2016 properties failed Windows Server 2012 R2: %v", err)
}
_, err = gatherCluster(t, cluster, nil, osversion.LTSC2016)
if err == nil || !strings.Contains(err.Error(), "S2DEnabled") {
t.Fatalf("missing Windows Server 2016 property ignored: %v", err)
}
}
// The CI cluster showed which MSCluster_Cluster values WMI synthesizes when the
// cluster service does not report the property.
func TestClusterWMISynthesizedValues(t *testing.T) {
cluster := clusterFixture()
synthesized := map[string]float64{
"ClusSvcRegroupOpeningTimeout": 0, "ClusSvcRegroupPruningTimeout": 0, "DisableGroupPreferredOwnerRandomization": 0,
"GracePeriodEnabled": 0, "GracePeriodTimeout": 0, "QuorumArbitrationTimeMin": 0, "ResourceDllDeadlockPeriod": 0,
"RootMemoryReserved": 0, "MaxNumberOfNodes": 64,
}
for name := range synthesized {
delete(cluster.Values, name)
}
families, err := gatherCluster(t, cluster, nil, osversion.LTSC2022)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"clus_svc_regroup_opening_timeout", "grace_period_enabled", "root_memory_reserved"} {
if got := families["windows_mscluster_cluster_"+name].GetMetric()[0].GetGauge().GetValue(); got != 0 {
t.Errorf("%s = %v", name, got)
}
}
if got := families["windows_mscluster_cluster_max_number_of_nodes"].GetMetric()[0].GetGauge().GetValue(); got != 64 {
t.Errorf("max_number_of_nodes = %v", got)
}
// Reported values win over the synthesized ones.
if got := familiesValue(t, clusterFixture(), "max_number_of_nodes"); got == 64 {
t.Error("synthesized value replaced the reported one")
}
}
func familiesValue(t *testing.T, cluster clusapi.Object, metric string) float64 {
t.Helper()
families, err := gatherCluster(t, cluster, nil, osversion.LTSC2022)
if err != nil {
t.Fatal(err)
}
return families["windows_mscluster_cluster_"+metric].GetMetric()[0].GetGauge().GetValue()
}
func TestClusterPartialResultsAndJoinedErrors(t *testing.T) {
first, second := errors.New("first cause"), errors.New("second cause")
cluster := clusterFixture()
delete(cluster.Values, "QuorumTypeValue")
families, err := gatherCluster(t, cluster, errors.Join(first, second), osversion.LTSC2022)
if !errors.Is(err, first) || !errors.Is(err, second) || !strings.Contains(err.Error(), "QuorumTypeValue") {
t.Fatalf("lost cause: %v", err)
}
if _, exists := families["windows_mscluster_cluster_quorum_type_value"]; exists {
t.Fatal("published missing property")
}
if len(families) != len(cluster.Values) {
t.Fatalf("lost successful properties: %d", len(families))
}
// A cluster whose name could not be read is not published.
families, err = gatherCluster(t, clusapi.Object{}, first, osversion.LTSC2022)
if !errors.Is(err, first) || len(families) != 0 {
t.Fatalf("published invalid identity: %d, %v", len(families), err)
}
}
func TestClusterCloseWaitsForCollection(t *testing.T) {
source := &clusterFixtureSource{cluster: clusterFixture(), started: make(chan struct{}), release: make(chan struct{})}
c := New(&Config{CollectorsEnabled: []string{subCollectorCluster}})
c.clusterSource = source
c.buildClusterDescriptors()
collectDone := make(chan error, 1)
go func() { collectDone <- c.Collect(make(chan prometheus.Metric, 100), time.Second) }()
<-source.started
closeDone := make(chan error, 1)
go func() { closeDone <- c.Close() }()
select {
case err := <-closeDone:
t.Fatalf("closed during collection: %v", err)
case <-time.After(10 * time.Millisecond):
}
close(source.release)
if err := <-collectDone; err != nil {
t.Fatal(err)
}
if err := <-closeDone; err != nil {
t.Fatal(err)
}
if err := c.Close(); err != nil || source.closeCount != 1 {
t.Fatalf("close count = %d, err = %v", source.closeCount, err)
}
}
func TestClusterRepeatedBuildClosesOldSource(t *testing.T) {
source := &clusterFixtureSource{}
c := New(&Config{CollectorsEnabled: []string{"unsupported"}})
c.clusterSource = source
if err := c.Build(slog.Default(), nil); err == nil {
t.Fatal("accepted unsupported collector")
}
if source.closeCount != 1 || c.clusterSource != nil {
t.Fatal("old cluster source leaked")
}
}
func BenchmarkClusterPublication(b *testing.B) {
c := New(&Config{CollectorsEnabled: []string{subCollectorCluster}})
c.buildClusterDescriptors()
cluster := clusterFixture()
ch := make(chan prometheus.Metric, len(cluster.Values))
b.ReportAllocs()
for b.Loop() {
if err := c.publishCluster(ch, cluster, osversion.LTSC2022, nil); err != nil {
b.Fatal(err)
}
for range len(cluster.Values) {
<-ch
}
}
}
@@ -0,0 +1,136 @@
// 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 mscluster
import (
"reflect"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/osversion"
"google.golang.org/protobuf/proto"
)
// clusterParityQuery is the query of the previous WMI implementation.
func clusterParityQuery() string {
query := "SELECT Name,AddEvictDelay,AdminAccessPoint,AutoAssignNodeSite,AutoBalancerLevel,AutoBalancerMode,BackupInProgress,BlockCacheSize,ClusSvcHangTimeout,ClusSvcRegroupOpeningTimeout,ClusSvcRegroupPruningTimeout,ClusSvcRegroupStageTimeout,ClusSvcRegroupTickInMilliseconds,ClusterEnforcedAntiAffinity,ClusterFunctionalLevel,ClusterGroupWaitDelay,ClusterLogLevel,ClusterLogSize,ClusterUpgradeVersion,CrossSiteDelay,CrossSiteThreshold,CrossSubnetDelay,CrossSubnetThreshold,CsvBalancer,DatabaseReadWriteMode,DefaultNetworkRole,DisableGroupPreferredOwnerRandomization,DrainOnShutdown,DynamicQuorumEnabled,EnableSharedVolumes,FixQuorum,GracePeriodEnabled,GracePeriodTimeout,GroupDependencyTimeout,HangRecoveryAction,IgnorePersistentStateOnStartup,LogResourceControls,LowerQuorumPriorityNodeId,MessageBufferLength,MinimumNeverPreemptPriority,MinimumPreemptorPriority,NetftIPSecEnabled,PlacementOptions,PlumbAllCrossSubnetRoutes,PreventQuorum,QuarantineDuration,QuarantineThreshold,QuorumArbitrationTimeMax,QuorumArbitrationTimeMin,QuorumLogFileSize,QuorumTypeValue,RequestReplyTimeout,ResiliencyDefaultPeriod,ResiliencyLevel,ResourceDllDeadlockPeriod,RootMemoryReserved,RouteHistoryLength,S2DBusTypes,S2DCacheDesiredState,S2DCacheFlashReservePercent,S2DCachePageSizeKBytes,S2DEnabled,S2DIOLatencyThreshold,S2DOptimizations,SameSubnetDelay,SameSubnetThreshold,SecurityLevel,SharedVolumeVssWriterOperationTimeout,ShutdownTimeoutInMinutes,UseClientAccessNetworksForSharedVolumes,WitnessDatabaseWriteTimeout,WitnessDynamicWeight,WitnessRestartInterval"
if osversion.Build() >= osversion.LTSC2022 {
query += ",DetectManagedEvents,SecurityLevelForStorage,MaxNumberOfNodes,DetectManagedEventsThreshold,DetectedCloudPlatform"
}
return query + " FROM MSCluster_Cluster"
}
// TestClusterNativeWMIParity compares gathered metric families from ClusAPI
// with the same publication fed by MSCluster_Cluster.
func TestClusterNativeWMIParity(t *testing.T) {
native, session, _ := resourceComparisonSources(t)
query, err := mi.NewQuery(clusterParityQuery())
if err != nil {
t.Fatal(err)
}
var rows []msClusterCluster
start := time.Now()
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
t.Fatal(err)
}
t.Logf("WMI cluster query duration: %s", time.Since(start))
if len(rows) != 1 {
t.Fatalf("cluster rows = %d, want 1", len(rows))
}
expected := clusapi.Object{Name: rows[0].Name, Values: make(map[string]uint32)}
value := reflect.ValueOf(rows[0])
for index := range value.NumField() {
if field := value.Type().Field(index); field.Name != "Name" {
expected.Values[field.Name] = uint32(value.Field(index).Uint())
}
}
start = time.Now()
cluster, err := native.Properties(time.Now().Add(time.Minute))
if err != nil {
t.Error(err)
}
t.Logf("ClusAPI cluster query duration: %s", time.Since(start))
gotFamilies, err := gatherCluster(t, cluster, nil, osversion.Build())
if err != nil {
t.Error(err)
}
wantFamilies, err := gatherCluster(t, expected, nil, osversion.Build())
if err != nil {
t.Fatal(err)
}
if len(gotFamilies) != len(wantFamilies) {
t.Errorf("families = %d, want %d", len(gotFamilies), len(wantFamilies))
}
for name, want := range wantFamilies {
if !proto.Equal(gotFamilies[name], want) {
t.Errorf("native/WMI metric mismatch: %s\nnative: %v\nWMI: %v", name, gotFamilies[name], want)
}
}
if t.Failed() {
t.Logf("native cluster %q DWORD properties: %v", cluster.Name, cluster.Values)
}
}
func BenchmarkClusterSources(b *testing.B) {
native, session, _ := resourceComparisonSources(b)
query, err := mi.NewQuery(clusterParityQuery())
if err != nil {
b.Fatal(err)
}
b.Run("WMI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
var rows []msClusterCluster
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
b.Fatal(err)
}
}
})
b.Run("ClusAPI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := native.Properties(time.Now().Add(time.Minute)); err != nil {
b.Fatal(err)
}
}
})
}
@@ -0,0 +1,90 @@
// 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 mscluster
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus/client_golang/prometheus"
)
// A ClusAPI RPC that hangs (for example in a resource DLL until its deadlock
// timeout) must not hold Collect: the other sub-collectors publish, Collect
// returns at the budget, and Close does not wait for the stuck read.
func TestCollectDoesNotWaitForStuckSource(t *testing.T) {
stuck := &resourceFixtureSource{started: make(chan struct{}), release: make(chan struct{})}
defer close(stuck.release)
networks := &objectFixtureSource{objects: []clusapi.Object{networkFixture("Cluster Network 1")}}
c := New(&Config{CollectorsEnabled: []string{subCollectorNetwork, subCollectorResource}})
c.resourceSource, c.networkSource = stuck, networks
c.buildResourceDescriptors()
c.buildNetworkDescriptors()
ch := make(chan prometheus.Metric, 100)
start := time.Now()
err := c.Collect(ch, 50*time.Millisecond)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("stuck resource read: %v", err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("Collect waited %s for the stuck read", elapsed)
}
close(ch)
published := 0
for range ch {
published++
}
if published == 0 {
t.Fatal("network metrics were not published")
}
closeDone := make(chan error, 1)
go func() { closeDone <- c.Close() }()
select {
case err := <-closeDone:
if err != nil {
t.Fatal(err)
}
case <-time.After(5 * time.Second):
t.Fatal("Close waited for the stuck read")
}
}
// Library consumers may call Collect after a failed Build.
func TestCollectWithoutSources(t *testing.T) {
c := New(&Config{CollectorsEnabled: []string{subCollectorCluster, subCollectorNetwork, subCollectorNode, subCollectorResource, subCollectorResourceGroup, subCollectorSharedVolumes}})
err := c.Collect(make(chan prometheus.Metric, 10), time.Second)
if err == nil || !strings.Contains(err.Error(), "not built") {
t.Fatalf("Collect without sources: %v", err)
}
}
@@ -18,18 +18,22 @@
package mscluster
import (
"fmt"
"time"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus/client_golang/prometheus"
)
const nameNetwork = Name + "_network"
type networkSource interface {
Networks(deadline time.Time) ([]clusapi.Object, error)
Close() error
}
type collectorNetwork struct {
networkMIQuery mi.Query
networkSource networkSource
networkCharacteristics *prometheus.Desc
networkFlags *prometheus.Desc
@@ -38,7 +42,8 @@ type collectorNetwork struct {
networkState *prometheus.Desc
}
// msClusterNetwork represents the MSCluster_Network WMI class
// msClusterNetwork represents the MSCluster_Network WMI class. The collector
// reads ClusAPI; the parity test compares against this WMI model.
// - https://docs.microsoft.com/en-us/previous-versions/windows/desktop/cluswmi/mscluster-network
type msClusterNetwork struct {
Name string `mi:"Name"`
@@ -51,13 +56,18 @@ type msClusterNetwork struct {
}
func (c *Collector) buildNetwork() error {
networkMIQuery, err := mi.NewQuery("SELECT Characteristics,Flags,Metric,Role,State FROM MSCluster_Network")
source, err := clusapi.Open()
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
return err
}
c.networkMIQuery = networkMIQuery
c.networkSource = source
c.buildNetworkDescriptors()
return nil
}
func (c *Collector) buildNetworkDescriptors() {
c.networkCharacteristics = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, nameNetwork, "characteristics"),
"Provides the characteristics of the network.",
@@ -88,61 +98,38 @@ func (c *Collector) buildNetwork() error {
[]string{"name"},
nil,
)
var dst []msClusterNetwork
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.networkMIQuery, 0); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
}
return nil
}
// Collect sends the metric values for each metric
// to the provided prometheus metric channel.
func (c *Collector) collectNetwork(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
var dst []msClusterNetwork
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.networkMIQuery, maxScrapeDuration); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
if c.networkSource == nil {
return errNotBuilt(subCollectorNetwork)
}
for _, v := range dst {
ch <- prometheus.MustNewConstMetric(
c.networkCharacteristics,
prometheus.GaugeValue,
float64(v.Characteristics),
v.Name,
)
networks, resultErr := callSource(maxScrapeDuration, c.networkSource.Networks)
ch <- prometheus.MustNewConstMetric(
c.networkFlags,
prometheus.GaugeValue,
float64(v.Flags),
v.Name,
)
return c.publishNetworks(ch, networks, resultErr)
}
ch <- prometheus.MustNewConstMetric(
c.networkMetric,
prometheus.GaugeValue,
float64(v.Metric),
v.Name,
)
func (c *Collector) publishNetworks(ch chan<- prometheus.Metric, networks []clusapi.Object, resultErr error) error {
fields := []objectField{
{name: "Characteristics", desc: c.networkCharacteristics},
{name: "Flags", desc: c.networkFlags},
{name: "Metric", desc: c.networkMetric},
{name: "Role", desc: c.networkRole},
{name: "State", desc: c.networkState},
}
ch <- prometheus.MustNewConstMetric(
c.networkRole,
prometheus.GaugeValue,
float64(v.Role),
v.Name,
)
for _, network := range networks {
if network.Name == "" {
continue
}
ch <- prometheus.MustNewConstMetric(
c.networkState,
prometheus.GaugeValue,
float64(v.State),
v.Name,
)
// All network properties exist since Windows Server 2012, so the
// build does not matter.
resultErr = publishObjectFields(ch, "network", network, fields, 0, resultErr)
}
return nil
return resultErr
}
@@ -0,0 +1,186 @@
// 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 mscluster
import (
"errors"
"fmt"
"log/slog"
"maps"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
)
func networkFixture(name string) clusapi.Object {
return clusapi.Object{Name: name, Values: map[string]uint32{
"Characteristics": 0, "Flags": 0, "Metric": 70240, "Role": 3, "State": ^uint32(0),
}}
}
func gatherNetworks(t *testing.T, networks []clusapi.Object, inputErr error) (map[string]*dto.MetricFamily, error) {
t.Helper()
c := New(&Config{CollectorsEnabled: []string{subCollectorNetwork}})
c.buildNetworkDescriptors()
return gatherPublished(t, func(ch chan<- prometheus.Metric) error {
return c.publishNetworks(ch, networks, inputErr)
})
}
func TestNetworkNativeMetricContract(t *testing.T) {
families, err := gatherNetworks(t, []clusapi.Object{networkFixture("Cluster Network 1")}, nil)
if err != nil {
t.Fatal(err)
}
expected := map[string]float64{"characteristics": 0, "flags": 0, "metric": 70240, "role": 3, "state": 4294967295}
if len(families) != len(expected) {
t.Fatalf("families = %d", len(families))
}
for suffix, value := range expected {
family := families["windows_mscluster_network_"+suffix]
if family == nil || family.GetType() != dto.MetricType_GAUGE || len(family.GetMetric()) != 1 {
t.Fatalf("invalid family %s: %v", suffix, family)
}
metric := family.GetMetric()[0]
if metric.GetGauge().GetValue() != value {
t.Errorf("%s value = %v, want %v", suffix, metric.GetGauge().GetValue(), value)
}
if !maps.Equal(metricLabels(metric), map[string]string{"name": "Cluster Network 1"}) {
t.Fatalf("labels = %v", metricLabels(metric))
}
}
}
func TestNetworkPartialResultsAndJoinedErrors(t *testing.T) {
first, second := errors.New("first cause"), errors.New("second cause")
network := networkFixture("Cluster Network 1")
delete(network.Values, "State")
families, err := gatherNetworks(t, []clusapi.Object{network, {}, networkFixture("Cluster Network 2")}, errors.Join(first, second))
if !errors.Is(err, first) || !errors.Is(err, second) {
t.Fatalf("lost joined cause: %v", err)
}
for name, family := range families {
want := 2
if name == "windows_mscluster_network_state" {
want = 1
}
if len(family.GetMetric()) != want {
t.Errorf("%s samples = %d, want %d", name, len(family.GetMetric()), want)
}
}
}
func TestNetworkCloseWaitsForCollection(t *testing.T) {
source := &objectFixtureSource{objects: []clusapi.Object{networkFixture("Cluster Network 1")}, started: make(chan struct{}), release: make(chan struct{})}
c := New(&Config{CollectorsEnabled: []string{subCollectorNetwork}})
c.networkSource = source
c.buildNetworkDescriptors()
collectDone := make(chan error, 1)
go func() { collectDone <- c.Collect(make(chan prometheus.Metric, 100), time.Second) }()
<-source.started
closeDone := make(chan error, 1)
go func() { closeDone <- c.Close() }()
select {
case err := <-closeDone:
t.Fatalf("closed during collection: %v", err)
case <-time.After(10 * time.Millisecond):
}
close(source.release)
if err := <-collectDone; err != nil {
t.Fatal(err)
}
if err := <-closeDone; err != nil {
t.Fatal(err)
}
if err := c.Close(); err != nil || source.closeCount != 1 {
t.Fatalf("close count = %d, err = %v", source.closeCount, err)
}
}
func TestNetworkRepeatedBuildClosesOldSource(t *testing.T) {
source := &objectFixtureSource{}
c := New(&Config{CollectorsEnabled: []string{"unsupported"}})
c.networkSource = source
if err := c.Build(slog.Default(), nil); err == nil {
t.Fatal("accepted unsupported collector")
}
if source.closeCount != 1 || c.networkSource != nil {
t.Fatal("old network source leaked")
}
}
func TestNetworkLargeClusterGather(t *testing.T) {
networks := make([]clusapi.Object, 32)
for index := range networks {
networks[index] = networkFixture(fmt.Sprintf("Cluster Network %d", index))
}
families, err := gatherNetworks(t, networks, nil)
if err != nil {
t.Fatal(err)
}
for name, family := range families {
if len(family.GetMetric()) != len(networks) {
t.Errorf("%s samples = %d", name, len(family.GetMetric()))
}
}
}
func BenchmarkNetworkPublication(b *testing.B) {
c := New(&Config{CollectorsEnabled: []string{subCollectorNetwork}})
c.buildNetworkDescriptors()
networks := []clusapi.Object{networkFixture("Cluster Network 1")}
ch := make(chan prometheus.Metric, 5)
b.ReportAllocs()
for b.Loop() {
if err := c.publishNetworks(ch, networks, nil); err != nil {
b.Fatal(err)
}
for range 5 {
<-ch
}
}
}
@@ -0,0 +1,126 @@
// 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 mscluster
import (
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/mi"
"google.golang.org/protobuf/proto"
)
const networkParityQuery = "SELECT Name,Characteristics,Flags,Metric,Role,State FROM MSCluster_Network"
// TestNetworkNativeWMIParity compares gathered metric families from ClusAPI
// with the same publication fed by MSCluster_Network.
func TestNetworkNativeWMIParity(t *testing.T) {
native, session, _ := resourceComparisonSources(t)
query, err := mi.NewQuery(networkParityQuery)
if err != nil {
t.Fatal(err)
}
var rows []msClusterNetwork
start := time.Now()
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
t.Fatal(err)
}
t.Logf("WMI network query duration: %s, networks: %d", time.Since(start), len(rows))
expected := make([]clusapi.Object, 0, len(rows))
for _, row := range rows {
expected = append(expected, clusapi.Object{Name: row.Name, Values: map[string]uint32{
"Characteristics": uint32(row.Characteristics),
"Flags": uint32(row.Flags),
"Metric": uint32(row.Metric),
"Role": uint32(row.Role),
"State": uint32(row.State),
}})
}
start = time.Now()
networks, err := native.Networks(time.Now().Add(time.Minute))
if err != nil {
t.Error(err)
}
t.Logf("ClusAPI network query duration: %s, networks: %d", time.Since(start), len(networks))
gotFamilies, err := gatherNetworks(t, networks, nil)
if err != nil {
t.Error(err)
}
wantFamilies, err := gatherNetworks(t, expected, nil)
if err != nil {
t.Fatal(err)
}
if len(gotFamilies) != len(wantFamilies) {
t.Errorf("families = %d, want %d", len(gotFamilies), len(wantFamilies))
}
for name, want := range wantFamilies {
if !proto.Equal(gotFamilies[name], want) {
t.Errorf("native/WMI metric mismatch: %s\nnative: %v\nWMI: %v", name, gotFamilies[name], want)
}
}
if t.Failed() {
for _, network := range networks {
t.Logf("native network %q DWORD properties: %v", network.Name, network.Values)
}
}
}
func BenchmarkNetworkSources(b *testing.B) {
native, session, _ := resourceComparisonSources(b)
query, err := mi.NewQuery(networkParityQuery)
if err != nil {
b.Fatal(err)
}
b.Run("WMI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
var rows []msClusterNetwork
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
b.Fatal(err)
}
}
})
b.Run("ClusAPI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := native.Networks(time.Now().Add(time.Minute)); err != nil {
b.Fatal(err)
}
}
})
}
+52 -126
View File
@@ -18,10 +18,9 @@
package mscluster
import (
"fmt"
"time"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/osversion"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus/client_golang/prometheus"
@@ -29,8 +28,13 @@ import (
const nameNode = Name + "_node"
type nodeSource interface {
Nodes(deadline time.Time) ([]clusapi.Object, error)
Close() error
}
type collectorNode struct {
nodeMIQuery mi.Query
nodeSource nodeSource
nodeBuildNumber *prometheus.Desc
nodeCharacteristics *prometheus.Desc
@@ -48,7 +52,8 @@ type collectorNode struct {
nodeStatusInformation *prometheus.Desc
}
// msClusterNode represents the MSCluster_Node WMI class
// msClusterNode represents the MSCluster_Node WMI class. The collector reads
// ClusAPI; the parity test compares against this WMI model.
// - https://docs.microsoft.com/en-us/previous-versions/windows/desktop/cluswmi/mscluster-node
type msClusterNode struct {
Name string `mi:"Name"`
@@ -70,20 +75,18 @@ type msClusterNode struct {
}
func (c *Collector) buildNode() error {
buildNumber := osversion.Build()
wmiSelect := "BuildNumber,Characteristics,DynamicWeight,Flags,MajorVersion,MinorVersion,NeedsPreventQuorum,NodeDrainStatus,NodeHighestVersion,NodeLowestVersion,NodeWeight,State,StatusInformation"
if buildNumber >= osversion.LTSC2022 {
wmiSelect += ",DetectedCloudPlatform"
}
nodeMIQuery, err := mi.NewQuery(fmt.Sprintf("SELECT %s FROM MSCluster_Node", wmiSelect))
source, err := clusapi.Open()
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
return err
}
c.nodeMIQuery = nodeMIQuery
c.nodeSource = source
c.buildNodeDescriptors()
return nil
}
func (c *Collector) buildNodeDescriptors() {
c.nodeBuildNumber = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, nameNode, "build_number"),
"Provides access to the node's BuildNumber property.",
@@ -168,128 +171,51 @@ func (c *Collector) buildNode() error {
[]string{"name"},
nil,
)
var dst []msClusterNode
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.nodeMIQuery, 0); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
}
return nil
}
// Collect sends the metric values for each metric
// to the provided prometheus Metric channel.
// to the provided prometheus Metric channel. It returns the node names that
// the resource and resource group subcollectors use for owner_node.
func (c *Collector) collectNode(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) ([]string, error) {
var dst []msClusterNode
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.nodeMIQuery, maxScrapeDuration); err != nil {
return nil, fmt.Errorf("WMI query failed: %w", err)
if c.nodeSource == nil {
return nil, errNotBuilt(subCollectorNode)
}
nodeNames := make([]string, 0, len(dst))
nodes, resultErr := callSource(maxScrapeDuration, c.nodeSource.Nodes)
for _, v := range dst {
ch <- prometheus.MustNewConstMetric(
c.nodeBuildNumber,
prometheus.GaugeValue,
float64(v.BuildNumber),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeCharacteristics,
prometheus.GaugeValue,
float64(v.Characteristics),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeDetectedCloudPlatform,
prometheus.GaugeValue,
float64(v.DetectedCloudPlatform),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeDynamicWeight,
prometheus.GaugeValue,
float64(v.DynamicWeight),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeFlags,
prometheus.GaugeValue,
float64(v.Flags),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeMajorVersion,
prometheus.GaugeValue,
float64(v.MajorVersion),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeMinorVersion,
prometheus.GaugeValue,
float64(v.MinorVersion),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeNeedsPreventQuorum,
prometheus.GaugeValue,
float64(v.NeedsPreventQuorum),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeNodeDrainStatus,
prometheus.GaugeValue,
float64(v.NodeDrainStatus),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeNodeHighestVersion,
prometheus.GaugeValue,
float64(v.NodeHighestVersion),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeNodeLowestVersion,
prometheus.GaugeValue,
float64(v.NodeLowestVersion),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.nodeNodeWeight,
prometheus.GaugeValue,
float64(v.NodeWeight),
v.Name,
)
return c.publishNodes(ch, nodes, osversion.Build(), resultErr)
}
func (c *Collector) publishNodes(ch chan<- prometheus.Metric, nodes []clusapi.Object, build uint16, resultErr error) ([]string, error) {
fields := []objectField{
{name: "BuildNumber", desc: c.nodeBuildNumber},
{name: "Characteristics", desc: c.nodeCharacteristics},
// The previous WMI query selected DetectedCloudPlatform only on Windows
// Server 2022 and newer and published 0 on older builds.
{name: "DetectedCloudPlatform", desc: c.nodeDetectedCloudPlatform, minBuild: osversion.LTSC2022, older: zeroIfMissing},
{name: "DynamicWeight", desc: c.nodeDynamicWeight},
{name: "Flags", desc: c.nodeFlags},
{name: "MajorVersion", desc: c.nodeMajorVersion},
{name: "MinorVersion", desc: c.nodeMinorVersion},
{name: "NeedsPreventQuorum", desc: c.nodeNeedsPreventQuorum},
{name: "NodeDrainStatus", desc: c.nodeNodeDrainStatus},
{name: "NodeHighestVersion", desc: c.nodeNodeHighestVersion},
{name: "NodeLowestVersion", desc: c.nodeNodeLowestVersion},
{name: "NodeWeight", desc: c.nodeNodeWeight},
{name: "State", desc: c.nodeState},
{name: "StatusInformation", desc: c.nodeStatusInformation, minBuild: osversion.LTSC2016},
}
ch <- prometheus.MustNewConstMetric(
c.nodeState,
prometheus.GaugeValue,
float64(v.State),
v.Name,
)
nodeNames := make([]string, 0, len(nodes))
ch <- prometheus.MustNewConstMetric(
c.nodeStatusInformation,
prometheus.GaugeValue,
float64(v.StatusInformation),
v.Name,
)
for _, node := range nodes {
if node.Name == "" {
continue
}
nodeNames = append(nodeNames, v.Name)
resultErr = publishObjectFields(ch, "node", node, fields, build, resultErr)
nodeNames = append(nodeNames, node.Name)
}
return nodeNames, nil
return nodeNames, resultErr
}
@@ -0,0 +1,279 @@
// 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 mscluster
import (
"errors"
"fmt"
"log/slog"
"maps"
"slices"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/osversion"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
)
func nodeFixture(name string) clusapi.Object {
return clusapi.Object{Name: name, Values: map[string]uint32{
"BuildNumber": 20348, "Characteristics": 0, "DetectedCloudPlatform": 1, "DynamicWeight": 1, "Flags": 2, "MajorVersion": 10, "MinorVersion": 0, "NeedsPreventQuorum": 0, "NodeDrainStatus": 3, "NodeHighestVersion": 0x000b0000, "NodeLowestVersion": 0x00090000, "NodeWeight": 1, "State": ^uint32(0), "StatusInformation": 2,
}}
}
func gatherNodes(t *testing.T, nodes []clusapi.Object, inputErr error, build uint16) (map[string]*dto.MetricFamily, []string, error) {
t.Helper()
c := New(&Config{CollectorsEnabled: []string{subCollectorNode}})
c.buildNodeDescriptors()
var nodeNames []string
families, err := gatherPublished(t, func(ch chan<- prometheus.Metric) error {
var err error
nodeNames, err = c.publishNodes(ch, nodes, build, inputErr)
return err
})
return families, nodeNames, err
}
func TestNodeNativeMetricContract(t *testing.T) {
families, nodeNames, err := gatherNodes(t, []clusapi.Object{nodeFixture("nodeA")}, nil, osversion.LTSC2022)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(nodeNames, []string{"nodeA"}) {
t.Fatalf("node names = %v", nodeNames)
}
expected := map[string]float64{
"build_number": 20348, "characteristics": 0, "detected_cloud_platform": 1, "dynamic_weight": 1, "flags": 2, "major_version": 10, "minor_version": 0, "needs_prevent_quorum": 0, "node_drain_status": 3, "node_highest_version": 0x000b0000, "node_lowest_version": 0x00090000, "node_weight": 1, "state": 4294967295, "status_information": 2,
}
if len(families) != len(expected) {
t.Fatalf("families = %d", len(families))
}
for suffix, value := range expected {
family := families["windows_mscluster_node_"+suffix]
if family == nil || family.GetType() != dto.MetricType_GAUGE || len(family.GetMetric()) != 1 {
t.Fatalf("invalid family %s: %v", suffix, family)
}
metric := family.GetMetric()[0]
if metric.GetGauge().GetValue() != value {
t.Errorf("%s value = %v, want %v", suffix, metric.GetGauge().GetValue(), value)
}
if !maps.Equal(metricLabels(metric), map[string]string{"name": "nodeA"}) {
t.Fatalf("labels = %v", metricLabels(metric))
}
}
}
func TestNodePartialResultsAndJoinedErrors(t *testing.T) {
first, second := errors.New("first cause"), errors.New("second cause")
node := nodeFixture("nodeA")
delete(node.Values, "State")
delete(node.Values, "NodeWeight")
families, nodeNames, err := gatherNodes(t, []clusapi.Object{node, {}, nodeFixture("nodeB")}, errors.Join(first, second), osversion.LTSC2022)
if !errors.Is(err, first) || !errors.Is(err, second) {
t.Fatalf("lost joined cause: %v", err)
}
// Nodes with unreadable properties still own resources and groups.
if !slices.Equal(nodeNames, []string{"nodeA", "nodeB"}) {
t.Fatalf("node names = %v", nodeNames)
}
for name, family := range families {
want := 2
if name == "windows_mscluster_node_state" || name == "windows_mscluster_node_node_weight" {
want = 1
}
if len(family.GetMetric()) != want {
t.Errorf("%s samples = %d, want %d", name, len(family.GetMetric()), want)
}
}
}
func TestNodeVersionDependentFields(t *testing.T) {
node := nodeFixture("nodeA")
delete(node.Values, "DetectedCloudPlatform")
delete(node.Values, "StatusInformation")
// Windows Server 2012 R2: StatusInformation does not exist, and the previous
// WMI query published DetectedCloudPlatform as 0.
families, _, err := gatherNodes(t, []clusapi.Object{node}, nil, osversion.LTSC2016-1)
if err != nil {
t.Fatal(err)
}
if _, exists := families["windows_mscluster_node_status_information"]; exists {
t.Fatal("published missing StatusInformation")
}
if got := families["windows_mscluster_node_detected_cloud_platform"].GetMetric()[0].GetGauge().GetValue(); got != 0 {
t.Fatalf("detected cloud platform = %v", got)
}
// Windows Server 2019: StatusInformation is required.
if _, _, err := gatherNodes(t, []clusapi.Object{node}, nil, osversion.LTSC2019); err == nil {
t.Fatal("missing StatusInformation ignored on Windows Server 2019")
}
// Windows Server 2022: both are required and never published as zero.
families, _, err = gatherNodes(t, []clusapi.Object{node}, nil, osversion.LTSC2022)
if err == nil {
t.Fatal("missing DetectedCloudPlatform ignored on Windows Server 2022")
}
if _, exists := families["windows_mscluster_node_detected_cloud_platform"]; exists {
t.Fatal("published missing DetectedCloudPlatform")
}
}
func TestNodeNamesFeedOwnerMetrics(t *testing.T) {
source := &objectFixtureSource{objects: []clusapi.Object{nodeFixture("nodeA"), nodeFixture("nodeB")}}
c := New(&Config{CollectorsEnabled: []string{subCollectorNode, subCollectorResourceGroup}})
c.nodeSource, c.resourceGroupSource = source, &objectFixtureSource{objects: []clusapi.Object{resourceGroupFixture()}}
c.buildNodeDescriptors()
c.buildResourceGroupDescriptors()
families, err := gatherPublished(t, func(ch chan<- prometheus.Metric) error {
return c.Collect(ch, time.Minute)
})
if err != nil {
t.Fatal(err)
}
owners := families["windows_mscluster_resourcegroup_owner_node"]
if len(owners.GetMetric()) != 2 {
t.Fatalf("owner samples = %d", len(owners.GetMetric()))
}
for _, metric := range owners.GetMetric() {
want := 0.0
if metricLabels(metric)["node_name"] == "nodeB" {
want = 1
}
if metric.GetGauge().GetValue() != want {
t.Fatalf("owner %v = %v", metricLabels(metric), metric.GetGauge().GetValue())
}
}
}
func TestNodeCloseWaitsForCollection(t *testing.T) {
source := &objectFixtureSource{objects: []clusapi.Object{nodeFixture("nodeA")}, started: make(chan struct{}), release: make(chan struct{})}
c := New(&Config{CollectorsEnabled: []string{subCollectorNode}})
c.nodeSource = source
c.buildNodeDescriptors()
collectDone := make(chan error, 1)
go func() { collectDone <- c.Collect(make(chan prometheus.Metric, 100), time.Second) }()
<-source.started
closeDone := make(chan error, 1)
go func() { closeDone <- c.Close() }()
select {
case err := <-closeDone:
t.Fatalf("closed during collection: %v", err)
case <-time.After(10 * time.Millisecond):
}
close(source.release)
if err := <-collectDone; err != nil {
t.Fatal(err)
}
if err := <-closeDone; err != nil {
t.Fatal(err)
}
if err := c.Close(); err != nil || source.closeCount != 1 {
t.Fatalf("close count = %d, err = %v", source.closeCount, err)
}
}
func TestNodeRepeatedBuildClosesOldSource(t *testing.T) {
source := &objectFixtureSource{}
c := New(&Config{CollectorsEnabled: []string{"unsupported"}})
c.nodeSource = source
if err := c.Build(slog.Default(), nil); err == nil {
t.Fatal("accepted unsupported collector")
}
if source.closeCount != 1 || c.nodeSource != nil {
t.Fatal("old node source leaked")
}
}
func TestNodeLargeClusterGather(t *testing.T) {
nodes := make([]clusapi.Object, 64)
for index := range nodes {
nodes[index] = nodeFixture(fmt.Sprintf("node-%d", index))
}
families, nodeNames, err := gatherNodes(t, nodes, nil, osversion.LTSC2022)
if err != nil {
t.Fatal(err)
}
if len(nodeNames) != len(nodes) {
t.Fatalf("node names = %d", len(nodeNames))
}
for name, family := range families {
if len(family.GetMetric()) != len(nodes) {
t.Errorf("%s samples = %d", name, len(family.GetMetric()))
}
}
}
func BenchmarkNodePublication(b *testing.B) {
c := New(&Config{CollectorsEnabled: []string{subCollectorNode}})
c.buildNodeDescriptors()
nodes := []clusapi.Object{nodeFixture("nodeA")}
ch := make(chan prometheus.Metric, 14)
b.ReportAllocs()
for b.Loop() {
if _, err := c.publishNodes(ch, nodes, osversion.LTSC2022, nil); err != nil {
b.Fatal(err)
}
for range 14 {
<-ch
}
}
}
@@ -0,0 +1,157 @@
// 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 mscluster
import (
"slices"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/osversion"
"google.golang.org/protobuf/proto"
)
func nodeParityQuery() string {
query := "SELECT Name,BuildNumber,Characteristics,DynamicWeight,Flags,MajorVersion,MinorVersion,NeedsPreventQuorum,NodeDrainStatus,NodeHighestVersion,NodeLowestVersion,NodeWeight,State,StatusInformation"
if osversion.Build() >= osversion.LTSC2022 {
query += ",DetectedCloudPlatform"
}
return query + " FROM MSCluster_Node"
}
// TestNodeNativeWMIParity compares gathered metric families and node names
// from ClusAPI with the same publication fed by MSCluster_Node.
func TestNodeNativeWMIParity(t *testing.T) {
native, session, _ := resourceComparisonSources(t)
query, err := mi.NewQuery(nodeParityQuery())
if err != nil {
t.Fatal(err)
}
var rows []msClusterNode
start := time.Now()
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
t.Fatal(err)
}
t.Logf("WMI node query duration: %s", time.Since(start))
if len(rows) == 0 {
t.Fatal("cluster fixture has no nodes")
}
expected := make([]clusapi.Object, 0, len(rows))
for _, row := range rows {
values := map[string]uint32{
"BuildNumber": uint32(row.BuildNumber),
"Characteristics": uint32(row.Characteristics),
"DynamicWeight": uint32(row.DynamicWeight),
"Flags": uint32(row.Flags),
"MajorVersion": uint32(row.MajorVersion),
"MinorVersion": uint32(row.MinorVersion),
"NeedsPreventQuorum": uint32(row.NeedsPreventQuorum),
"NodeDrainStatus": uint32(row.NodeDrainStatus),
"NodeHighestVersion": uint32(row.NodeHighestVersion),
"NodeLowestVersion": uint32(row.NodeLowestVersion),
"NodeWeight": uint32(row.NodeWeight),
"State": uint32(row.State),
"StatusInformation": uint32(row.StatusInformation),
}
if osversion.Build() >= osversion.LTSC2022 {
values["DetectedCloudPlatform"] = uint32(row.DetectedCloudPlatform)
}
expected = append(expected, clusapi.Object{Name: row.Name, Values: values})
}
start = time.Now()
nodes, err := native.Nodes(time.Now().Add(time.Minute))
if err != nil {
t.Error(err)
}
t.Logf("ClusAPI node query duration: %s, nodes: %d", time.Since(start), len(nodes))
gotFamilies, gotNames, err := gatherNodes(t, nodes, nil, osversion.Build())
if err != nil {
t.Error(err)
}
wantFamilies, wantNames, err := gatherNodes(t, expected, nil, osversion.Build())
if err != nil {
t.Fatal(err)
}
// Owner labels of resources and groups depend on identical node names.
if !slices.Equal(slices.Sorted(slices.Values(gotNames)), slices.Sorted(slices.Values(wantNames))) {
t.Errorf("node names = %v, want %v", gotNames, wantNames)
}
if len(gotFamilies) != len(wantFamilies) {
t.Errorf("families = %d, want %d", len(gotFamilies), len(wantFamilies))
}
for name, want := range wantFamilies {
if !proto.Equal(gotFamilies[name], want) {
t.Errorf("native/WMI metric mismatch: %s\nnative: %v\nWMI: %v", name, gotFamilies[name], want)
}
}
if t.Failed() {
for _, node := range nodes {
t.Logf("native node %q DWORD properties: %v", node.Name, node.Values)
}
}
}
func BenchmarkNodeSources(b *testing.B) {
native, session, _ := resourceComparisonSources(b)
query, err := mi.NewQuery(nodeParityQuery())
if err != nil {
b.Fatal(err)
}
b.Run("WMI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
var rows []msClusterNode
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
b.Fatal(err)
}
}
})
b.Run("ClusAPI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := native.Nodes(time.Now().Add(time.Minute)); err != nil {
b.Fatal(err)
}
}
})
}
@@ -0,0 +1,92 @@
// 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 mscluster
import (
"errors"
"fmt"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus/client_golang/prometheus"
)
// beforeMinBuild selects how a field is published on Windows builds older than
// its objectField.minBuild.
type beforeMinBuild int
const (
// omitIfMissing publishes a value that exists and omits a missing one
// without an error. The property did not exist on older builds.
omitIfMissing beforeMinBuild = iota
// zeroIfMissing keeps the previous behavior for properties that the WMI
// query did not select on older builds and therefore published as 0.
zeroIfMissing
// neverPublish keeps the previous behavior for properties that were not
// published at all on older builds.
neverPublish
)
// objectField maps a ClusAPI value of a cluster, group, node or network to the
// metric that previously published the WMI property of the same name.
type objectField struct {
name string
desc *prometheus.Desc
// Signed fields are sint32 in WMI, so 0xFFFFFFFF is published as -1.
signed bool
// MinBuild is the first Windows build that has the property.
minBuild uint16
older beforeMinBuild
// Absent, if set, is published when the cluster service does not report
// the property on any build. It reproduces a value that the WMI provider
// synthesized; see the cluster field list for each case.
absent *uint32
}
// publishObjectFields publishes the fields of one object with its name as the
// only label. Missing values are omitted and reported, never published as 0.
func publishObjectFields(ch chan<- prometheus.Metric, kind string, object clusapi.Object, fields []objectField, build uint16, resultErr error) error {
for _, field := range fields {
raw, exists := object.Values[field.name]
older := build < field.minBuild
switch {
case older && field.older == neverPublish:
continue
case exists:
case field.absent != nil:
raw = *field.absent
case older && field.older == zeroIfMissing:
raw = 0
case older:
continue
default:
resultErr = errors.Join(resultErr, fmt.Errorf("%s %q: missing property %s", kind, object.Name, field.name))
continue
}
value := float64(raw)
if field.signed {
value = float64(int32(raw))
}
ch <- prometheus.MustNewConstMetric(field.desc, prometheus.GaugeValue, value, object.Name)
}
return resultErr
}
@@ -18,18 +18,24 @@
package mscluster
import (
"errors"
"fmt"
"time"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus/client_golang/prometheus"
)
const nameResource = Name + "_resource"
type resourceSource interface {
Resources(deadline time.Time) ([]clusapi.Resource, error)
Close() error
}
type collectorResource struct {
resourceMIQuery mi.Query
resourceSource resourceSource
resourceCharacteristics *prometheus.Desc
resourceDeadlockTimeout *prometheus.Desc
@@ -77,13 +83,18 @@ type msClusterResource struct {
}
func (c *Collector) buildResource() error {
resourceMIQuery, err := mi.NewQuery("SELECT Name,Type,OwnerGroup,OwnerNode,Characteristics,DeadlockTimeout,EmbeddedFailureAction,Flags,IsAlivePollInterval,LooksAlivePollInterval,MonitorProcessId,PendingTimeout,ResourceClass,RestartAction,RestartDelay,RestartPeriod,RestartThreshold,RetryPeriodOnFailure,State,Subclass FROM MSCluster_Resource")
source, err := clusapi.Open()
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
return err
}
c.resourceMIQuery = resourceMIQuery
c.resourceSource = source
c.buildResourceDescriptors()
return nil
}
func (c *Collector) buildResourceDescriptors() {
c.resourceCharacteristics = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, nameResource, "characteristics"),
"Provides the characteristics of the object.",
@@ -192,152 +203,72 @@ func (c *Collector) buildResource() error {
[]string{"type", "owner_group", "name"},
nil,
)
var dst []msClusterResource
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.resourceMIQuery, 0); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
}
return nil
}
// Collect sends the metric values for each metric
// to the provided prometheus Metric channel.
func (c *Collector) collectResource(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration, nodeNames []string) error {
var dst []msClusterResource
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.resourceMIQuery, maxScrapeDuration); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
if c.resourceSource == nil {
return errNotBuilt(subCollectorResource)
}
for _, v := range dst {
ch <- prometheus.MustNewConstMetric(
c.resourceCharacteristics,
prometheus.GaugeValue,
float64(v.Characteristics),
v.Type, v.OwnerGroup, v.Name,
)
resources, resultErr := callSource(maxScrapeDuration, c.resourceSource.Resources)
ch <- prometheus.MustNewConstMetric(
c.resourceDeadlockTimeout,
prometheus.GaugeValue,
float64(v.DeadlockTimeout),
v.Type, v.OwnerGroup, v.Name,
)
return c.publishResources(ch, resources, nodeNames, resultErr)
}
ch <- prometheus.MustNewConstMetric(
c.resourceEmbeddedFailureAction,
prometheus.GaugeValue,
float64(v.EmbeddedFailureAction),
v.Type, v.OwnerGroup, v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceFlags,
prometheus.GaugeValue,
float64(v.Flags),
v.Type, v.OwnerGroup, v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceIsAlivePollInterval,
prometheus.GaugeValue,
float64(v.IsAlivePollInterval),
v.Type, v.OwnerGroup, v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceLooksAlivePollInterval,
prometheus.GaugeValue,
float64(v.LooksAlivePollInterval),
v.Type, v.OwnerGroup, v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceMonitorProcessId,
prometheus.GaugeValue,
float64(v.MonitorProcessId),
v.Type, v.OwnerGroup, v.Name,
)
for _, nodeName := range nodeNames {
isCurrentState := 0.0
if v.OwnerNode == nodeName {
isCurrentState = 1.0
}
ch <- prometheus.MustNewConstMetric(
c.resourceOwnerNode,
prometheus.GaugeValue,
isCurrentState,
v.Type, v.OwnerGroup, nodeName, v.Name,
)
func (c *Collector) publishResources(ch chan<- prometheus.Metric, resources []clusapi.Resource, nodeNames []string, resultErr error) error {
for _, resource := range resources {
if !resource.IdentityValid {
continue
}
ch <- prometheus.MustNewConstMetric(
c.resourcePendingTimeout,
prometheus.GaugeValue,
float64(v.PendingTimeout),
v.Type, v.OwnerGroup, v.Name,
)
fields := []struct {
name string
desc *prometheus.Desc
}{
{"Characteristics", c.resourceCharacteristics},
{"DeadlockTimeout", c.resourceDeadlockTimeout},
{"EmbeddedFailureAction", c.resourceEmbeddedFailureAction},
{"Flags", c.resourceFlags},
{"IsAlivePollInterval", c.resourceIsAlivePollInterval},
{"LooksAlivePollInterval", c.resourceLooksAlivePollInterval},
{"MonitorProcessId", c.resourceMonitorProcessId},
{"PendingTimeout", c.resourcePendingTimeout},
{"ResourceClass", c.resourceResourceClass},
{"RestartAction", c.resourceRestartAction},
{"RestartDelay", c.resourceRestartDelay},
{"RestartPeriod", c.resourceRestartPeriod},
{"RestartThreshold", c.resourceRestartThreshold},
{"RetryPeriodOnFailure", c.resourceRetryPeriodOnFailure},
{"State", c.resourceState},
}
for _, field := range fields {
value, exists := resource.Values[field.name]
if !exists {
resultErr = errors.Join(resultErr, fmt.Errorf("resource %q: missing DWORD property %s", resource.Name, field.name))
ch <- prometheus.MustNewConstMetric(
c.resourceResourceClass,
prometheus.GaugeValue,
float64(v.ResourceClass),
v.Type, v.OwnerGroup, v.Name,
)
continue
}
ch <- prometheus.MustNewConstMetric(
c.resourceRestartAction,
prometheus.GaugeValue,
float64(v.RestartAction),
v.Type, v.OwnerGroup, v.Name,
)
ch <- prometheus.MustNewConstMetric(field.desc, prometheus.GaugeValue, float64(value), resource.Type, resource.OwnerGroup, resource.Name)
}
ch <- prometheus.MustNewConstMetric(
c.resourceRestartDelay,
prometheus.GaugeValue,
float64(v.RestartDelay),
v.Type, v.OwnerGroup, v.Name,
)
// MSCluster_Resource.Subclass is NULL for every resource (seen on
// Windows Server 2022, also for IP Address resources whose class
// information carries CLUS_RESSUBCLASS_NETWORK_INTERNET_PROTOCOL), so
// the WMI collector always published 0. Keep that series unchanged.
ch <- prometheus.MustNewConstMetric(c.resourceSubClass, prometheus.GaugeValue, 0, resource.Type, resource.OwnerGroup, resource.Name)
ch <- prometheus.MustNewConstMetric(
c.resourceRestartPeriod,
prometheus.GaugeValue,
float64(v.RestartPeriod),
v.Type, v.OwnerGroup, v.Name,
)
for _, nodeName := range nodeNames {
value := 0.0
if resource.OwnerNode == nodeName {
value = 1
}
ch <- prometheus.MustNewConstMetric(
c.resourceRestartThreshold,
prometheus.GaugeValue,
float64(v.RestartThreshold),
v.Type, v.OwnerGroup, v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceRetryPeriodOnFailure,
prometheus.GaugeValue,
float64(v.RetryPeriodOnFailure),
v.Type, v.OwnerGroup, v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceState,
prometheus.GaugeValue,
float64(v.State),
v.Type, v.OwnerGroup, v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceSubClass,
prometheus.GaugeValue,
float64(v.Subclass),
v.Type, v.OwnerGroup, v.Name,
)
ch <- prometheus.MustNewConstMetric(c.resourceOwnerNode, prometheus.GaugeValue, value, resource.Type, resource.OwnerGroup, nodeName, resource.Name)
}
}
return nil
return resultErr
}
@@ -0,0 +1,328 @@
// 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 mscluster
import (
"errors"
"fmt"
"log/slog"
"maps"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
)
type resourceFixtureSource struct {
resources []clusapi.Resource
err error
started chan struct{}
release chan struct{}
closeCount int
}
func (s *resourceFixtureSource) Resources(time.Time) ([]clusapi.Resource, error) {
if s.started != nil {
close(s.started)
<-s.release
}
return s.resources, s.err
}
func (s *resourceFixtureSource) Close() error {
s.closeCount++
return nil
}
type gatheredSamples []prometheus.Metric
func (s gatheredSamples) Describe(ch chan<- *prometheus.Desc) {
for _, metric := range s {
ch <- metric.Desc()
}
}
func (s gatheredSamples) Collect(ch chan<- prometheus.Metric) {
for _, metric := range s {
ch <- metric
}
}
func resourceFixture() clusapi.Resource {
return clusapi.Resource{Name: "Disk", Type: "Physical Disk", OwnerGroup: "Cluster Group", OwnerNode: "nodeB", IdentityValid: true, Values: map[string]uint32{
"Characteristics": 1, "DeadlockTimeout": 2, "EmbeddedFailureAction": 3, "Flags": 4, "IsAlivePollInterval": ^uint32(0), "LooksAlivePollInterval": 6, "MonitorProcessId": 7, "PendingTimeout": 8, "ResourceClass": 1, "RestartAction": 10, "RestartDelay": 11, "RestartPeriod": 12, "RestartThreshold": 13, "RetryPeriodOnFailure": 14, "State": ^uint32(0), "Subclass": 0x80000000,
}}
}
func gatherResources(t *testing.T, resources []clusapi.Resource, inputErr error, nodeNames ...string) (map[string]*dto.MetricFamily, error) {
t.Helper()
c := New(&Config{CollectorsEnabled: []string{subCollectorResource}})
c.buildResourceDescriptors()
ch := make(chan prometheus.Metric, 100)
if len(nodeNames) == 0 {
nodeNames = []string{"nodeA", "nodeB"}
}
publicationDone := make(chan error, 1)
go func() {
publicationDone <- c.publishResources(ch, resources, nodeNames, inputErr)
close(ch)
}()
var samples gatheredSamples
for metric := range ch {
samples = append(samples, metric)
}
err := <-publicationDone
registry := prometheus.NewPedanticRegistry()
if err := registry.Register(samples); err != nil {
t.Fatal(err)
}
families, gatherErr := registry.Gather()
if gatherErr != nil {
t.Fatal(gatherErr)
}
result := make(map[string]*dto.MetricFamily, len(families))
for _, family := range families {
result[family.GetName()] = family
}
return result, err
}
func metricLabels(metric *dto.Metric) map[string]string {
labels := make(map[string]string)
for _, label := range metric.GetLabel() {
labels[label.GetName()] = label.GetValue()
}
return labels
}
func TestResourceNativeMetricContract(t *testing.T) {
families, err := gatherResources(t, []clusapi.Resource{resourceFixture()}, nil)
if err != nil {
t.Fatal(err)
}
expected := map[string]float64{
"characteristics": 1, "deadlock_timeout": 2, "embedded_failure_action": 3, "flags": 4, "is_alive_poll_interval": 4294967295, "looks_alive_poll_interval": 6, "monitor_process_id": 7, "pending_timeout": 8, "resource_class": 1, "restart_action": 10, "restart_delay": 11, "restart_period": 12, "restart_threshold": 13, "retry_period_on_failure": 14, "state": 4294967295, "subclass": 0,
}
if len(families) != 17 {
t.Fatalf("families = %d", len(families))
}
for suffix, value := range expected {
family := families["windows_mscluster_resource_"+suffix]
if family == nil || family.GetType() != dto.MetricType_GAUGE || len(family.GetMetric()) != 1 {
t.Fatalf("invalid family %s: %v", suffix, family)
}
metric := family.GetMetric()[0]
if metric.GetGauge().GetValue() != value {
t.Errorf("%s value = %v, want %v", suffix, metric.GetGauge().GetValue(), value)
}
if !maps.Equal(metricLabels(metric), map[string]string{"type": "Physical Disk", "owner_group": "Cluster Group", "name": "Disk"}) {
t.Fatalf("labels = %v", metricLabels(metric))
}
}
owners := families["windows_mscluster_resource_owner_node"]
if owners.GetType() != dto.MetricType_GAUGE || len(owners.GetMetric()) != 2 {
t.Fatal("invalid owner family")
}
for _, metric := range owners.GetMetric() {
labels := metricLabels(metric)
value := 0.0
if labels["node_name"] == "nodeB" {
value = 1
}
if metric.GetGauge().GetValue() != value {
t.Fatal("invalid owner value")
}
if !maps.Equal(labels, map[string]string{"type": "Physical Disk", "owner_group": "Cluster Group", "name": "Disk", "node_name": labels["node_name"]}) {
t.Fatal("invalid owner labels")
}
}
}
func TestResourcePartialResultsAndJoinedErrors(t *testing.T) {
first, second := errors.New("first cause"), errors.New("second cause")
resource := resourceFixture()
delete(resource.Values, "DeadlockTimeout")
families, err := gatherResources(t, []clusapi.Resource{resource, {Name: "unavailable"}}, errors.Join(first, second))
if !errors.Is(err, first) || !errors.Is(err, second) {
t.Fatalf("lost joined cause: %v", err)
}
if _, exists := families["windows_mscluster_resource_deadlock_timeout"]; exists {
t.Fatal("published missing field as zero")
}
if len(families) != 16 {
t.Fatalf("lost successful properties: %d", len(families))
}
for _, family := range families {
for _, metric := range family.GetMetric() {
if metricLabels(metric)["name"] != "Disk" {
t.Fatal("published invalid identity")
}
}
}
}
func TestResourceOwnerNameCasePreserved(t *testing.T) {
resource := resourceFixture()
resource.OwnerNode = "NODEB"
families, err := gatherResources(t, []clusapi.Resource{resource}, nil)
if err != nil {
t.Fatal(err)
}
for _, metric := range families["windows_mscluster_resource_owner_node"].GetMetric() {
if metric.GetGauge().GetValue() != 0 {
t.Fatal("changed case-sensitive owner matching")
}
}
}
func TestResourceCloseWaitsForCollection(t *testing.T) {
source := &resourceFixtureSource{resources: []clusapi.Resource{resourceFixture()}, started: make(chan struct{}), release: make(chan struct{})}
c := New(&Config{CollectorsEnabled: []string{subCollectorResource}})
c.resourceSource = source
c.buildResourceDescriptors()
collectDone := make(chan error, 1)
go func() { collectDone <- c.Collect(make(chan prometheus.Metric, 100), time.Second) }()
<-source.started
closeDone := make(chan error, 1)
go func() { closeDone <- c.Close() }()
select {
case err := <-closeDone:
t.Fatalf("closed during collection: %v", err)
case <-time.After(10 * time.Millisecond):
}
close(source.release)
if err := <-collectDone; err != nil {
t.Fatal(err)
}
if err := <-closeDone; err != nil {
t.Fatal(err)
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
if source.closeCount != 1 {
t.Fatalf("close count = %d", source.closeCount)
}
}
func TestResourceRepeatedBuildClosesOldSource(t *testing.T) {
source := &resourceFixtureSource{}
c := New(&Config{CollectorsEnabled: []string{"unsupported"}})
c.resourceSource = source
if err := c.Build(slog.Default(), nil); err == nil {
t.Fatal("accepted unsupported collector")
}
if source.closeCount != 1 || c.resourceSource != nil {
t.Fatal("old resource source leaked")
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
}
func BenchmarkResourcePublication(b *testing.B) {
c := New(&Config{CollectorsEnabled: []string{subCollectorResource}})
c.buildResourceDescriptors()
resources := []clusapi.Resource{resourceFixture()}
ch := make(chan prometheus.Metric, 18)
b.ReportAllocs()
for b.Loop() {
if err := c.publishResources(ch, resources, []string{"nodeA", "nodeB"}, nil); err != nil {
b.Fatal(err)
}
for range 18 {
<-ch
}
}
}
func TestResourceLargeClusterGather(t *testing.T) {
resources := make([]clusapi.Resource, 81)
for index := range resources {
resources[index] = resourceFixture()
resources[index].Name = fmt.Sprintf("resource-%d", index)
}
families, err := gatherResources(t, resources, nil)
if err != nil {
t.Fatal(err)
}
if len(families) != 17 {
t.Fatalf("families = %d, want 17", len(families))
}
for name, family := range families {
want := len(resources)
if name == "windows_mscluster_resource_owner_node" {
want *= 2
}
if len(family.GetMetric()) != want {
t.Errorf("%s samples = %d, want %d", name, len(family.GetMetric()), want)
}
}
}
@@ -0,0 +1,253 @@
// 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 mscluster
import (
"errors"
"os"
"reflect"
"slices"
"strings"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/mi"
"google.golang.org/protobuf/proto"
)
// This parity test runs against the CI failover-cluster fixture. Optional local
// hosts may lack the role; fixture-required hosts must never skip native failures.
func TestResourceNativeWMIParity(t *testing.T) {
native, session, required := resourceComparisonSources(t)
query, err := mi.NewQuery("SELECT Name,Type,OwnerGroup,OwnerNode,Characteristics,DeadlockTimeout,EmbeddedFailureAction,Flags,IsAlivePollInterval,LooksAlivePollInterval,MonitorProcessId,PendingTimeout,ResourceClass,RestartAction,RestartDelay,RestartPeriod,RestartThreshold,RetryPeriodOnFailure,State,Subclass FROM MSCluster_Resource")
if err != nil {
t.Fatal(err)
}
var rows []msClusterResource
start := time.Now()
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
t.Fatal(err)
}
t.Logf("WMI resource query duration: %s", time.Since(start))
// The CI cluster provisions online, offline and failed resources. Comparing
// two empty sets proves nothing, so the fixture must provide resources.
if len(rows) == 0 {
if required {
t.Fatal("cluster resource fixture has no resources")
}
t.Log("cluster has no resources; comparing empty sources")
}
expected := make([]clusapi.Resource, 0, len(rows))
for _, row := range rows {
resource := clusapi.Resource{Name: row.Name, Type: row.Type, OwnerGroup: row.OwnerGroup, OwnerNode: row.OwnerNode, IdentityValid: true, Values: make(map[string]uint32)}
value := reflect.ValueOf(row)
for index := 4; index < value.NumField(); index++ {
resource.Values[value.Type().Field(index).Name] = uint32(value.Field(index).Uint())
}
expected = append(expected, resource)
}
start = time.Now()
resources, err := native.Resources(time.Now().Add(time.Minute))
if err != nil {
t.Fatal(err)
}
t.Logf("ClusAPI resource query duration: %s", time.Since(start))
nodeQuery, err := mi.NewQuery("SELECT Name FROM MSCluster_Node")
if err != nil {
t.Fatal(err)
}
var nodes []struct {
Name string `mi:"Name"`
}
if err := session.Query(&nodes, mi.NamespaceRootMSCluster, nodeQuery, time.Minute); err != nil {
t.Fatal(err)
}
nodeNames := make([]string, 0, len(nodes))
for _, node := range nodes {
nodeNames = append(nodeNames, node.Name)
}
if len(nodeNames) == 0 {
t.Fatal("cluster fixture has no nodes")
}
gotFamilies, err := gatherResources(t, resources, nil, nodeNames...)
if err != nil {
t.Fatal(err)
}
wantFamilies, err := gatherResources(t, expected, nil, nodeNames...)
if err != nil {
t.Fatal(err)
}
if len(gotFamilies) != len(wantFamilies) {
t.Fatalf("families = %d, want %d", len(gotFamilies), len(wantFamilies))
}
for name, want := range wantFamilies {
if !proto.Equal(gotFamilies[name], want) {
t.Errorf("native/WMI metric mismatch: %s\nnative: %v\nWMI: %v", name, gotFamilies[name], want)
}
}
if t.Failed() {
logResourceWMINulls(t, session, query)
}
}
// logResourceWMINulls shows which WMI properties are NULL. The WMI collector
// published NULL as 0, so a native value can only match if it is 0 as well.
func logResourceWMINulls(t *testing.T, session *mi.Session, query mi.Query) {
t.Helper()
err := session.QueryFunc(mi.NamespaceRootMSCluster, query, time.Minute, func(instance *mi.Instance) error {
element, err := instance.GetElement("Name")
if err != nil {
return err
}
name, err := element.String()
if err != nil {
return err
}
var nulls []string
for _, property := range []string{"Characteristics", "DeadlockTimeout", "EmbeddedFailureAction", "Flags", "IsAlivePollInterval", "LooksAlivePollInterval", "MonitorProcessId", "PendingTimeout", "ResourceClass", "RestartAction", "RestartDelay", "RestartPeriod", "RestartThreshold", "RetryPeriodOnFailure", "State", "Subclass", "Type", "OwnerGroup", "OwnerNode"} {
element, err := instance.GetElement(property)
if err != nil {
return err
}
if element.IsNull() {
nulls = append(nulls, property)
}
}
t.Logf("WMI resource %q NULL properties: %v", name, nulls)
return nil
})
if err != nil {
t.Logf("WMI NULL diagnostics: %v", err)
}
}
// Resource source comparisons require a live cluster; missing CI fixtures fail.
func resourceComparisonSources(tb testing.TB) (*clusapi.Cluster, *mi.Session, bool) {
tb.Helper()
required := slices.Contains(strings.Split(os.Getenv("WINDOWS_EXPORTER_TEST_COLLECTORS"), ","), Name)
native, err := clusapi.Open()
if err != nil {
if !required && errors.Is(err, errors.ErrUnsupported) {
tb.Skipf("failover cluster role unavailable: %v", err)
}
tb.Fatal(err)
}
tb.Cleanup(func() {
if err := native.Close(); err != nil {
tb.Error(err)
}
})
app, err := mi.ApplicationInitialize()
if err != nil {
tb.Fatal(err)
}
tb.Cleanup(func() {
if err := app.Close(); err != nil {
tb.Error(err)
}
})
session, err := app.NewSession(nil)
if err != nil {
tb.Fatal(err)
}
tb.Cleanup(func() {
if err := session.Close(); err != nil {
tb.Error(err)
}
})
return native, session, required
}
func BenchmarkResourceSources(b *testing.B) {
native, session, _ := resourceComparisonSources(b)
query, err := mi.NewQuery("SELECT Name,Type,OwnerGroup,OwnerNode,Characteristics,DeadlockTimeout,EmbeddedFailureAction,Flags,IsAlivePollInterval,LooksAlivePollInterval,MonitorProcessId,PendingTimeout,ResourceClass,RestartAction,RestartDelay,RestartPeriod,RestartThreshold,RetryPeriodOnFailure,State,Subclass FROM MSCluster_Resource")
if err != nil {
b.Fatal(err)
}
var rows []msClusterResource
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
b.Fatal(err)
}
// Timing two empty enumerations says nothing about resource collection.
if len(rows) == 0 {
b.Skip("cluster has no resources")
}
b.Run("WMI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
var rows []msClusterResource
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
b.Fatal(err)
}
}
})
b.Run("ClusAPI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := native.Resources(time.Now().Add(time.Minute)); err != nil {
b.Fatal(err)
}
}
})
}
@@ -18,18 +18,23 @@
package mscluster
import (
"fmt"
"time"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/osversion"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus/client_golang/prometheus"
)
const nameResourceGroup = Name + "_resourcegroup"
type resourceGroupSource interface {
Groups(deadline time.Time) ([]clusapi.Object, error)
Close() error
}
type collectorResourceGroup struct {
resourceGroupMIQuery mi.Query
resourceGroupSource resourceGroupSource
resourceGroupAutoFailbackType *prometheus.Desc
resourceGroupCharacteristics *prometheus.Desc
@@ -47,7 +52,8 @@ type collectorResourceGroup struct {
resourceGroupState *prometheus.Desc
}
// msClusterResourceGroup represents the MSCluster_ResourceGroup WMI class
// msClusterResourceGroup represents the MSCluster_ResourceGroup WMI class. The
// collector reads ClusAPI; the parity test compares against this WMI model.
// - https://docs.microsoft.com/en-us/previous-versions/windows/desktop/cluswmi/mscluster-resourcegroup
type msClusterResourceGroup struct {
Name string `mi:"Name"`
@@ -69,13 +75,18 @@ type msClusterResourceGroup struct {
}
func (c *Collector) buildResourceGroup() error {
resourceGroupMIQuery, err := mi.NewQuery("SELECT AutoFailbackType,Characteristics,ColdStartSetting,DefaultOwner,FailbackWindowEnd,FailbackWindowStart,FailoverPeriod,FailoverThreshold,Flags,GroupType,OwnerNode,Priority,ResiliencyPeriod,State FROM MSCluster_ResourceGroup")
source, err := clusapi.Open()
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
return err
}
c.resourceGroupMIQuery = resourceGroupMIQuery
c.resourceGroupSource = source
c.buildResourceGroupDescriptors()
return nil
}
func (c *Collector) buildResourceGroupDescriptors() {
c.resourceGroupAutoFailbackType = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, nameResourceGroup, "auto_failback_type"),
"Provides access to the group's AutoFailbackType property.",
@@ -142,12 +153,6 @@ func (c *Collector) buildResourceGroup() error {
[]string{"node_name", "name"},
nil,
)
c.resourceGroupOwnerNode = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, nameResourceGroup, "owner_node"),
"The node hosting the resource group. 0: Not hosted; 1: Hosted",
[]string{"node_name", "name"},
nil,
)
c.resourceGroupPriority = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, nameResourceGroup, "priority"),
"Priority value of the resource group",
@@ -166,131 +171,57 @@ func (c *Collector) buildResourceGroup() error {
[]string{"name"},
nil,
)
var dst []msClusterResourceGroup
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.resourceGroupMIQuery, 0); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
}
return nil
}
// Collect sends the metric values for each metric
// to the provided prometheus Metric channel.
func (c *Collector) collectResourceGroup(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration, nodeNames []string) error {
var dst []msClusterResourceGroup
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.resourceGroupMIQuery, maxScrapeDuration); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
if c.resourceGroupSource == nil {
return errNotBuilt(subCollectorResourceGroup)
}
for _, v := range dst {
ch <- prometheus.MustNewConstMetric(
c.resourceGroupAutoFailbackType,
prometheus.GaugeValue,
float64(v.AutoFailbackType),
v.Name,
)
groups, resultErr := callSource(maxScrapeDuration, c.resourceGroupSource.Groups)
ch <- prometheus.MustNewConstMetric(
c.resourceGroupCharacteristics,
prometheus.GaugeValue,
float64(v.Characteristics),
v.Name,
)
return c.publishResourceGroups(ch, groups, nodeNames, osversion.Build(), resultErr)
}
ch <- prometheus.MustNewConstMetric(
c.resourceGroupColdStartSetting,
prometheus.GaugeValue,
float64(v.ColdStartSetting),
v.Name,
)
func (c *Collector) publishResourceGroups(ch chan<- prometheus.Metric, groups []clusapi.Object, nodeNames []string, build uint16, resultErr error) error {
fields := []objectField{
{name: "AutoFailbackType", desc: c.resourceGroupAutoFailbackType},
{name: "Characteristics", desc: c.resourceGroupCharacteristics},
{name: "ColdStartSetting", desc: c.resourceGroupColdStartSetting, minBuild: osversion.LTSC2016, older: omitIfMissing},
{name: "DefaultOwner", desc: c.resourceGroupDefaultOwner},
{name: "FailbackWindowEnd", desc: c.resourceGroupFailbackWindowEnd, signed: true},
{name: "FailbackWindowStart", desc: c.resourceGroupFailbackWindowStart, signed: true},
{name: "FailoverPeriod", desc: c.resourceGroupFailOverPeriod},
{name: "FailoverThreshold", desc: c.resourceGroupFailOverThreshold},
{name: "Flags", desc: c.resourceGroupFlags},
{name: "GroupType", desc: c.resourceGroupGroupType},
{name: "Priority", desc: c.resourceGroupPriority},
{name: "ResiliencyPeriod", desc: c.resourceGroupResiliencyPeriod, minBuild: osversion.LTSC2016},
{name: "State", desc: c.resourceGroupState},
}
ch <- prometheus.MustNewConstMetric(
c.resourceGroupDefaultOwner,
prometheus.GaugeValue,
float64(v.DefaultOwner),
v.Name,
)
for _, group := range groups {
if group.Name == "" {
continue
}
ch <- prometheus.MustNewConstMetric(
c.resourceGroupFailbackWindowEnd,
prometheus.GaugeValue,
float64(v.FailbackWindowEnd),
v.Name,
)
resultErr = publishObjectFields(ch, "group", group, fields, build, resultErr)
ch <- prometheus.MustNewConstMetric(
c.resourceGroupFailbackWindowStart,
prometheus.GaugeValue,
float64(v.FailbackWindowStart),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceGroupFailOverPeriod,
prometheus.GaugeValue,
float64(v.FailoverPeriod),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceGroupFailOverThreshold,
prometheus.GaugeValue,
float64(v.FailoverThreshold),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceGroupFlags,
prometheus.GaugeValue,
float64(v.Flags),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceGroupGroupType,
prometheus.GaugeValue,
float64(v.GroupType),
v.Name,
)
if !group.OwnerNodeValid {
continue
}
for _, nodeName := range nodeNames {
isCurrentState := 0.0
if v.OwnerNode == nodeName {
if group.OwnerNode == nodeName {
isCurrentState = 1.0
}
ch <- prometheus.MustNewConstMetric(
c.resourceGroupOwnerNode,
prometheus.GaugeValue,
isCurrentState,
nodeName, v.Name,
)
ch <- prometheus.MustNewConstMetric(c.resourceGroupOwnerNode, prometheus.GaugeValue, isCurrentState, nodeName, group.Name)
}
ch <- prometheus.MustNewConstMetric(
c.resourceGroupPriority,
prometheus.GaugeValue,
float64(v.Priority),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceGroupResiliencyPeriod,
prometheus.GaugeValue,
float64(v.ResiliencyPeriod),
v.Name,
)
ch <- prometheus.MustNewConstMetric(
c.resourceGroupState,
prometheus.GaugeValue,
float64(v.State),
v.Name,
)
}
return nil
return resultErr
}
@@ -0,0 +1,381 @@
// 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 mscluster
import (
"errors"
"fmt"
"log/slog"
"maps"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/osversion"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
)
type objectFixtureSource struct {
objects []clusapi.Object
err error
started chan struct{}
release chan struct{}
closeCount int
closeErr error
}
func (s *objectFixtureSource) objectsWithDeadline() ([]clusapi.Object, error) {
if s.started != nil {
close(s.started)
<-s.release
}
return s.objects, s.err
}
func (s *objectFixtureSource) Groups(time.Time) ([]clusapi.Object, error) {
return s.objectsWithDeadline()
}
func (s *objectFixtureSource) Nodes(time.Time) ([]clusapi.Object, error) {
return s.objectsWithDeadline()
}
func (s *objectFixtureSource) Networks(time.Time) ([]clusapi.Object, error) {
return s.objectsWithDeadline()
}
func (s *objectFixtureSource) Close() error {
s.closeCount++
return s.closeErr
}
// gatherPublished registers the published samples in a pedantic registry, so
// invalid descriptors, duplicate series and label mismatches fail the test.
func gatherPublished(t *testing.T, publish func(chan<- prometheus.Metric) error) (map[string]*dto.MetricFamily, error) {
t.Helper()
ch := make(chan prometheus.Metric, 100)
publicationDone := make(chan error, 1)
go func() {
publicationDone <- publish(ch)
close(ch)
}()
var samples gatheredSamples
for metric := range ch {
samples = append(samples, metric)
}
err := <-publicationDone
registry := prometheus.NewPedanticRegistry()
if err := registry.Register(samples); err != nil {
t.Fatal(err)
}
families, gatherErr := registry.Gather()
if gatherErr != nil {
t.Fatal(gatherErr)
}
result := make(map[string]*dto.MetricFamily, len(families))
for _, family := range families {
result[family.GetName()] = family
}
return result, err
}
func resourceGroupFixture() clusapi.Object {
return clusapi.Object{Name: "Cluster Group", OwnerNode: "nodeB", OwnerNodeValid: true, Values: map[string]uint32{
"AutoFailbackType": 1, "Characteristics": 2, "ColdStartSetting": 3, "DefaultOwner": ^uint32(0), "FailbackWindowEnd": ^uint32(0), "FailbackWindowStart": 5, "FailoverPeriod": 6, "FailoverThreshold": ^uint32(0) - 1, "Flags": 1, "GroupType": 9999, "Priority": 3000, "ResiliencyPeriod": 240, "State": ^uint32(0),
}}
}
func gatherResourceGroups(t *testing.T, groups []clusapi.Object, inputErr error, build uint16, nodeNames ...string) (map[string]*dto.MetricFamily, error) {
t.Helper()
c := New(&Config{CollectorsEnabled: []string{subCollectorResourceGroup}})
c.buildResourceGroupDescriptors()
if len(nodeNames) == 0 {
nodeNames = []string{"nodeA", "nodeB"}
}
return gatherPublished(t, func(ch chan<- prometheus.Metric) error {
return c.publishResourceGroups(ch, groups, nodeNames, build, inputErr)
})
}
func TestResourceGroupNativeMetricContract(t *testing.T) {
families, err := gatherResourceGroups(t, []clusapi.Object{resourceGroupFixture()}, nil, osversion.LTSC2022)
if err != nil {
t.Fatal(err)
}
expected := map[string]float64{
"auto_failback_type": 1, "characteristics": 2, "cold_start_setting": 3, "default_owner": 4294967295, "failback_window_end": -1, "failback_window_start": 5, "failover_period": 6, "failover_threshold": 4294967294, "flags": 1, "group_type": 9999, "priority": 3000, "resiliency_period": 240, "state": 4294967295,
}
if len(families) != len(expected)+1 {
t.Fatalf("families = %d", len(families))
}
for suffix, value := range expected {
family := families["windows_mscluster_resourcegroup_"+suffix]
if family == nil || family.GetType() != dto.MetricType_GAUGE || len(family.GetMetric()) != 1 {
t.Fatalf("invalid family %s: %v", suffix, family)
}
metric := family.GetMetric()[0]
if metric.GetGauge().GetValue() != value {
t.Errorf("%s value = %v, want %v", suffix, metric.GetGauge().GetValue(), value)
}
if !maps.Equal(metricLabels(metric), map[string]string{"name": "Cluster Group"}) {
t.Fatalf("labels = %v", metricLabels(metric))
}
}
owners := families["windows_mscluster_resourcegroup_owner_node"]
if owners.GetType() != dto.MetricType_GAUGE || len(owners.GetMetric()) != 2 {
t.Fatal("invalid owner family")
}
for _, metric := range owners.GetMetric() {
labels := metricLabels(metric)
value := 0.0
if labels["node_name"] == "nodeB" {
value = 1
}
if metric.GetGauge().GetValue() != value {
t.Fatal("invalid owner value")
}
if !maps.Equal(labels, map[string]string{"name": "Cluster Group", "node_name": labels["node_name"]}) {
t.Fatalf("invalid owner labels: %v", labels)
}
}
}
func TestResourceGroupPartialResultsAndJoinedErrors(t *testing.T) {
first, second := errors.New("first cause"), errors.New("second cause")
group := resourceGroupFixture()
delete(group.Values, "Priority")
delete(group.Values, "State")
group.OwnerNodeValid = false
families, err := gatherResourceGroups(t, []clusapi.Object{group, {}}, errors.Join(first, second), osversion.LTSC2022)
if !errors.Is(err, first) || !errors.Is(err, second) {
t.Fatalf("lost joined cause: %v", err)
}
for _, name := range []string{"priority", "state", "owner_node"} {
if _, exists := families["windows_mscluster_resourcegroup_"+name]; exists {
t.Fatalf("published unreadable %s", name)
}
}
if len(families) != 11 {
t.Fatalf("lost successful properties: %d", len(families))
}
for _, family := range families {
for _, metric := range family.GetMetric() {
if metricLabels(metric)["name"] != "Cluster Group" {
t.Fatal("published invalid identity")
}
}
}
}
func TestResourceGroupServer2016FieldsOnOlderBuilds(t *testing.T) {
group := resourceGroupFixture()
delete(group.Values, "ColdStartSetting")
delete(group.Values, "ResiliencyPeriod")
families, err := gatherResourceGroups(t, []clusapi.Object{group}, nil, osversion.LTSC2016-1)
if err != nil {
t.Fatalf("missing Windows Server 2016 fields failed an older build: %v", err)
}
if len(families) != 12 {
t.Fatalf("families = %d", len(families))
}
if _, err := gatherResourceGroups(t, []clusapi.Object{group}, nil, osversion.LTSC2022); err == nil {
t.Fatal("missing Windows Server 2016 fields were ignored on a newer build")
}
}
func TestResourceGroupOwnerNameCasePreserved(t *testing.T) {
group := resourceGroupFixture()
group.OwnerNode = "NODEB"
families, err := gatherResourceGroups(t, []clusapi.Object{group}, nil, osversion.LTSC2022)
if err != nil {
t.Fatal(err)
}
for _, metric := range families["windows_mscluster_resourcegroup_owner_node"].GetMetric() {
if metric.GetGauge().GetValue() != 0 {
t.Fatal("changed case-sensitive owner matching")
}
}
}
func TestResourceGroupCloseWaitsForCollection(t *testing.T) {
source := &objectFixtureSource{objects: []clusapi.Object{resourceGroupFixture()}, started: make(chan struct{}), release: make(chan struct{})}
c := New(&Config{CollectorsEnabled: []string{subCollectorResourceGroup}})
c.resourceGroupSource = source
c.buildResourceGroupDescriptors()
collectDone := make(chan error, 1)
go func() { collectDone <- c.Collect(make(chan prometheus.Metric, 100), time.Second) }()
<-source.started
closeDone := make(chan error, 1)
go func() { closeDone <- c.Close() }()
select {
case err := <-closeDone:
t.Fatalf("closed during collection: %v", err)
case <-time.After(10 * time.Millisecond):
}
close(source.release)
if err := <-collectDone; err != nil {
t.Fatal(err)
}
if err := <-closeDone; err != nil {
t.Fatal(err)
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
if source.closeCount != 1 {
t.Fatalf("close count = %d", source.closeCount)
}
}
func TestResourceGroupRepeatedBuildClosesOldSources(t *testing.T) {
resources, groups := &resourceFixtureSource{}, &objectFixtureSource{}
c := New(&Config{CollectorsEnabled: []string{"unsupported"}})
c.resourceSource, c.resourceGroupSource = resources, groups
if err := c.Build(slog.Default(), nil); err == nil {
t.Fatal("accepted unsupported collector")
}
if resources.closeCount != 1 || groups.closeCount != 1 || c.resourceSource != nil || c.resourceGroupSource != nil {
t.Fatal("old sources leaked")
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
}
// clusapi.Cluster releases its handle even when CloseCluster fails, so the
// collector drops the source too and a later Build can create a new one.
func TestCloseSourcesDropsFailedSource(t *testing.T) {
closeErr := errors.New("close failed")
resources, groups := &resourceFixtureSource{}, &objectFixtureSource{closeErr: closeErr}
c := New(&Config{CollectorsEnabled: []string{subCollectorResourceGroup}})
c.resourceSource, c.resourceGroupSource = resources, groups
if err := c.Close(); !errors.Is(err, closeErr) {
t.Fatalf("lost close error: %v", err)
}
if c.resourceSource != nil || c.resourceGroupSource != nil {
t.Fatal("source retained after Close")
}
if err := c.Close(); err != nil || groups.closeCount != 1 {
t.Fatalf("second close: err=%v count=%d", err, groups.closeCount)
}
}
func TestResourceGroupExpiredBudget(t *testing.T) {
if _, err := remainingBudget(time.Millisecond, time.Now().Add(-time.Second)); err == nil {
t.Fatal("expired budget accepted")
}
budget, err := remainingBudget(0, time.Now().Add(-time.Second))
if err != nil || budget != 0 {
t.Fatalf("unlimited budget = %v, %v", budget, err)
}
}
func TestResourceGroupLargeClusterGather(t *testing.T) {
groups := make([]clusapi.Object, 64)
for index := range groups {
groups[index] = resourceGroupFixture()
groups[index].Name = fmt.Sprintf("group-%d", index)
}
families, err := gatherResourceGroups(t, groups, nil, osversion.LTSC2022)
if err != nil {
t.Fatal(err)
}
for name, family := range families {
want := len(groups)
if name == "windows_mscluster_resourcegroup_owner_node" {
want *= 2
}
if len(family.GetMetric()) != want {
t.Errorf("%s samples = %d, want %d", name, len(family.GetMetric()), want)
}
}
}
func BenchmarkResourceGroupPublication(b *testing.B) {
c := New(&Config{CollectorsEnabled: []string{subCollectorResourceGroup}})
c.buildResourceGroupDescriptors()
groups := []clusapi.Object{resourceGroupFixture()}
ch := make(chan prometheus.Metric, 15)
b.ReportAllocs()
for b.Loop() {
if err := c.publishResourceGroups(ch, groups, []string{"nodeA", "nodeB"}, osversion.LTSC2022, nil); err != nil {
b.Fatal(err)
}
for range 15 {
<-ch
}
}
}
@@ -0,0 +1,175 @@
// 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 mscluster
import (
"maps"
"slices"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/osversion"
"google.golang.org/protobuf/proto"
)
const resourceGroupParityQuery = "SELECT Name,AutoFailbackType,Characteristics,ColdStartSetting,DefaultOwner,FailbackWindowEnd,FailbackWindowStart,FailoverPeriod,FailoverThreshold,Flags,GroupType,OwnerNode,Priority,ResiliencyPeriod,State FROM MSCluster_ResourceGroup"
// TestResourceGroupNativeWMIParity compares gathered metric families from
// ClusAPI with the same publication fed by MSCluster_ResourceGroup.
func TestResourceGroupNativeWMIParity(t *testing.T) {
native, session, _ := resourceComparisonSources(t)
query, err := mi.NewQuery(resourceGroupParityQuery)
if err != nil {
t.Fatal(err)
}
var rows []msClusterResourceGroup
start := time.Now()
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
t.Fatal(err)
}
t.Logf("WMI resource group query duration: %s", time.Since(start))
// A configured cluster always has the core cluster group.
if len(rows) == 0 {
t.Fatal("cluster fixture has no resource groups")
}
expected := make([]clusapi.Object, 0, len(rows))
for _, row := range rows {
expected = append(expected, clusapi.Object{Name: row.Name, OwnerNode: row.OwnerNode, OwnerNodeValid: true, Values: map[string]uint32{
"AutoFailbackType": uint32(row.AutoFailbackType),
"Characteristics": uint32(row.Characteristics),
"ColdStartSetting": uint32(row.ColdStartSetting),
"DefaultOwner": uint32(row.DefaultOwner),
"FailbackWindowEnd": uint32(int32(row.FailbackWindowEnd)),
"FailbackWindowStart": uint32(int32(row.FailbackWindowStart)),
"FailoverPeriod": uint32(row.FailoverPeriod),
"FailoverThreshold": uint32(row.FailoverThreshold),
"Flags": uint32(row.Flags),
"GroupType": uint32(row.GroupType),
"Priority": uint32(row.Priority),
"ResiliencyPeriod": uint32(row.ResiliencyPeriod),
"State": uint32(row.State),
}})
}
start = time.Now()
groups, err := native.Groups(time.Now().Add(time.Minute))
if err != nil {
for _, group := range groups {
t.Logf("native group %q DWORD properties: %v", group.Name, slices.Sorted(maps.Keys(group.Values)))
}
t.Fatal(err)
}
t.Logf("ClusAPI resource group query duration: %s, groups: %d", time.Since(start), len(groups))
nodeNames := wmiNodeNames(t, session)
gotFamilies, err := gatherResourceGroups(t, groups, nil, osversion.Build(), nodeNames...)
if err != nil {
t.Error(err)
}
wantFamilies, err := gatherResourceGroups(t, expected, nil, osversion.Build(), nodeNames...)
if err != nil {
t.Fatal(err)
}
if len(gotFamilies) != len(wantFamilies) {
t.Errorf("families = %d, want %d", len(gotFamilies), len(wantFamilies))
}
for name, want := range wantFamilies {
if !proto.Equal(gotFamilies[name], want) {
t.Errorf("native/WMI metric mismatch: %s\nnative: %v\nWMI: %v", name, gotFamilies[name], want)
}
}
if t.Failed() {
for _, group := range groups {
t.Logf("native group %q owner %q DWORD properties: %v", group.Name, group.OwnerNode, group.Values)
}
}
}
func wmiNodeNames(tb testing.TB, session *mi.Session) []string {
tb.Helper()
nodeQuery, err := mi.NewQuery("SELECT Name FROM MSCluster_Node")
if err != nil {
tb.Fatal(err)
}
var nodes []struct {
Name string `mi:"Name"`
}
if err := session.Query(&nodes, mi.NamespaceRootMSCluster, nodeQuery, time.Minute); err != nil {
tb.Fatal(err)
}
nodeNames := make([]string, 0, len(nodes))
for _, node := range nodes {
nodeNames = append(nodeNames, node.Name)
}
if len(nodeNames) == 0 {
tb.Fatal("cluster fixture has no nodes")
}
return nodeNames
}
func BenchmarkResourceGroupSources(b *testing.B) {
native, session, _ := resourceComparisonSources(b)
query, err := mi.NewQuery(resourceGroupParityQuery)
if err != nil {
b.Fatal(err)
}
b.Run("WMI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
var rows []msClusterResourceGroup
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
b.Fatal(err)
}
}
})
b.Run("ClusAPI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := native.Groups(time.Now().Add(time.Minute)); err != nil {
b.Fatal(err)
}
}
})
}
@@ -22,22 +22,30 @@ import (
"strings"
"time"
"github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus/client_golang/prometheus"
"golang.org/x/sys/windows"
)
const nameSharedVolumes = Name + "_shared_volumes"
type sharedVolumesSource interface {
DiskPartitions(deadline time.Time) ([]clusapi.Partition, error)
Close() error
}
type collectorSharedVolumes struct {
sharedVolumesMIQuery mi.Query
sharedVolumesSource sharedVolumesSource
sharedVolumesInfo *prometheus.Desc
sharedVolumesTotalSize *prometheus.Desc
sharedVolumesFreeSpace *prometheus.Desc
}
// msClusterDiskPartition represents the MSCluster_DiskPartition WMI class
// msClusterDiskPartition represents the MSCluster_DiskPartition WMI class. The
// collector reads ClusAPI; the parity test compares against this WMI model.
// TotalSize and FreeSpace are megabytes.
type msClusterDiskPartition struct {
Name string `mi:"Name"`
Path string `mi:"Path"`
@@ -48,13 +56,18 @@ type msClusterDiskPartition struct {
}
func (c *Collector) buildSharedVolumes() error {
sharedVolumesMIQuery, err := mi.NewQuery("SELECT Name, Path, TotalSize, FreeSpace, VolumeLabel, VolumeGuid FROM MSCluster_DiskPartition")
source, err := clusapi.Open()
if err != nil {
return fmt.Errorf("failed to create WMI query: %w", err)
return err
}
c.sharedVolumesMIQuery = sharedVolumesMIQuery
c.sharedVolumesSource = source
c.buildSharedVolumesDescriptors()
return nil
}
func (c *Collector) buildSharedVolumesDescriptors() {
c.sharedVolumesInfo = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, nameSharedVolumes, "info"),
"Cluster Shared Volumes information (value is always 1)",
@@ -75,49 +88,56 @@ func (c *Collector) buildSharedVolumes() error {
[]string{"name", "volume_guid"},
nil,
)
var dst []msClusterDiskPartition
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.sharedVolumesMIQuery, 0); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
}
return nil
}
func (c *Collector) collectSharedVolumes(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
var dst []msClusterDiskPartition
if err := c.miSession.Query(&dst, mi.NamespaceRootMSCluster, c.sharedVolumesMIQuery, maxScrapeDuration); err != nil {
return fmt.Errorf("WMI query failed: %w", err)
if c.sharedVolumesSource == nil {
return errNotBuilt(subCollectorSharedVolumes)
}
for _, partition := range dst {
volume := strings.TrimRight(partition.Volume, " ")
partitions, resultErr := callSource(maxScrapeDuration, c.sharedVolumesSource.DiskPartitions)
return c.publishSharedVolumes(ch, partitions, resultErr)
}
func (c *Collector) publishSharedVolumes(ch chan<- prometheus.Metric, partitions []clusapi.Partition, resultErr error) error {
for _, partition := range partitions {
volume := strings.TrimRight(partition.VolumeLabel, " ")
volumeGUID := formatVolumeGUID(partition.VolumeGUID)
ch <- prometheus.MustNewConstMetric(
c.sharedVolumesInfo,
prometheus.GaugeValue,
1.0,
volume,
partition.Path,
partition.VolumeGuid,
partition.DeviceName,
volumeGUID,
)
// MSCluster_DiskPartition reports whole megabytes; keep that resolution.
ch <- prometheus.MustNewConstMetric(
c.sharedVolumesTotalSize,
prometheus.GaugeValue,
float64(partition.TotalSize)*1024*1024, // Convert from KB to bytes
float64(partition.TotalBytes>>20)*1024*1024,
volume,
partition.VolumeGuid,
volumeGUID,
)
ch <- prometheus.MustNewConstMetric(
c.sharedVolumesFreeSpace,
prometheus.GaugeValue,
float64(partition.FreeSpace)*1024*1024, // Convert from KB to bytes
float64(partition.FreeBytes>>20)*1024*1024,
volume,
partition.VolumeGuid,
volumeGUID,
)
}
return nil
return resultErr
}
// formatVolumeGUID matches MSCluster_DiskPartition.VolumeGuid: lower case,
// without braces.
func formatVolumeGUID(guid windows.GUID) string {
return fmt.Sprintf("%08x-%04x-%04x-%x-%x",
guid.Data1, guid.Data2, guid.Data3, guid.Data4[:2], guid.Data4[2:])
}
@@ -0,0 +1,198 @@
// 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 mscluster
import (
"errors"
"log/slog"
"maps"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"golang.org/x/sys/windows"
)
type partitionFixtureSource struct {
partitions []clusapi.Partition
err error
started chan struct{}
release chan struct{}
closeCount int
}
func (s *partitionFixtureSource) DiskPartitions(time.Time) ([]clusapi.Partition, error) {
if s.started != nil {
close(s.started)
<-s.release
}
return s.partitions, s.err
}
func (s *partitionFixtureSource) Close() error {
s.closeCount++
return nil
}
// partitionFixture matches the example output in #2301: 18128 MiB free.
func partitionFixture() clusapi.Partition {
return clusapi.Partition{
Resource: "Cluster Virtual Disk (ClusterPerformanceHistory)",
DeviceName: `C:\ClusterStorage\Volume1`,
VolumeLabel: "ClusterPerformanceHistory ",
VolumeGUID: windows.GUID{Data1: 0xd3dbada3, Data2: 0x448f, Data3: 0x4c4c, Data4: [8]byte{0xbe, 0x4d, 0x12, 0x2b, 0x7f, 0x29, 0x60, 0x07}},
TotalBytes: 21474836480 + 4096,
FreeBytes: 19008585728 + 1048575,
}
}
func gatherSharedVolumes(t *testing.T, partitions []clusapi.Partition, inputErr error) (map[string]*dto.MetricFamily, error) {
t.Helper()
c := New(&Config{CollectorsEnabled: []string{subCollectorSharedVolumes}})
c.buildSharedVolumesDescriptors()
return gatherPublished(t, func(ch chan<- prometheus.Metric) error {
return c.publishSharedVolumes(ch, partitions, inputErr)
})
}
func TestSharedVolumesNativeMetricContract(t *testing.T) {
families, err := gatherSharedVolumes(t, []clusapi.Partition{partitionFixture()}, nil)
if err != nil {
t.Fatal(err)
}
if len(families) != 3 {
t.Fatalf("families = %d", len(families))
}
labels := map[string]string{"name": "ClusterPerformanceHistory", "volume_guid": "d3dbada3-448f-4c4c-be4d-122b7f296007"}
for name, want := range map[string]float64{"total_bytes": 21474836480, "free_bytes": 1.9008585728e+10} {
family := families["windows_mscluster_shared_volumes_"+name]
if family.GetType() != dto.MetricType_GAUGE || len(family.GetMetric()) != 1 {
t.Fatalf("invalid family %s", name)
}
metric := family.GetMetric()[0]
if metric.GetGauge().GetValue() != want {
t.Errorf("%s = %v, want %v", name, metric.GetGauge().GetValue(), want)
}
if !maps.Equal(metricLabels(metric), labels) {
t.Errorf("%s labels = %v", name, metricLabels(metric))
}
}
info := families["windows_mscluster_shared_volumes_info"].GetMetric()[0]
if info.GetGauge().GetValue() != 1 || !maps.Equal(metricLabels(info), map[string]string{"name": "ClusterPerformanceHistory", "path": `C:\ClusterStorage\Volume1`, "volume_guid": "d3dbada3-448f-4c4c-be4d-122b7f296007"}) {
t.Fatalf("info = %v", metricLabels(info))
}
}
func TestSharedVolumesPartialResults(t *testing.T) {
cause := errors.New("resource failed")
second := partitionFixture()
second.VolumeLabel = "CSV02"
second.VolumeGUID.Data1++
families, err := gatherSharedVolumes(t, []clusapi.Partition{partitionFixture(), second}, cause)
if !errors.Is(err, cause) {
t.Fatalf("lost cause: %v", err)
}
for name, family := range families {
if len(family.GetMetric()) != 2 {
t.Errorf("%s samples = %d", name, len(family.GetMetric()))
}
}
}
func TestSharedVolumesCloseWaitsForCollection(t *testing.T) {
source := &partitionFixtureSource{partitions: []clusapi.Partition{partitionFixture()}, started: make(chan struct{}), release: make(chan struct{})}
c := New(&Config{CollectorsEnabled: []string{subCollectorSharedVolumes}})
c.sharedVolumesSource = source
c.buildSharedVolumesDescriptors()
collectDone := make(chan error, 1)
go func() { collectDone <- c.Collect(make(chan prometheus.Metric, 100), time.Second) }()
<-source.started
closeDone := make(chan error, 1)
go func() { closeDone <- c.Close() }()
select {
case err := <-closeDone:
t.Fatalf("closed during collection: %v", err)
case <-time.After(10 * time.Millisecond):
}
close(source.release)
if err := <-collectDone; err != nil {
t.Fatal(err)
}
if err := <-closeDone; err != nil {
t.Fatal(err)
}
if err := c.Close(); err != nil || source.closeCount != 1 {
t.Fatalf("close count = %d, err = %v", source.closeCount, err)
}
}
func TestSharedVolumesRepeatedBuildClosesOldSource(t *testing.T) {
source := &partitionFixtureSource{}
c := New(&Config{CollectorsEnabled: []string{"unsupported"}})
c.sharedVolumesSource = source
if err := c.Build(slog.Default(), nil); err == nil {
t.Fatal("accepted unsupported collector")
}
if source.closeCount != 1 || c.sharedVolumesSource != nil {
t.Fatal("old shared volumes source leaked")
}
}
func BenchmarkSharedVolumesPublication(b *testing.B) {
c := New(&Config{CollectorsEnabled: []string{subCollectorSharedVolumes}})
c.buildSharedVolumesDescriptors()
partitions := []clusapi.Partition{partitionFixture()}
ch := make(chan prometheus.Metric, 3)
b.ReportAllocs()
for b.Loop() {
if err := c.publishSharedVolumes(ch, partitions, nil); err != nil {
b.Fatal(err)
}
for range 3 {
<-ch
}
}
}
@@ -0,0 +1,138 @@
// 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 mscluster
import (
"strings"
"testing"
"time"
"github.com/prometheus-community/windows_exporter/internal/headers/clusapi"
"github.com/prometheus-community/windows_exporter/internal/mi"
"golang.org/x/sys/windows"
"google.golang.org/protobuf/proto"
)
const sharedVolumesParityQuery = "SELECT Name, Path, TotalSize, FreeSpace, VolumeLabel, VolumeGuid FROM MSCluster_DiskPartition"
// TestSharedVolumesNativeWMIParity compares gathered metric families from
// CLUSCTL_RESOURCE_STORAGE_GET_DISK_INFO_EX with MSCluster_DiskPartition. The
// CI cluster has no clustered disks, so there it compares empty sources.
func TestSharedVolumesNativeWMIParity(t *testing.T) {
native, session, _ := resourceComparisonSources(t)
query, err := mi.NewQuery(sharedVolumesParityQuery)
if err != nil {
t.Fatal(err)
}
var rows []msClusterDiskPartition
start := time.Now()
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
t.Fatal(err)
}
t.Logf("WMI disk partition query duration: %s, partitions: %d", time.Since(start), len(rows))
expected := make([]clusapi.Partition, 0, len(rows))
for _, row := range rows {
guid, err := windows.GUIDFromString("{" + strings.Trim(row.VolumeGuid, "{}") + "}")
if err != nil {
t.Fatalf("WMI VolumeGuid %q: %v", row.VolumeGuid, err)
}
expected = append(expected, clusapi.Partition{
DeviceName: row.Path,
VolumeLabel: row.Volume,
VolumeGUID: guid,
TotalBytes: row.TotalSize << 20,
FreeBytes: row.FreeSpace << 20,
})
}
start = time.Now()
partitions, err := native.DiskPartitions(time.Now().Add(time.Minute))
if err != nil {
t.Error(err)
}
t.Logf("ClusAPI disk partition query duration: %s, partitions: %d", time.Since(start), len(partitions))
gotFamilies, err := gatherSharedVolumes(t, partitions, nil)
if err != nil {
t.Error(err)
}
wantFamilies, err := gatherSharedVolumes(t, expected, nil)
if err != nil {
t.Fatal(err)
}
if len(gotFamilies) != len(wantFamilies) {
t.Errorf("families = %d, want %d", len(gotFamilies), len(wantFamilies))
}
for name, want := range wantFamilies {
if !proto.Equal(gotFamilies[name], want) {
t.Errorf("native/WMI metric mismatch: %s\nnative: %v\nWMI: %v", name, gotFamilies[name], want)
}
}
if t.Failed() {
for _, row := range rows {
t.Logf("WMI partition: name=%q path=%q label=%q guid=%q", row.Name, row.Path, row.Volume, row.VolumeGuid)
}
for _, partition := range partitions {
t.Logf("native partition: %+v", partition)
}
}
}
func BenchmarkSharedVolumesSources(b *testing.B) {
native, session, _ := resourceComparisonSources(b)
query, err := mi.NewQuery(sharedVolumesParityQuery)
if err != nil {
b.Fatal(err)
}
b.Run("WMI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
var rows []msClusterDiskPartition
if err := session.Query(&rows, mi.NamespaceRootMSCluster, query, time.Minute); err != nil {
b.Fatal(err)
}
}
})
b.Run("ClusAPI", func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := native.DiskPartitions(time.Now().Add(time.Minute)); err != nil {
b.Fatal(err)
}
}
})
}
+15 -1
View File
@@ -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,18 @@ func BenchmarkCollector(b *testing.B) {
}
func TestCollector(t *testing.T) {
testutils.TestCollector(t, mscluster.New, nil)
metrics := testutils.TestCollector(t, mscluster.New, nil)
// Resource fixtures from the CI cluster setup cover each resource state.
for name, state := range map[string]float64{
"CI IP Address": 2, // Online
"CI Generic Service": 2, // Online
"CI Offline IP Address": 3, // Offline
"CI Failed Service": 4, // Failed
} {
metric := testutils.RequireFixtureMetric(t, metrics, mscluster.Name, "windows_mscluster_resource_state", prometheus.Labels{"name": name})
if metric != nil && metric.GetGauge().GetValue() != state {
t.Errorf("resource %q state = %v, want %v", name, metric.GetGauge().GetValue(), state)
}
}
}
+690
View File
@@ -0,0 +1,690 @@
// 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 clusapi exposes read-only access to local failover cluster resources.
package clusapi
import (
"context"
"encoding/binary"
"errors"
"fmt"
"sync"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
const (
resourceGetCharacteristics = 0x01000005
resourceGetFlags = 0x01000009
resourceGetROCommonProperties = 0x01000055
resourceGetCommonProperties = 0x01000059
resourceGetType = 0x0100002d
resourceGetClassInfo = 0x0100000d
maxBufferSize = 64 << 20
stateUnknown = ^uint32(0) // ClusterResourceStateUnknown
propertyListBufferSize = 4 << 10
typeBufferSize = 512
)
//nolint:gochecknoglobals
var (
dll = windows.NewLazySystemDLL("clusapi.dll")
getClusterState = dll.NewProc("GetNodeClusterState")
openCluster = dll.NewProc("OpenClusterEx")
closeCluster = dll.NewProc("CloseCluster")
openEnum = dll.NewProc("ClusterOpenEnum")
nextEnum = dll.NewProc("ClusterEnum")
closeEnum = dll.NewProc("ClusterCloseEnum")
openResource = dll.NewProc("OpenClusterResourceEx")
closeResource = dll.NewProc("CloseClusterResource")
resourceControl = dll.NewProc("ClusterResourceControl")
resourceState = dll.NewProc("GetClusterResourceState")
)
// ErrBusy reports that an earlier call on the same Cluster has not returned
// yet. ClusAPI RPCs cannot be cancelled; a call that outlived its scrape keeps
// the cluster handle until the RPC returns, and later calls fail fast instead
// of queuing behind it.
var ErrBusy = errors.New("previous ClusAPI collection is still running")
// nativeAPI holds the ClusAPI calls that drive the handle lifecycle and the
// enumeration. Tests substitute it to exercise these paths without a cluster.
type nativeAPI struct {
openCluster func() (uintptr, error)
closeCluster func(cluster uintptr) error
openEnum func(cluster uintptr, enumType uint32) (uintptr, error)
closeEnum func(enum uintptr) error
enumName func(enum uintptr, index, enumType uint32, deadline time.Time) (objectName, error)
openResource func(cluster uintptr, name objectName) (uintptr, error)
closeResource func(resource uintptr) error
readResource func(resource uintptr, out *Resource, deadline time.Time) error
}
//nolint:gochecknoglobals
var clusAPI = nativeAPI{
openCluster: func() (uintptr, error) {
handle, _, err := openCluster.Call(0, windows.GENERIC_READ, 0)
if handle == 0 {
return 0, callError(err)
}
return handle, nil
},
closeCluster: func(cluster uintptr) error {
if result, _, err := closeCluster.Call(cluster); result == 0 {
return callError(err)
}
return nil
},
openEnum: func(cluster uintptr, enumType uint32) (uintptr, error) {
enum, _, err := openEnum.Call(cluster, uintptr(enumType))
if enum == 0 {
return 0, callError(err)
}
return enum, nil
},
closeEnum: func(enum uintptr) error {
if status, _, _ := closeEnum.Call(enum); status != 0 {
return windows.Errno(status)
}
return nil
},
enumName: enumName,
openResource: func(cluster uintptr, name objectName) (uintptr, error) {
return openObject(openResource, cluster, name)
},
closeResource: func(resource uintptr) error {
return closeObject(closeResource, resource)
},
readResource: readResource,
}
// callError returns the last error of a failed handle or BOOL call. Go clears
// the thread's last error before each call, so 0 means the API set none.
func callError(err error) error {
if errors.Is(err, windows.Errno(0)) {
return errors.New("failed without setting a Windows error code")
}
return err
}
// openObject calls one of the OpenCluster*Ex functions, which share a signature.
func openObject(proc *windows.LazyProc, cluster uintptr, name objectName) (uintptr, error) {
handle, _, err := proc.Call(cluster, uintptr(unsafe.Pointer(&name.units[0])), windows.GENERIC_READ, 0)
if handle == 0 {
return 0, callError(err)
}
return handle, nil
}
// closeObject calls one of the CloseCluster* functions, which return a BOOL.
func closeObject(proc *windows.LazyProc, handle uintptr) error {
if result, _, err := proc.Call(handle); result == 0 {
return callError(err)
}
return nil
}
// Cluster owns a cluster handle. Only one call runs at a time: while a call
// runs it owns the handle, and Close leaves the handle to that call, which
// closes it when its RPC returns. This keeps a hung RPC from blocking Close.
type Cluster struct {
api *nativeAPI
mu sync.Mutex // guards closed and running, and handle when not running
handle uintptr
closed bool
running bool
}
type Resource struct {
Name string
Type string
OwnerGroup string
OwnerNode string
IdentityValid bool
Values map[string]uint32
}
// Open checks that the local node is a configured cluster member. A missing
// clusapi.dll or export reports errors.ErrUnsupported, like a node that is not
// part of a cluster.
func Open() (*Cluster, error) {
for _, proc := range []*windows.LazyProc{getClusterState, openCluster, closeCluster, openEnum, nextEnum, closeEnum, openResource, closeResource, resourceControl, resourceState} {
if err := proc.Find(); err != nil {
return nil, fmt.Errorf("load ClusAPI: %w: %w", errors.ErrUnsupported, err)
}
}
var state uint32
status, _, _ := getClusterState.Call(0, uintptr(unsafe.Pointer(&state)))
if status != 0 {
return nil, fmt.Errorf("GetNodeClusterState: %w", windows.Errno(status))
}
// Installed and configured are separate flags. A configured but stopped
// service remains an operational failure rather than unsupported.
if state&3 != 3 {
return nil, fmt.Errorf("local failover cluster is not configured: %w", errors.ErrUnsupported)
}
// Defer the potentially blocking RPC until collection. A transient service
// outage must not turn resource initialization into an exporter startup failure.
return &Cluster{api: &clusAPI}, nil
}
func (c *Cluster) native() *nativeAPI {
if c.api == nil {
return &clusAPI
}
return c.api
}
// Close releases the cluster handle. A handle whose CloseCluster fails is
// dropped anyway, so a later Build starts from a clean state. If a call is
// still running, it closes the handle when it returns.
func (c *Cluster) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.closed = true
if c.running || c.handle == 0 {
return nil
}
handle := c.handle
c.handle = 0
if err := c.native().closeCluster(handle); err != nil {
return fmt.Errorf("CloseCluster: %w", err)
}
return nil
}
// begin marks a call as running. A call that is still running makes it fail
// with ErrBusy. A successful begin must be paired with end.
func (c *Cluster) begin(deadline time.Time) error {
c.mu.Lock()
defer c.mu.Unlock()
switch {
case c.closed:
return errors.New("cluster is closed")
case c.running:
return ErrBusy
}
if err := checkDeadline(deadline); err != nil {
return err
}
c.running = true
return nil
}
// open opens the cluster handle on first use. The running call owns the
// handle, so the RPC runs without the lock.
func (c *Cluster) open() error {
if c.handle != 0 {
return nil
}
handle, err := c.native().openCluster()
if err != nil {
return fmt.Errorf("OpenClusterEx: %w", err)
}
c.handle = handle
return nil
}
// end finishes a call started by begin. If Close ran meanwhile, the handle is
// released here and the CloseCluster error is returned.
func (c *Cluster) end() error {
c.mu.Lock()
defer c.mu.Unlock()
c.running = false
if !c.closed || c.handle == 0 {
return nil
}
handle := c.handle
c.handle = 0
if err := c.native().closeCluster(handle); err != nil {
return fmt.Errorf("CloseCluster: %w", err)
}
return nil
}
// resetHandle drops a handle whose RPC binding failed, for example after a
// cluster service restart, so the next call reopens the cluster. The caller
// owns the handle through begin. The close error is irrelevant: the call
// reports the error that made the handle unusable.
func (c *Cluster) resetHandle() {
_ = c.native().closeCluster(c.handle)
c.handle = 0
}
// errObjectDeleted marks an object that was deleted after ClusterOpenEnum took
// its snapshot. The enumeration skips it without an error.
var errObjectDeleted = errors.New("object was deleted during the enumeration")
// enumerate calls visit for every object name of a CLUSTER_ENUM type. Visit
// errors are joined per object; the walk stops at the deadline. The caller
// owns the handle through begin.
func (c *Cluster) enumerate(enumType uint32, kind string, deadline time.Time, visit func(name objectName) error) (resultErr error) {
if err := checkDeadline(deadline); err != nil {
return err
}
api := c.native()
enum, err := api.openEnum(c.handle, enumType)
if err != nil {
c.resetHandle()
return fmt.Errorf("ClusterOpenEnum: %w", err)
}
defer func() {
if err := api.closeEnum(enum); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("ClusterCloseEnum: %w", err))
}
}()
for index := uint32(0); ; index++ {
name, err := api.enumName(enum, index, enumType, deadline)
if errors.Is(err, windows.ERROR_NO_MORE_ITEMS) {
return resultErr
}
if _, ok := errors.AsType[invalidEnumEntryError](err); ok {
// One malformed entry must not hide the objects after it.
resultErr = errors.Join(resultErr, fmt.Errorf("ClusterEnum index %d: %w", index, err))
continue
}
if err != nil {
return errors.Join(resultErr, fmt.Errorf("ClusterEnum: %w", err))
}
err = visit(name)
if errors.Is(err, errObjectDeleted) {
continue
}
if err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("%s %q: %w", kind, name.name, err))
}
if errors.Is(err, context.DeadlineExceeded) {
return resultErr
}
}
}
// Resources retains successful resource/property results alongside joined failures.
// Windows ClusAPI RPCs cannot be cancelled. Deadline checks prevent starting
// another RPC after the supplied budget; a call that still outlives it makes
// later calls fail with ErrBusy until it returns.
func (c *Cluster) Resources(deadline time.Time) (_ []Resource, resultErr error) {
if err := c.begin(deadline); err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, c.end()) }()
if err := c.open(); err != nil {
return nil, err
}
api := c.native()
var resources []Resource
err := c.enumerate(enumResource, "resource", deadline, func(name objectName) error {
if err := checkDeadline(deadline); err != nil {
return err
}
handle, err := api.openResource(c.handle, name)
if errors.Is(err, windows.ERROR_RESOURCE_NOT_FOUND) {
return errObjectDeleted
}
resource := Resource{Name: name.name, Values: make(map[string]uint32)}
if err != nil {
resources = append(resources, resource)
return fmt.Errorf("OpenClusterResourceEx: %w", err)
}
err = api.readResource(handle, &resource, deadline)
if closeErr := api.closeResource(handle); closeErr != nil {
err = errors.Join(err, fmt.Errorf("CloseClusterResource: %w", closeErr))
}
resources = append(resources, resource)
return err
})
return resources, err
}
// invalidEnumEntryError reports a ClusterEnum entry that violates the API
// contract. Enumeration continues with the next index.
type invalidEnumEntryError string
func (e invalidEnumEntryError) Error() string { return string(e) }
// objectName keeps the NUL-terminated native name for opening the resource
// next to the decoded label value.
type objectName struct {
units []uint16
name string
}
func enumName(enum uintptr, index, enumType uint32, deadline time.Time) (objectName, error) {
buffer := make([]uint16, 256)
for range 16 {
if err := checkDeadline(deadline); err != nil {
return objectName{}, err
}
length := uint32(len(buffer))
var objectType uint32
status, _, _ := nextEnum.Call(enum, uintptr(index), uintptr(unsafe.Pointer(&objectType)), uintptr(unsafe.Pointer(&buffer[0])), uintptr(unsafe.Pointer(&length)))
if windows.Errno(status) == windows.ERROR_MORE_DATA {
if length >= maxBufferSize/2 || int(length)+1 <= len(buffer) {
return objectName{}, errors.New("invalid ClusterEnum buffer size")
}
buffer = make([]uint16, int(length)+1)
continue
}
if status != 0 {
return objectName{}, windows.Errno(status)
}
if objectType != enumType || length >= uint32(len(buffer)) {
return objectName{}, invalidEnumEntryError(fmt.Sprintf("invalid ClusterEnum entry: type %d, length %d", objectType, length))
}
units := make([]uint16, length+1)
copy(units, buffer[:length])
return objectName{units: units, name: decodeUnits(buffer[:length])}, nil
}
return objectName{}, errors.New("ClusterEnum buffer did not stabilize")
}
// readResource fills the properties of an open resource. Failed properties are
// omitted; the identity is only valid once the state and owner are known.
func readResource(handle uintptr, resource *Resource, deadline time.Time) error {
var resultErr error
hasType := false
for _, code := range []uint32{resourceGetROCommonProperties, resourceGetCommonProperties} {
data, err := resourceBuffer(handle, code, propertyListBufferSize, deadline)
if err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("ClusterResourceControl %#x: %w", code, err))
continue
}
properties, err := ParseProperties(data)
if err != nil {
resultErr = errors.Join(resultErr, err)
continue
}
for name, values := range properties {
if len(values) != 1 {
continue
}
if value, err := values[0].DWORD(); err == nil {
resource.Values[name] = value
}
}
// The read-only common Type property is the same resource type name
// that CLUSCTL_RESOURCE_GET_RESOURCE_TYPE returns.
if values := properties["Type"]; len(values) == 1 {
if value, err := values[0].String(); err == nil {
resource.Type = value
hasType = true
}
}
}
for _, field := range []struct {
name string
code uint32
}{{"Characteristics", resourceGetCharacteristics}, {"Flags", resourceGetFlags}} {
data, err := resourceBuffer(handle, field.code, 4, deadline)
switch {
case err != nil:
resultErr = errors.Join(resultErr, fmt.Errorf("%s: %w", field.name, err))
case len(data) != 4:
resultErr = errors.Join(resultErr, fmt.Errorf("%s: invalid DWORD size", field.name))
default:
resource.Values[field.name] = binary.LittleEndian.Uint32(data)
}
}
data, err := resourceBuffer(handle, resourceGetClassInfo, 8, deadline)
switch {
case err != nil:
resultErr = errors.Join(resultErr, fmt.Errorf("class information: %w", err))
case len(data) != 8:
resultErr = errors.Join(resultErr, errors.New("invalid resource class information size"))
default:
resource.Values["ResourceClass"] = binary.LittleEndian.Uint32(data)
resource.Values["Subclass"] = binary.LittleEndian.Uint32(data[4:])
}
// The property lists normally carry the type; only ask separately without it.
if !hasType {
data, err = resourceBuffer(handle, resourceGetType, typeBufferSize, deadline)
if err != nil {
return errors.Join(resultErr, fmt.Errorf("resource type: %w", err))
}
resource.Type = decodeString(data)
}
state, owner, group, err := readResourceState(handle, deadline)
if err != nil {
return errors.Join(resultErr, fmt.Errorf("GetClusterResourceState: %w", err))
}
resource.Values["State"] = state
resource.OwnerNode = owner
resource.OwnerGroup = group
resource.IdentityValid = true
return resultErr
}
func resourceBuffer(handle uintptr, code uint32, size int, deadline time.Time) ([]byte, error) {
return controlBuffer(deadline, size, func(buffer []byte) (uint32, error) {
var (
size uint32
pointer *byte
)
if len(buffer) != 0 {
pointer = &buffer[0]
}
status, _, _ := resourceControl.Call(handle, 0, uintptr(code), 0, 0, uintptr(unsafe.Pointer(pointer)), uintptr(len(buffer)), uintptr(unsafe.Pointer(&size)))
if status != 0 {
return size, windows.Errno(status)
}
return size, nil
})
}
func readResourceState(handle uintptr, deadline time.Time) (uint32, string, string, error) {
return stateBuffers(deadline, func(node, group []uint16) (uint32, uint32, uint32, error) {
nodeLength, groupLength := uint32(len(node)), uint32(len(group))
state, _, err := resourceState.Call(handle, uintptr(unsafe.Pointer(&node[0])), uintptr(unsafe.Pointer(&nodeLength)), uintptr(unsafe.Pointer(&group[0])), uintptr(unsafe.Pointer(&groupLength)))
// Only ERROR_MORE_DATA matters: the caller retries with larger buffers.
if uint32(state) == stateUnknown && errors.Is(err, windows.ERROR_MORE_DATA) {
return uint32(state), nodeLength, groupLength, err
}
return uint32(state), nodeLength, groupLength, nil
})
}
func stateBuffers(deadline time.Time, call func([]uint16, []uint16) (uint32, uint32, uint32, error)) (uint32, string, string, error) {
node, group := make([]uint16, 256), make([]uint16, 256)
for range 16 {
if err := checkDeadline(deadline); err != nil {
return 0, "", "", err
}
state, nodeLength, groupLength, err := call(node, group)
if errors.Is(err, windows.ERROR_MORE_DATA) {
if nodeLength >= maxBufferSize/2 || groupLength >= maxBufferSize/2 {
return 0, "", "", errors.New("invalid resource state name size")
}
grew := false
if int(nodeLength)+1 > len(node) {
node = make([]uint16, int(nodeLength)+1)
grew = true
}
if int(groupLength)+1 > len(group) {
group = make([]uint16, int(groupLength)+1)
grew = true
}
if !grew {
return 0, "", "", errors.New("resource state name buffer did not grow")
}
continue
}
// ClusterResourceStateUnknown is a state WMI published as 4294967295
// together with the other properties. A failed query may leave the
// names and lengths untouched; the buffers start zeroed, so decoding up
// to the first NUL yields whatever names were returned.
if state == stateUnknown {
return state, decodeUnits(node), decodeUnits(group), nil
}
if uint64(nodeLength) >= uint64(len(node)) || uint64(groupLength) >= uint64(len(group)) {
return 0, "", "", errors.New("invalid resource state names")
}
return state, decodeUnits(node[:nodeLength]), decodeUnits(group[:groupLength]), nil
}
return 0, "", "", errors.New("resource state buffers did not stabilize")
}
func checkDeadline(deadline time.Time) error {
if !deadline.IsZero() && !time.Now().Before(deadline) {
return context.DeadlineExceeded
}
return nil
}
// controlBuffer starts with a buffer of the expected size, so most controls need
// one RPC. ERROR_MORE_DATA still grows the buffer to the reported size; size 0
// starts with a NULL size probe.
func controlBuffer(deadline time.Time, size int, call func([]byte) (uint32, error)) ([]byte, error) {
var buffer []byte
if size > 0 {
buffer = make([]byte, size)
}
for range 16 {
if err := checkDeadline(deadline); err != nil {
return nil, err
}
size, err := call(buffer)
if errors.Is(err, windows.ERROR_MORE_DATA) {
if size > maxBufferSize || int(size) <= len(buffer) {
return nil, errors.New("invalid ClusterResourceControl buffer size")
}
buffer = make([]byte, size)
continue
}
if err != nil {
return nil, err
}
// NULL/zero-size probes may succeed with the required buffer size.
if len(buffer) == 0 && size != 0 {
if size > maxBufferSize {
return nil, errors.New("invalid ClusterResourceControl buffer size")
}
buffer = make([]byte, size)
continue
}
if uint64(size) > uint64(len(buffer)) {
return nil, errors.New("ClusterResourceControl returned an oversized buffer")
}
return buffer[:size], nil
}
return nil, errors.New("ClusterResourceControl buffer did not stabilize")
}
+271
View File
@@ -0,0 +1,271 @@
// 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 clusapi
import (
"context"
"errors"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestControlBuffer(t *testing.T) {
calls := 0
data, err := controlBuffer(time.Time{}, 0, func(buffer []byte) (uint32, error) {
calls++
switch calls {
case 1:
if len(buffer) != 0 {
t.Fatal("first query must not supply buffer")
}
return 4, windows.ERROR_MORE_DATA
case 2:
if len(buffer) != 4 {
t.Fatalf("buffer = %d", len(buffer))
}
return 8, windows.ERROR_MORE_DATA
default:
if len(buffer) != 8 {
t.Fatalf("buffer = %d", len(buffer))
}
copy(buffer, []byte{1, 2, 3, 4})
return 4, nil
}
})
if err != nil || len(data) != 4 || data[3] != 4 || calls != 3 {
t.Fatalf("data = %v, calls = %d, err = %v", data, calls, err)
}
}
func TestControlBufferPresized(t *testing.T) {
calls := 0
data, err := controlBuffer(time.Time{}, 8, func(buffer []byte) (uint32, error) {
calls++
if len(buffer) != 8 {
t.Fatalf("buffer = %d", len(buffer))
}
copy(buffer, []byte{1, 2, 3, 4, 5, 6, 7, 8})
return 8, nil
})
if err != nil || calls != 1 || len(data) != 8 || data[7] != 8 {
t.Fatalf("data = %v, calls = %d, err = %v", data, calls, err)
}
calls = 0
data, err = controlBuffer(time.Time{}, 4, func(buffer []byte) (uint32, error) {
calls++
if calls == 1 {
return 6, windows.ERROR_MORE_DATA
}
if len(buffer) != 6 {
t.Fatalf("buffer = %d", len(buffer))
}
return 6, nil
})
if err != nil || calls != 2 || len(data) != 6 {
t.Fatalf("growth: data = %v, calls = %d, err = %v", data, calls, err)
}
// A pre-sized buffer that is already large enough must not be shrunk or retried.
_, err = controlBuffer(time.Time{}, 8, func([]byte) (uint32, error) { return 4, windows.ERROR_MORE_DATA })
if err == nil {
t.Fatal("accepted ERROR_MORE_DATA without growth")
}
}
func TestControlBufferInvalidSizes(t *testing.T) {
for _, tc := range []struct {
name string
size uint32
err error
}{
{"zero_more_data", 0, windows.ERROR_MORE_DATA},
{"oversized_more_data", maxBufferSize + 1, windows.ERROR_MORE_DATA},
{"oversized_success", maxBufferSize + 1, nil},
{"unrelated_error", 0, windows.ERROR_ACCESS_DENIED},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := controlBuffer(time.Time{}, 0, func([]byte) (uint32, error) { return tc.size, tc.err })
if err == nil {
t.Fatal("accepted invalid native response")
}
if errors.Is(tc.err, windows.ERROR_ACCESS_DENIED) && !errors.Is(err, tc.err) {
t.Fatalf("lost native error: %v", err)
}
})
}
}
func TestControlBufferDeadline(t *testing.T) {
calls := 0
deadline := time.Now().Add(time.Millisecond)
_, err := controlBuffer(deadline, 0, func([]byte) (uint32, error) {
calls++
time.Sleep(2 * time.Millisecond)
return 4, windows.ERROR_MORE_DATA
})
if !errors.Is(err, context.DeadlineExceeded) || calls != 1 {
t.Fatalf("calls = %d, err = %v", calls, err)
}
_, err = controlBuffer(time.Now().Add(-time.Second), 0, func([]byte) (uint32, error) {
t.Fatal("started native call after deadline")
return 0, nil
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
}
func TestClusterClosed(t *testing.T) {
c := &Cluster{}
if err := c.Close(); err != nil {
t.Fatal(err)
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
if _, err := c.Resources(time.Time{}); err == nil {
t.Fatal("read closed cluster")
}
}
func TestStateBuffersGrowthAndBitPatterns(t *testing.T) {
calls := 0
state, node, group, err := stateBuffers(time.Time{}, func(node, group []uint16) (uint32, uint32, uint32, error) {
calls++
if calls == 1 {
return ^uint32(0), 300, 400, windows.ERROR_MORE_DATA
}
if len(node) != 301 || len(group) != 401 {
t.Fatalf("node/group lengths = %d/%d", len(node), len(group))
}
copy(node, []uint16{'N', 0})
copy(group, []uint16{'G', 0})
return 0x80000000, 1, 1, nil
})
if err != nil || state != 0x80000000 || node != "N" || group != "G" || calls != 2 {
t.Fatalf("state=%d node=%s group=%s calls=%d err=%v", state, node, group, calls, err)
}
}
// WMI published ClusterResourceStateUnknown with the other resource properties,
// so the state is a value, not a failure. The names may be left untouched.
func TestStateBuffersUnknownState(t *testing.T) {
state, node, group, err := stateBuffers(time.Time{}, func(node, group []uint16) (uint32, uint32, uint32, error) {
return stateUnknown, uint32(len(node)), uint32(len(group)), nil
})
if err != nil || state != stateUnknown || node != "" || group != "" {
t.Fatalf("untouched: state=%d node=%q group=%q err=%v", state, node, group, err)
}
state, node, group, err = stateBuffers(time.Time{}, func(node, group []uint16) (uint32, uint32, uint32, error) {
copy(node, []uint16{'N', 0})
copy(group, []uint16{'G', 0})
return stateUnknown, 1, 1, nil
})
if err != nil || state != stateUnknown || node != "N" || group != "G" {
t.Fatalf("filled: state=%d node=%q group=%q err=%v", state, node, group, err)
}
}
func TestStateBuffersInvalidNativeResponses(t *testing.T) {
for _, tc := range []struct {
name string
state uint32
nodeSize uint32
groupSize uint32
err error
}{
{"more_data_without_growth", ^uint32(0), 1, 1, windows.ERROR_MORE_DATA},
{"oversized_more_data", ^uint32(0), maxBufferSize, 0, windows.ERROR_MORE_DATA},
{"oversized_success", 2, 256, 0, nil},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, _, err := stateBuffers(time.Time{}, func([]uint16, []uint16) (uint32, uint32, uint32, error) {
return tc.state, tc.nodeSize, tc.groupSize, tc.err
})
if err == nil {
t.Fatal("accepted invalid native response")
}
})
}
}
func TestControlBufferSuccessfulSizeProbe(t *testing.T) {
calls := 0
data, err := controlBuffer(time.Time{}, 0, func(buffer []byte) (uint32, error) {
calls++
if calls == 1 {
return 4, nil
}
copy(buffer, []byte{1, 2, 3, 4})
return 4, nil
})
if err != nil || calls != 2 || len(data) != 4 || data[3] != 4 {
t.Fatalf("successful probe: data=%v calls=%d err=%v", data, calls, err)
}
}
func TestUnopenedClusterExpiredBudget(t *testing.T) {
c := &Cluster{}
if _, err := c.Resources(time.Now().Add(-time.Second)); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expired budget: %v", err)
}
if c.handle != 0 || c.closed {
t.Fatal("expired scrape changed unopened cluster lifecycle")
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
if !c.closed {
t.Fatal("unopened cluster was not closed")
}
}
+264
View File
@@ -0,0 +1,264 @@
// 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 clusapi
import (
"context"
"encoding/binary"
"errors"
"fmt"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
const (
// CLUS_OBJECT_CLUSTER, used for CLUSCTL_CLUSTER_* control codes.
objectCluster = 7
// CLUS_RESCLASS_STORAGE.
resourceClassStorage = 1
)
// GetClusterInformation, ClusterControl and GetClusterQuorumResource are
// available since Windows Server 2008.
//
//nolint:gochecknoglobals
var (
clusterInformation = dll.NewProc("GetClusterInformation")
clusterControl = dll.NewProc("ClusterControl")
quorumResource = dll.NewProc("GetClusterQuorumResource")
)
// Properties reads the cluster name, the 32-bit cluster common properties and
// the quorum values (see readQuorum). A failed name read returns no object.
func (c *Cluster) Properties(deadline time.Time) (_ Object, resultErr error) {
for _, proc := range []*windows.LazyProc{openCluster, clusterInformation, clusterControl, quorumResource, openResource, closeResource, resourceControl} {
if err := proc.Find(); err != nil {
return Object{}, fmt.Errorf("load ClusAPI: %w: %w", errors.ErrUnsupported, err)
}
}
if err := c.begin(deadline); err != nil {
return Object{}, err
}
defer func() { resultErr = errors.Join(resultErr, c.end()) }()
if err := c.open(); err != nil {
return Object{}, err
}
name, err := readClusterName(c.handle, deadline)
if err != nil {
// The name is the first RPC on the handle; a broken binding fails here.
c.resetHandle()
return Object{}, fmt.Errorf("GetClusterInformation: %w", err)
}
object := Object{Name: name, Values: make(map[string]uint32)}
if err := readCommonProperties(clusterControl, c.handle, objectCluster, object.Values, deadline); err != nil {
resultErr = errors.Join(resultErr, err)
if errors.Is(err, context.DeadlineExceeded) {
return object, resultErr
}
}
// The common property is spelled ClusterEnforcedAntiaffinity; WMI and the
// metric use ClusterEnforcedAntiAffinity.
if value, exists := object.Values["ClusterEnforcedAntiaffinity"]; exists {
object.Values["ClusterEnforcedAntiAffinity"] = value
}
if err := c.readQuorum(object.Values, deadline); err != nil {
resultErr = errors.Join(resultErr, err)
}
return object, resultErr
}
// Quorum type values of MSCluster_Cluster.QuorumTypeValue.
const (
quorumTypeNode = 1
quorumTypeFileShareWitness = 2
quorumTypeStorage = 3
)
// readQuorum derives QuorumLogFileSize and QuorumTypeValue from
// GetClusterQuorumResource. The log size parameter carries the quorum type
// (CLUS_NODE_MAJORITY_QUORUM, CLUS_HYBRID_QUORUM or CLUS_LEGACY_QUORUM). A
// cluster without a witness has no quorum resource; otherwise the witness
// resource class decides between a disk and a file share or cloud witness.
// https://learn.microsoft.com/en-us/windows/win32/api/clusapi/nf-clusapi-setclusterquorumresource
func (c *Cluster) readQuorum(values map[string]uint32, deadline time.Time) error {
resource, logSize, err := readQuorumResource(c.handle, deadline)
if err != nil {
return fmt.Errorf("GetClusterQuorumResource: %w", err)
}
values["QuorumLogFileSize"] = logSize
if resource == "" {
values["QuorumTypeValue"] = quorumTypeNode
return nil
}
class, err := c.resourceClass(resource, deadline)
if err != nil {
return fmt.Errorf("quorum resource %q: %w", resource, err)
}
if class == resourceClassStorage {
values["QuorumTypeValue"] = quorumTypeStorage
} else {
values["QuorumTypeValue"] = quorumTypeFileShareWitness
}
return nil
}
// resourceClass returns the CLUS_RESOURCE_CLASS of a resource.
func (c *Cluster) resourceClass(name string, deadline time.Time) (_ uint32, resultErr error) {
if err := checkDeadline(deadline); err != nil {
return 0, err
}
namePtr, err := windows.UTF16PtrFromString(name)
if err != nil {
return 0, err
}
handle, _, err := openResource.Call(c.handle, uintptr(unsafe.Pointer(namePtr)), windows.GENERIC_READ, 0)
if handle == 0 {
return 0, fmt.Errorf("OpenClusterResourceEx: %w", err)
}
defer func() {
result, _, err := closeResource.Call(handle)
if result == 0 {
resultErr = errors.Join(resultErr, fmt.Errorf("CloseClusterResource: %w", err))
}
}()
data, err := resourceBuffer(handle, resourceGetClassInfo, 8, deadline)
if err != nil {
return 0, fmt.Errorf("class information: %w", err)
}
if len(data) != 8 {
return 0, errors.New("invalid resource class information size")
}
return binary.LittleEndian.Uint32(data), nil
}
// readClusterName calls GetClusterInformation without CLUSTERVERSIONINFO.
// https://learn.microsoft.com/en-us/windows/win32/api/clusapi/nf-clusapi-getclusterinformation
func readClusterName(handle uintptr, deadline time.Time) (string, error) {
_, name, _, err := stateBuffers(deadline, func(name, _ []uint16) (uint32, uint32, uint32, error) {
length := uint32(len(name))
status, _, _ := clusterInformation.Call(handle, uintptr(unsafe.Pointer(&name[0])), uintptr(unsafe.Pointer(&length)), 0)
if status != 0 {
return 0, length, 0, windows.Errno(status)
}
return 0, length, 0, nil
})
return name, err
}
// readQuorumResource returns the quorum resource name and the
// lpdwMaxQuorumLogSize value of GetClusterQuorumResource.
func readQuorumResource(handle uintptr, deadline time.Time) (string, uint32, error) {
return quorumBuffers(deadline, func(resource, device []uint16) (uint32, uint32, uint32, error) {
var maxLogSize uint32
resourceLength, deviceLength := uint32(len(resource)), uint32(len(device))
status, _, _ := quorumResource.Call(handle, uintptr(unsafe.Pointer(&resource[0])), uintptr(unsafe.Pointer(&resourceLength)), uintptr(unsafe.Pointer(&device[0])), uintptr(unsafe.Pointer(&deviceLength)), uintptr(unsafe.Pointer(&maxLogSize)))
if status != 0 {
return 0, resourceLength, deviceLength, windows.Errno(status)
}
return maxLogSize, resourceLength, deviceLength, nil
})
}
// quorumBuffers grows the name buffers on ERROR_MORE_DATA. On a node majority
// cluster the CI fixture showed returned lengths that cover NUL characters, so
// the resource name ends at its first NUL within the reported length.
func quorumBuffers(deadline time.Time, call func([]uint16, []uint16) (uint32, uint32, uint32, error)) (string, uint32, error) {
resource, device := make([]uint16, 256), make([]uint16, 256)
for range 16 {
if err := checkDeadline(deadline); err != nil {
return "", 0, err
}
logSize, resourceLength, deviceLength, err := call(resource, device)
if errors.Is(err, windows.ERROR_MORE_DATA) {
if resourceLength >= maxBufferSize/2 || deviceLength >= maxBufferSize/2 {
return "", 0, errors.New("invalid quorum resource name size")
}
grew := false
if int(resourceLength)+1 > len(resource) {
resource = make([]uint16, int(resourceLength)+1)
grew = true
}
if int(deviceLength)+1 > len(device) {
device = make([]uint16, int(deviceLength)+1)
grew = true
}
if !grew {
return "", 0, errors.New("quorum resource name buffer did not grow")
}
continue
}
if err != nil {
return "", 0, err
}
if uint64(resourceLength) >= uint64(len(resource)) {
return "", 0, fmt.Errorf("quorum resource name length %d exceeds buffer", resourceLength)
}
units := resource[:resourceLength]
for index, unit := range units {
if unit == 0 {
units = units[:index]
break
}
}
return decodeUnits(units), logSize, nil
}
return "", 0, errors.New("quorum resource buffers did not stabilize")
}
+104
View File
@@ -0,0 +1,104 @@
// 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 clusapi
import (
"errors"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestQuorumBuffersNodeMajority(t *testing.T) {
// No quorum resource: the reported length can cover NUL characters.
name, logSize, err := quorumBuffers(time.Time{}, func(resource, _ []uint16) (uint32, uint32, uint32, error) {
clear(resource)
return 0, 4, 4, nil
})
if err != nil || name != "" || logSize != 0 {
t.Fatalf("name=%q logSize=%d err=%v", name, logSize, err)
}
}
func TestQuorumBuffersWitness(t *testing.T) {
calls := 0
name, logSize, err := quorumBuffers(time.Time{}, func(resource, device []uint16) (uint32, uint32, uint32, error) {
calls++
if calls == 1 {
return 0, 300, 10, windows.ERROR_MORE_DATA
}
if len(resource) != 301 || len(device) != 256 {
t.Fatalf("resource/device lengths = %d/%d", len(resource), len(device))
}
copy(resource, []uint16{'F', 'S', 'W', 0})
return 0x400, 3, 0, nil
})
if err != nil || name != "FSW" || logSize != 0x400 || calls != 2 {
t.Fatalf("name=%q logSize=%#x calls=%d err=%v", name, logSize, calls, err)
}
}
func TestQuorumBuffersInvalid(t *testing.T) {
for name, call := range map[string]func([]uint16, []uint16) (uint32, uint32, uint32, error){
"oversized_success": func([]uint16, []uint16) (uint32, uint32, uint32, error) { return 0, 256, 0, nil },
"no_growth": func([]uint16, []uint16) (uint32, uint32, uint32, error) { return 0, 1, 1, windows.ERROR_MORE_DATA },
"oversized_growth": func([]uint16, []uint16) (uint32, uint32, uint32, error) {
return 0, maxBufferSize, 0, windows.ERROR_MORE_DATA
},
} {
t.Run(name, func(t *testing.T) {
if _, _, err := quorumBuffers(time.Time{}, call); err == nil {
t.Fatal("accepted invalid native response")
}
})
}
// Names decode like WMI strings: invalid UTF-16 becomes U+FFFD.
name, _, err := quorumBuffers(time.Time{}, func(resource, _ []uint16) (uint32, uint32, uint32, error) {
resource[0] = 0xd800
return 0, 1, 0, nil
})
if err != nil || name != "�" {
t.Fatalf("surrogate: name=%q err=%v", name, err)
}
_, _, err = quorumBuffers(time.Time{}, func([]uint16, []uint16) (uint32, uint32, uint32, error) {
return 0, 0, 0, windows.ERROR_ACCESS_DENIED
})
if !errors.Is(err, windows.ERROR_ACCESS_DENIED) {
t.Fatalf("lost native error: %v", err)
}
}
func TestClusterClosedProperties(t *testing.T) {
c := &Cluster{}
if err := c.Close(); err != nil {
t.Fatal(err)
}
if _, err := c.Properties(time.Time{}); err == nil {
t.Fatal("read closed cluster")
}
}
+171
View File
@@ -0,0 +1,171 @@
// 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 clusapi
import (
"encoding/hex"
"errors"
"fmt"
"os"
"slices"
"strings"
"testing"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
const (
resourceGetROPrivateProperties = 0x0100007d
resourceGetPrivateProperties = 0x01000081
resourceTypeGetROCommonProperties = 0x02000055
resourceTypeGetCommonProperties = 0x02000059
resourceTypeGetPrivateProperties = 0x02000081
)
//nolint:gochecknoglobals
var resourceTypeControl = dll.NewProc("ClusterResourceTypeControl")
// TestDumpPropertyLists logs raw property lists from a live cluster as hex, so
// parser fixtures can be built from real cluster service output. Set
// WINDOWS_EXPORTER_TEST_CLUSAPI_DUMP=1 on a cluster node to enable it.
func TestDumpPropertyLists(t *testing.T) {
if os.Getenv("WINDOWS_EXPORTER_TEST_CLUSAPI_DUMP") == "" {
t.Skip("WINDOWS_EXPORTER_TEST_CLUSAPI_DUMP is not set")
}
cluster, _, err := openCluster.Call(0, windows.GENERIC_READ, 0)
if cluster == 0 {
t.Fatalf("OpenClusterEx: %v", err)
}
defer closeCluster.Call(cluster) //nolint:errcheck
deadline := time.Now().Add(time.Minute)
for _, code := range []uint32{objectCluster<<24 | ctlGetROCommonProperties, objectCluster<<24 | ctlGetCommonProperties} {
data, err := controlBuffer(deadline, 0, func(buffer []byte) (uint32, error) {
var (
size uint32
pointer *byte
)
if len(buffer) != 0 {
pointer = &buffer[0]
}
status, _, _ := clusterControl.Call(cluster, 0, uintptr(code), 0, 0, uintptr(unsafe.Pointer(pointer)), uintptr(len(buffer)), uintptr(unsafe.Pointer(&size)))
if status != 0 {
return size, windows.Errno(status)
}
return size, nil
})
logPropertyList(t, fmt.Sprintf("cluster %#x", code), data, err)
}
// Resource type lists carry EXPAND_SZ values such as DllName, which the
// cluster service returns as an EXPAND_SZ and EXPANDED_SZ value pair.
for _, typeName := range []string{"Generic Service", "IP Address"} {
typePtr, err := windows.UTF16PtrFromString(typeName)
if err != nil {
t.Fatal(err)
}
for _, code := range []uint32{resourceTypeGetROCommonProperties, resourceTypeGetCommonProperties, resourceTypeGetPrivateProperties} {
data, err := controlBuffer(deadline, 0, func(buffer []byte) (uint32, error) {
var (
size uint32
pointer *byte
)
if len(buffer) != 0 {
pointer = &buffer[0]
}
status, _, _ := resourceTypeControl.Call(cluster, uintptr(unsafe.Pointer(typePtr)), 0, uintptr(code), 0, 0, uintptr(unsafe.Pointer(pointer)), uintptr(len(buffer)), uintptr(unsafe.Pointer(&size)))
if status != 0 {
return size, windows.Errno(status)
}
return size, nil
})
logPropertyList(t, fmt.Sprintf("resource type %q %#x", typeName, code), data, err)
}
}
enum, _, err := openEnum.Call(cluster, 4)
if enum == 0 {
t.Fatalf("ClusterOpenEnum: %v", err)
}
defer closeEnum.Call(enum) //nolint:errcheck
for index := uint32(0); ; index++ {
name, err := enumName(enum, index, enumResource, deadline)
if errors.Is(err, windows.ERROR_NO_MORE_ITEMS) {
return
}
if err != nil {
t.Fatalf("ClusterEnum: %v", err)
}
handle, _, err := openResource.Call(cluster, uintptr(unsafe.Pointer(&name.units[0])), windows.GENERIC_READ, 0)
if handle == 0 {
t.Errorf("OpenClusterResourceEx %q: %v", name.name, err)
continue
}
for _, code := range []uint32{resourceGetROCommonProperties, resourceGetCommonProperties, resourceGetROPrivateProperties, resourceGetPrivateProperties} {
data, err := resourceBuffer(handle, code, 0, deadline)
logPropertyList(t, fmt.Sprintf("resource %q %#x", name.name, code), data, err)
}
data, err := resourceBuffer(handle, resourceGetType, 0, deadline)
t.Logf("resource %q GET_RESOURCE_TYPE: %s err=%v", name.name, hex.EncodeToString(data), err)
closeResource.Call(handle) //nolint:errcheck
}
}
func logPropertyList(t *testing.T, label string, data []byte, err error) {
t.Helper()
if err != nil {
t.Logf("%s: %v", label, err)
return
}
properties, parseErr := ParseProperties(data)
formats := make([]string, 0, len(properties))
for name, values := range properties {
valueFormats := make([]string, 0, len(values))
for _, value := range values {
valueFormats = append(valueFormats, fmt.Sprintf("%d/%d", value.Format, len(value.Data)))
}
formats = append(formats, name+"="+strings.Join(valueFormats, "+"))
}
slices.Sort(formats)
t.Logf("%s: parse error=%v formats=[%s]\n%s", label, parseErr, strings.Join(formats, " "), hex.EncodeToString(data))
}
+229
View File
@@ -0,0 +1,229 @@
// 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 clusapi
import (
"errors"
"sync"
"testing"
"time"
"golang.org/x/sys/windows"
)
const fakeClusterHandle = 0x10
// fakeAPI is a nativeAPI over an in-memory resource list. Names listed in
// deleted fail to open like resources removed after the enumeration snapshot.
type fakeAPI struct {
mu sync.Mutex
opens int
closes []uintptr
openEnumErr []error
closeErr error
names []string
deleted map[string]bool
// blockEnum, if set, is closed when the first enumeration starts and
// waited on before it continues.
started chan struct{}
blockEnum chan struct{}
}
func (f *fakeAPI) api() *nativeAPI {
return &nativeAPI{
openCluster: func() (uintptr, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.opens++
return fakeClusterHandle, nil
},
closeCluster: func(cluster uintptr) error {
f.mu.Lock()
defer f.mu.Unlock()
f.closes = append(f.closes, cluster)
return f.closeErr
},
openEnum: func(uintptr, uint32) (uintptr, error) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.openEnumErr) != 0 {
err := f.openEnumErr[0]
f.openEnumErr = f.openEnumErr[1:]
return 0, err
}
return 1, nil
},
closeEnum: func(uintptr) error { return nil },
enumName: func(_ uintptr, index, _ uint32, _ time.Time) (objectName, error) {
if index == 0 && f.blockEnum != nil {
close(f.started)
<-f.blockEnum
}
if int(index) >= len(f.names) {
return objectName{}, windows.ERROR_NO_MORE_ITEMS
}
name := f.names[index]
return objectName{units: append(windowsUnits(name), 0), name: name}, nil
},
openResource: func(_ uintptr, name objectName) (uintptr, error) {
if f.deleted[name.name] {
return 0, windows.ERROR_RESOURCE_NOT_FOUND
}
return 2, nil
},
closeResource: func(uintptr) error { return nil },
readResource: func(_ uintptr, resource *Resource, _ time.Time) error {
resource.Values["State"] = 2
resource.IdentityValid = true
return nil
},
}
}
func windowsUnits(name string) []uint16 {
units := make([]uint16, 0, len(name))
for _, r := range name {
units = append(units, uint16(r))
}
return units
}
// A ClusAPI RPC that outlives its scrape must not queue later calls behind it,
// and Close must not wait for it: the running call releases the handle.
func TestClusterBusyAndCloseWhileRunning(t *testing.T) {
fake := &fakeAPI{names: []string{"a"}, started: make(chan struct{}), blockEnum: make(chan struct{})}
c := &Cluster{api: fake.api()}
done := make(chan error, 1)
go func() {
_, err := c.Resources(time.Time{})
done <- err
}()
<-fake.started
if _, err := c.Resources(time.Time{}); !errors.Is(err, ErrBusy) {
t.Fatalf("second call: %v, want ErrBusy", err)
}
if err := c.Close(); err != nil {
t.Fatalf("Close while running: %v", err)
}
fake.mu.Lock()
closes := len(fake.closes)
fake.mu.Unlock()
if closes != 0 {
t.Fatal("Close released the handle of a running call")
}
close(fake.blockEnum)
if err := <-done; err != nil {
t.Fatal(err)
}
if len(fake.closes) != 1 || fake.closes[0] != fakeClusterHandle || c.handle != 0 {
t.Fatalf("running call did not release the handle: %v", fake.closes)
}
if _, err := c.Resources(time.Time{}); err == nil || errors.Is(err, ErrBusy) {
t.Fatalf("closed cluster: %v", err)
}
}
// A failed ClusterOpenEnum usually means a broken RPC binding. The handle is
// dropped so the next scrape reopens the cluster.
func TestClusterReopensAfterOpenEnumFailure(t *testing.T) {
fake := &fakeAPI{names: []string{"a"}, openEnumErr: []error{windows.RPC_S_SERVER_UNAVAILABLE}}
c := &Cluster{api: fake.api()}
if _, err := c.Resources(time.Time{}); !errors.Is(err, windows.RPC_S_SERVER_UNAVAILABLE) {
t.Fatalf("lost native error: %v", err)
}
if c.handle != 0 || len(fake.closes) != 1 {
t.Fatalf("handle = %#x, closes = %v", c.handle, fake.closes)
}
resources, err := c.Resources(time.Time{})
if err != nil || len(resources) != 1 || fake.opens != 2 {
t.Fatalf("resources = %v, opens = %d, err = %v", resources, fake.opens, err)
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
}
func TestResourcesSkipDeletedResources(t *testing.T) {
fake := &fakeAPI{names: []string{"gone", "kept"}, deleted: map[string]bool{"gone": true}}
c := &Cluster{api: fake.api()}
resources, err := c.Resources(time.Time{})
if err != nil || len(resources) != 1 || resources[0].Name != "kept" || !resources[0].IdentityValid {
t.Fatalf("resources = %v, err = %v", resources, err)
}
if err := c.Close(); err != nil {
t.Fatal(err)
}
}
// A handle whose CloseCluster fails is dropped, so Build can start over.
func TestClusterCloseDropsHandleOnFailure(t *testing.T) {
fake := &fakeAPI{closeErr: windows.ERROR_INVALID_HANDLE}
c := &Cluster{api: fake.api(), handle: fakeClusterHandle}
if err := c.Close(); !errors.Is(err, windows.ERROR_INVALID_HANDLE) {
t.Fatalf("lost native error: %v", err)
}
if c.handle != 0 || !c.closed {
t.Fatal("failed close kept the handle")
}
if err := c.Close(); err != nil {
t.Fatalf("second Close: %v", err)
}
}
func TestCallErrorWithoutLastError(t *testing.T) {
if err := callError(windows.Errno(0)); err == nil || errors.Is(err, windows.Errno(0)) {
t.Fatalf("callError(0) = %v", err)
}
if err := callError(windows.ERROR_ACCESS_DENIED); !errors.Is(err, windows.ERROR_ACCESS_DENIED) {
t.Fatalf("lost native error: %v", err)
}
}
+359
View File
@@ -0,0 +1,359 @@
// 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 clusapi
import (
"context"
"encoding/binary"
"errors"
"fmt"
"time"
"unsafe"
"golang.org/x/sys/windows"
)
// Errors of the OpenCluster*Ex functions for an object that no longer exists.
const (
errGroupNotFound windows.Errno = 5013 // ERROR_GROUP_NOT_FOUND
errNodeNotFound windows.Errno = 5042 // ERROR_CLUSTER_NODE_NOT_FOUND
errNetworkNotFound windows.Errno = 5045 // ERROR_CLUSTER_NETWORK_NOT_FOUND
)
// CLUSTER_ENUM values for ClusterOpenEnum.
// https://learn.microsoft.com/en-us/windows/win32/api/clusapi/ne-clusapi-cluster_enum
const (
enumNode = 0x01
enumResource = 0x04
enumGroup = 0x08
enumNetwork = 0x10
)
// CLUS_OBJECT_* identifiers and CLCTL_* operations. A CLUSCTL_* control code is
// (object << 24) | operation, for example CLUSCTL_GROUP_GET_FLAGS = 0x03000009.
// https://learn.microsoft.com/en-us/previous-versions/windows/desktop/mscs/control-code-architecture
const (
objectGroup = 3
objectNode = 4
objectNetwork = 5
ctlGetCharacteristics = 0x05
ctlGetFlags = 0x09
ctlGetROCommonProperties = 0x55
ctlGetCommonProperties = 0x59
)
// The Ex open functions and the control/state functions below are available
// since Windows Server 2008 R2, so they cover Windows Server 2012 R2 and 2016.
// ClusterOpenEnumEx and the per-type *OpenEnumEx functions (Windows Server 2016+)
// are deliberately not used.
//
//nolint:gochecknoglobals
var (
openGroup = dll.NewProc("OpenClusterGroupEx")
closeGroup = dll.NewProc("CloseClusterGroup")
groupControl = dll.NewProc("ClusterGroupControl")
groupState = dll.NewProc("GetClusterGroupState")
openNode = dll.NewProc("OpenClusterNodeEx")
closeNode = dll.NewProc("CloseClusterNode")
nodeControl = dll.NewProc("ClusterNodeControl")
nodeState = dll.NewProc("GetClusterNodeState")
openNetwork = dll.NewProc("OpenClusterNetworkEx")
closeNetwork = dll.NewProc("CloseClusterNetwork")
networkControl = dll.NewProc("ClusterNetworkControl")
networkState = dll.NewProc("GetClusterNetworkState")
)
// Object is a cluster group, node or network. Values contains the 32-bit
// common properties plus Characteristics, Flags and State. A missing key means
// the value could not be read; it is never substituted with zero.
type Object struct {
Name string
// OwnerNode is the hosting node of a group. OwnerNodeValid is false when
// the group state could not be read or the object has no owner concept.
OwnerNode string
OwnerNodeValid bool
Values map[string]uint32
}
type objectAPI struct {
kind string
enumType uint32
object uint32
// notFound is the error of the open function for a deleted object.
notFound windows.Errno
open *windows.LazyProc
close *windows.LazyProc
control *windows.LazyProc
// state reads the object state. It returns the owner node name for groups.
state func(handle uintptr, deadline time.Time) (uint32, string, bool, error)
}
//nolint:gochecknoglobals
var groupAPI = objectAPI{
kind: "group",
enumType: enumGroup,
object: objectGroup,
notFound: errGroupNotFound,
open: openGroup,
close: closeGroup,
control: groupControl,
state: readGroupState,
}
//nolint:gochecknoglobals
var nodeAPI = objectAPI{
kind: "node",
enumType: enumNode,
object: objectNode,
notFound: errNodeNotFound,
open: openNode,
close: closeNode,
control: nodeControl,
state: simpleState(nodeState),
}
//nolint:gochecknoglobals
var networkAPI = objectAPI{
kind: "network",
enumType: enumNetwork,
object: objectNetwork,
notFound: errNetworkNotFound,
open: openNetwork,
close: closeNetwork,
control: networkControl,
state: simpleState(networkState),
}
// Networks reads every cluster network; see Groups for the result contract.
func (c *Cluster) Networks(deadline time.Time) ([]Object, error) {
return c.objects(&networkAPI, deadline)
}
// Nodes reads every cluster node; see Groups for the result contract.
func (c *Cluster) Nodes(deadline time.Time) ([]Object, error) {
return c.objects(&nodeAPI, deadline)
}
// Groups reads every cluster group. Successful groups and properties are
// retained alongside joined failures; see Resources for the deadline contract.
func (c *Cluster) Groups(deadline time.Time) ([]Object, error) {
return c.objects(&groupAPI, deadline)
}
func (c *Cluster) objects(api *objectAPI, deadline time.Time) (_ []Object, resultErr error) {
for _, proc := range []*windows.LazyProc{openCluster, openEnum, nextEnum, closeEnum, api.open, api.close, api.control} {
if err := proc.Find(); err != nil {
return nil, fmt.Errorf("load ClusAPI: %w: %w", errors.ErrUnsupported, err)
}
}
if err := c.begin(deadline); err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, c.end()) }()
if err := c.open(); err != nil {
return nil, err
}
var objects []Object
err := c.enumerate(api.enumType, api.kind, deadline, func(name objectName) error {
object, err := c.readObject(api, name, deadline)
if errors.Is(err, errObjectDeleted) {
return err
}
objects = append(objects, object)
return err
})
return objects, err
}
func (c *Cluster) readObject(api *objectAPI, name objectName, deadline time.Time) (_ Object, resultErr error) {
object := Object{Name: name.name, Values: make(map[string]uint32)}
if err := checkDeadline(deadline); err != nil {
return object, err
}
handle, err := openObject(api.open, c.handle, name)
if errors.Is(err, api.notFound) {
return object, errObjectDeleted
}
if err != nil {
return object, fmt.Errorf("%s: %w", api.open.Name, err)
}
defer func() {
if err := closeObject(api.close, handle); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("%s: %w", api.close.Name, err))
}
}()
if err := readCommonProperties(api.control, handle, api.object, object.Values, deadline); err != nil {
resultErr = errors.Join(resultErr, err)
if errors.Is(err, context.DeadlineExceeded) {
return object, resultErr
}
}
for _, field := range []struct {
name string
operation uint32
}{{"Characteristics", ctlGetCharacteristics}, {"Flags", ctlGetFlags}} {
data, err := objectBuffer(api.control, handle, api.object<<24|field.operation, 4, deadline)
switch {
case err != nil:
resultErr = errors.Join(resultErr, fmt.Errorf("%s: %w", field.name, err))
if errors.Is(err, context.DeadlineExceeded) {
return object, resultErr
}
case len(data) != 4:
resultErr = errors.Join(resultErr, fmt.Errorf("%s: invalid DWORD size %d", field.name, len(data)))
default:
object.Values[field.name] = binary.LittleEndian.Uint32(data)
}
}
state, owner, ownerValid, err := api.state(handle, deadline)
if err != nil {
return object, errors.Join(resultErr, fmt.Errorf("state: %w", err))
}
object.Values["State"] = state
object.OwnerNode = owner
object.OwnerNodeValid = ownerValid
return object, resultErr
}
// readCommonProperties stores the single 32-bit values of the read-only and
// read/write common property lists in values. Other formats are skipped.
func readCommonProperties(control *windows.LazyProc, handle uintptr, object uint32, values map[string]uint32, deadline time.Time) error {
var resultErr error
for _, operation := range []uint32{ctlGetROCommonProperties, ctlGetCommonProperties} {
code := object<<24 | operation
data, err := objectBuffer(control, handle, code, propertyListBufferSize, deadline)
if err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("%s %#x: %w", control.Name, code, err))
if errors.Is(err, context.DeadlineExceeded) {
return resultErr
}
continue
}
properties, err := ParseProperties(data)
if err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("%s %#x: %w", control.Name, code, err))
continue
}
for name, list := range properties {
if len(list) != 1 {
continue
}
if value, err := list[0].Value32(); err == nil {
values[name] = value
}
}
}
return resultErr
}
// objectBuffer calls a Cluster{Group,Node,Network}Control function. They share
// the ClusterResourceControl signature and return a DWORD status code.
func objectBuffer(proc *windows.LazyProc, handle uintptr, code uint32, size int, deadline time.Time) ([]byte, error) {
return controlBuffer(deadline, size, func(buffer []byte) (uint32, error) {
var (
size uint32
pointer *byte
)
if len(buffer) != 0 {
pointer = &buffer[0]
}
status, _, _ := proc.Call(handle, 0, uintptr(code), 0, 0, uintptr(unsafe.Pointer(pointer)), uintptr(len(buffer)), uintptr(unsafe.Pointer(&size)))
if status != 0 {
return size, windows.Errno(status)
}
return size, nil
})
}
// readGroupState calls GetClusterGroupState. Like the resource state,
// ClusterGroupStateUnknown (-1) is published as a value, as WMI did; the last
// error only matters for ERROR_MORE_DATA.
// https://learn.microsoft.com/en-us/windows/win32/api/clusapi/nf-clusapi-getclustergroupstate
func readGroupState(handle uintptr, deadline time.Time) (uint32, string, bool, error) {
if err := groupState.Find(); err != nil {
return 0, "", false, err
}
// stateBuffers handles a second name; groups have only an owner node, so
// the unused buffer stays empty.
state, node, _, err := stateBuffers(deadline, func(node, _ []uint16) (uint32, uint32, uint32, error) {
nodeLength := uint32(len(node))
state, _, err := groupState.Call(handle, uintptr(unsafe.Pointer(&node[0])), uintptr(unsafe.Pointer(&nodeLength)))
if uint32(state) == stateUnknown && errors.Is(err, windows.ERROR_MORE_DATA) {
return uint32(state), nodeLength, 0, err
}
return uint32(state), nodeLength, 0, nil
})
if err != nil {
return 0, "", false, err
}
return state, node, true, nil
}
// simpleState wraps GetClusterNodeState and GetClusterNetworkState. Both take
// only the object handle. Their unknown state (-1) is published as a value,
// as WMI did.
func simpleState(proc *windows.LazyProc) func(uintptr, time.Time) (uint32, string, bool, error) {
return func(handle uintptr, deadline time.Time) (uint32, string, bool, error) {
if err := proc.Find(); err != nil {
return 0, "", false, err
}
if err := checkDeadline(deadline); err != nil {
return 0, "", false, err
}
state, _, _ := proc.Call(handle)
return uint32(state), "", false, nil
}
}
+109
View File
@@ -0,0 +1,109 @@
// 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 clusapi
import (
"context"
"errors"
"testing"
"time"
"golang.org/x/sys/windows"
)
func TestControlCodes(t *testing.T) {
// Values from clusapi.h (CLUSCTL_GROUP_CODES).
for code, want := range map[uint32]uint32{
objectGroup<<24 | ctlGetCharacteristics: 0x03000005,
objectGroup<<24 | ctlGetFlags: 0x03000009,
objectGroup<<24 | ctlGetROCommonProperties: 0x03000055,
objectGroup<<24 | ctlGetCommonProperties: 0x03000059,
// CLUSCTL_NODE_CODES.
objectNode<<24 | ctlGetCharacteristics: 0x04000005,
objectNode<<24 | ctlGetFlags: 0x04000009,
objectNode<<24 | ctlGetROCommonProperties: 0x04000055,
objectNode<<24 | ctlGetCommonProperties: 0x04000059,
// CLUSCTL_NETWORK_CODES.
objectNetwork<<24 | ctlGetCharacteristics: 0x05000005,
objectNetwork<<24 | ctlGetFlags: 0x05000009,
objectNetwork<<24 | ctlGetROCommonProperties: 0x05000055,
objectNetwork<<24 | ctlGetCommonProperties: 0x05000059,
} {
if code != want {
t.Errorf("control code %#x, want %#x", code, want)
}
}
if resourceGetROCommonProperties != 1<<24|ctlGetROCommonProperties || resourceGetFlags != 1<<24|ctlGetFlags {
t.Error("object control codes diverge from the resource control codes")
}
}
func TestGroupStateBuffers(t *testing.T) {
calls := 0
state, node, _, err := stateBuffers(time.Time{}, func(node, _ []uint16) (uint32, uint32, uint32, error) {
calls++
if calls == 1 {
return ^uint32(0), 300, 0, windows.ERROR_MORE_DATA
}
copy(node, []uint16{'N', 'B', 0})
return 0, 2, 0, nil
})
if err != nil || state != 0 || node != "NB" || calls != 2 {
t.Fatalf("state=%d node=%q calls=%d err=%v", state, node, calls, err)
}
}
func TestClusterClosedObjects(t *testing.T) {
c := &Cluster{}
if err := c.Close(); err != nil {
t.Fatal(err)
}
if _, err := c.Groups(time.Time{}); err == nil {
t.Fatal("read closed cluster")
}
if _, err := c.Nodes(time.Time{}); err == nil {
t.Fatal("read closed cluster")
}
if _, err := c.Networks(time.Time{}); err == nil {
t.Fatal("read closed cluster")
}
}
func TestUnopenedClusterObjectsExpiredBudget(t *testing.T) {
c := &Cluster{}
_, err := c.Groups(time.Now().Add(-time.Second))
if errors.Is(err, context.DeadlineExceeded) {
if c.handle != 0 || c.closed {
t.Fatal("expired scrape changed unopened cluster lifecycle")
}
return
}
// Hosts without the Failover Clustering feature lack clusapi.dll.
if err == nil {
t.Fatal("expired budget accepted")
}
}
+188
View File
@@ -0,0 +1,188 @@
// 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.
package clusapi
import (
"encoding/binary"
"errors"
"fmt"
"unicode/utf16"
)
const (
propertyName = 0x00040003
propertyValue = 0x00010000
formatDWORD = 2
formatString = 3
formatExpandString = 4
formatLong = 7
formatExpandedString = 8
)
// Property is a single CLUSPROP_LIST_VALUE. Values retain their native format;
// unsupported formats can be skipped without confusing an absent DWORD with zero.
type Property struct {
Format uint16
Data []byte
}
func (p Property) DWORD() (uint32, error) {
if p.Format != formatDWORD || len(p.Data) != 4 {
return 0, fmt.Errorf("expected DWORD, format %d, length %d", p.Format, len(p.Data))
}
return binary.LittleEndian.Uint32(p.Data), nil
}
// String decodes an SZ, EXPAND_SZ or EXPANDED_SZ value like WMI does: the value
// ends at the first NUL and invalid UTF-16 is replaced with U+FFFD.
func (p Property) String() (string, error) {
switch p.Format {
case formatString, formatExpandString, formatExpandedString:
return decodeString(p.Data), nil
default:
return "", fmt.Errorf("expected string, format %d", p.Format)
}
}
// Value32 returns the bit pattern of a DWORD or LONG value. Callers decide the
// signedness, matching the WMI type of the property they publish.
func (p Property) Value32() (uint32, error) {
if (p.Format != formatDWORD && p.Format != formatLong) || len(p.Data) != 4 {
return 0, fmt.Errorf("expected DWORD or LONG, format %d, length %d", p.Format, len(p.Data))
}
return binary.LittleEndian.Uint32(p.Data), nil
}
// ParseProperties decodes CLUSPROP_LIST without casting untrusted bytes to native
// structs. Headers and payloads are DWORD aligned; cbLength is measured in bytes.
// Only the structure is validated: string contents are decoded when used, so one
// unusual value does not discard the whole list.
// https://learn.microsoft.com/en-us/previous-versions/windows/desktop/mscs/property-lists
func ParseProperties(data []byte) (map[string][]Property, error) {
if len(data) < 4 {
return nil, errors.New("property list is missing its count")
}
count := binary.LittleEndian.Uint32(data)
data = data[4:]
if uint64(count) > uint64(len(data))/16 {
return nil, errors.New("property count exceeds buffer")
}
properties := make(map[string][]Property, count)
for range count {
if len(data) < 8 || binary.LittleEndian.Uint32(data) != propertyName {
return nil, errors.New("invalid property name header")
}
nameBytes, rest, err := propertyPayload(data)
if err != nil {
return nil, err
}
name := decodeString(nameBytes)
data = rest
values := make([]Property, 0, 1)
for {
if len(data) < 4 {
return nil, fmt.Errorf("missing endmark for %q", name)
}
syntax := binary.LittleEndian.Uint32(data)
if syntax == 0 {
data = data[4:]
break
}
if syntax&0xffff0000 != propertyValue {
return nil, fmt.Errorf("invalid value syntax %#x for %q", syntax, name)
}
payload, rest, err := propertyPayload(data)
if err != nil {
return nil, fmt.Errorf("property %q: %w", name, err)
}
format := uint16(syntax)
if (format == formatDWORD || format == formatLong) && len(payload) != 4 {
return nil, fmt.Errorf("invalid DWORD length for %q", name)
}
values = append(values, Property{Format: format, Data: payload})
data = rest
}
// Keep the first occurrence; a repeated name does not affect the structure.
if _, exists := properties[name]; !exists {
properties[name] = values
}
}
// Lists returned by the cluster service end with a CLUSPROP_SYNTAX_ENDMARK
// after the last value list. Accept zero padding, reject anything else.
for _, b := range data {
if b != 0 {
return nil, fmt.Errorf("%d bytes of trailing property list data starting with % x", len(data), data[:min(len(data), 16)])
}
}
return properties, nil
}
func propertyPayload(data []byte) ([]byte, []byte, error) {
if len(data) < 8 {
return nil, nil, errors.New("truncated property header")
}
length := uint64(binary.LittleEndian.Uint32(data[4:]))
aligned := (length + 3) &^ uint64(3)
if aligned > uint64(len(data)-8) {
return nil, nil, errors.New("property payload exceeds buffer")
}
return data[8 : 8+length], data[8+aligned:], nil
}
// decodeString decodes little-endian UTF-16 bytes. A trailing odd byte is ignored.
func decodeString(data []byte) string {
units := make([]uint16, len(data)/2)
for i := range units {
units[i] = binary.LittleEndian.Uint16(data[i*2:])
}
return decodeUnits(units)
}
// decodeUnits ends the string at the first NUL and replaces invalid UTF-16 with
// U+FFFD, matching the strings WMI returned for the same names.
func decodeUnits(units []uint16) string {
for i, unit := range units {
if unit == 0 {
units = units[:i]
break
}
}
return string(utf16.Decode(units))
}
@@ -0,0 +1,229 @@
// 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 clusapi
import (
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
// CLUSPROP_FORMAT values that the collectors do not decode.
const (
formatBinary = 1
formatMultiString = 5
formatULargeInteger = 6
formatFileTime = 12
)
// Property lists in testdata were captured with TestDumpPropertyLists from the
// CI cluster (Windows Server 2022). Unlike lists built from the documentation,
// they end with the extra CLUSPROP_SYNTAX_ENDMARK of the cluster service.
func readPropertyFixture(tb testing.TB, name string) []byte {
tb.Helper()
text, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
tb.Fatal(err)
}
data, err := hex.DecodeString(strings.Join(strings.Fields(string(text)), ""))
if err != nil {
tb.Fatal(err)
}
return data
}
type fixtureValue struct {
format uint16
length int
dword *uint32
text *string
}
func dwordValue(value uint32) fixtureValue {
return fixtureValue{format: formatDWORD, length: 4, dword: &value}
}
func stringValue(value string, length int) fixtureValue {
return fixtureValue{format: formatString, length: length, text: &value}
}
func checkFixtureProperties(t *testing.T, properties map[string][]Property, count int, want map[string]fixtureValue) {
t.Helper()
if len(properties) != count {
t.Errorf("properties = %d, want %d", len(properties), count)
}
for name, value := range want {
values := properties[name]
if len(values) != 1 || values[0].Format != value.format || len(values[0].Data) != value.length {
t.Errorf("%s = %+v, want format %d with %d bytes", name, values, value.format, value.length)
continue
}
if value.dword != nil {
if got, err := values[0].DWORD(); err != nil || got != *value.dword {
t.Errorf("%s = %d, %v, want %d", name, got, err, *value.dword)
}
}
if value.text != nil {
if got, err := values[0].String(); err != nil || got != *value.text {
t.Errorf("%s = %q, %v, want %q", name, got, err, *value.text)
}
}
}
}
func TestParsePropertiesCapturedResourceLists(t *testing.T) {
// Read-only common properties: an SZ whose 38 bytes need 2 bytes of
// padding, ULARGE_INTEGER values and a DWORD.
properties, err := ParseProperties(readPropertyFixture(t, "resource_ro_common_generic_service.hex"))
if err != nil {
t.Fatal(err)
}
checkFixtureProperties(t, properties, 6, map[string]fixtureValue{
"Name": stringValue("CI Generic Service", 38),
"MonitorProcessId": dwordValue(9092),
"StatusInformation": {format: formatULargeInteger, length: 8},
"LastOperationStatusCode": {format: formatULargeInteger, length: 8},
"ResourceSpecificData1": {format: formatULargeInteger, length: 8},
"ResourceSpecificData2": {format: formatULargeInteger, length: 8},
})
// Common properties: the resource type name the collector publishes, empty
// strings (2 bytes, padded) and the DWORD values.
properties, err = ParseProperties(readPropertyFixture(t, "resource_common_generic_service.hex"))
if err != nil {
t.Fatal(err)
}
checkFixtureProperties(t, properties, 15, map[string]fixtureValue{
"Type": stringValue("Generic Service", 32),
"Description": stringValue("", 2),
"ResourceSpecificStatus": stringValue("", 2),
"DeadlockTimeout": dwordValue(300000),
"EmbeddedFailureAction": dwordValue(2),
"IsAlivePollInterval": dwordValue(^uint32(0)),
"LooksAlivePollInterval": dwordValue(^uint32(0)),
"PendingTimeout": dwordValue(180000),
"PersistentState": dwordValue(1),
"RestartAction": dwordValue(2),
"RestartDelay": dwordValue(500),
"RestartPeriod": dwordValue(600000),
"RestartThreshold": dwordValue(1),
"RetryPeriodOnFailure": dwordValue(600000),
"SeparateMonitor": dwordValue(0),
})
// Read-only private properties of an IP Address: FILETIME values.
properties, err = ParseProperties(readPropertyFixture(t, "resource_ro_private_ip_address.hex"))
if err != nil {
t.Fatal(err)
}
checkFixtureProperties(t, properties, 5, map[string]fixtureValue{
"LeaseObtainedTime": {format: formatFileTime, length: 8},
"LeaseExpiresTime": {format: formatFileTime, length: 8},
"DhcpServer": stringValue("255.255.255.255", 32),
"DhcpAddress": stringValue("0.0.0.0", 16),
"DhcpSubnetMask": stringValue("255.0.0.0", 20),
})
}
func TestParsePropertiesCapturedClusterList(t *testing.T) {
// The cluster common list is larger than the 4 KiB first buffer and holds
// BINARY security descriptors, MULTI_SZ lists, ULARGE_INTEGER and FILETIME
// values next to 64 DWORDs.
data := readPropertyFixture(t, "cluster_common.hex")
if len(data) <= propertyListBufferSize {
t.Fatalf("fixture has %d bytes, want more than the first buffer", len(data))
}
properties, err := ParseProperties(data)
if err != nil {
t.Fatal(err)
}
checkFixtureProperties(t, properties, 74, map[string]fixtureValue{
"Description": stringValue("", 2),
"PreferredSite": stringValue("", 2),
"Security Descriptor": {format: formatBinary, length: 396},
"SharedVolumeSecurityDescriptor": {format: formatBinary, length: 280},
"SharedVolumeCompatibleFilters": {format: formatMultiString, length: 2},
"SharedVolumeIncompatibleFilters": {format: formatMultiString, length: 2},
"EnabledEventLogs": {format: formatMultiString, length: 354},
"DumpPolicy": {format: formatULargeInteger, length: 8},
"S2DCacheBehavior": {format: formatULargeInteger, length: 8},
"RecentEventsResetTime": {format: formatFileTime, length: 8},
"ClusterEnforcedAntiaffinity": {format: formatDWORD, length: 4},
})
dwords := 0
for _, values := range properties {
if _, err := values[0].Value32(); err == nil {
dwords++
}
}
if dwords != 64 {
t.Errorf("32-bit values = %d, want 64", dwords)
}
// Every truncation of a real list must be rejected without a panic.
for _, name := range []string{"resource_ro_common_generic_service.hex", "resource_common_generic_service.hex"} {
data := readPropertyFixture(t, name)
for index := range len(data) - 4 {
if _, err := ParseProperties(data[:index]); err == nil {
t.Errorf("%s: accepted truncation at %d", name, index)
}
}
}
}
func FuzzParsePropertiesCaptured(f *testing.F) {
for _, name := range []string{"resource_ro_common_generic_service.hex", "resource_common_generic_service.hex", "resource_ro_private_ip_address.hex", "cluster_common.hex"} {
f.Add(readPropertyFixture(f, name))
}
f.Fuzz(func(t *testing.T, data []byte) {
properties, err := ParseProperties(data)
if err != nil {
return
}
for _, values := range properties {
for _, value := range values {
_, _ = value.String()
_, _ = value.Value32()
if (value.Format == formatDWORD || value.Format == formatLong) && len(value.Data) != 4 {
t.Fatalf("32-bit value with %d bytes", len(value.Data))
}
}
}
})
}
+238
View File
@@ -0,0 +1,238 @@
// 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.
package clusapi
import (
"encoding/binary"
"testing"
"unicode/utf16"
)
func testPropertyList() []byte {
data := binary.LittleEndian.AppendUint32(nil, 1)
name := make([]byte, 0)
for _, unit := range utf16.Encode([]rune("NodeWeight\x00")) {
name = binary.LittleEndian.AppendUint16(name, unit)
}
data = binary.LittleEndian.AppendUint32(data, propertyName)
data = binary.LittleEndian.AppendUint32(data, uint32(len(name)))
data = append(data, name...)
for len(data)%4 != 0 {
data = append(data, 0)
}
data = binary.LittleEndian.AppendUint32(data, propertyValue|formatDWORD)
data = binary.LittleEndian.AppendUint32(data, 4)
data = binary.LittleEndian.AppendUint32(data, ^uint32(0))
return binary.LittleEndian.AppendUint32(data, 0)
}
func TestParseProperties(t *testing.T) {
data := testPropertyList()
properties, err := ParseProperties(data)
if err != nil {
t.Fatal(err)
}
values := properties["NodeWeight"]
if len(values) != 1 {
t.Fatalf("values = %v", values)
}
value, err := values[0].DWORD()
if err != nil || value != ^uint32(0) {
t.Fatalf("value = %d, err = %v", value, err)
}
for i := range data {
if _, err := ParseProperties(data[:i]); err == nil {
t.Errorf("accepted truncation at %d", i)
}
}
}
func TestParsePropertiesInvalid(t *testing.T) {
tests := map[string]func([]byte){
"count": func(data []byte) { binary.LittleEndian.PutUint32(data, ^uint32(0)) },
"syntax": func(data []byte) { binary.LittleEndian.PutUint32(data[4:], propertyValue|formatString) },
"length": func(data []byte) { binary.LittleEndian.PutUint32(data[8:], ^uint32(0)) },
"dword_length": func(data []byte) { binary.LittleEndian.PutUint32(data[40:], 3) },
"endmark": func(data []byte) { binary.LittleEndian.PutUint32(data[len(data)-4:], propertyValue) },
}
for name, mutate := range tests {
t.Run(name, func(t *testing.T) {
data := testPropertyList()
mutate(data)
if _, err := ParseProperties(data); err == nil {
t.Fatal("accepted invalid property list")
}
})
}
for _, trailing := range [][]byte{{1}, {1, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0x80}} {
if _, err := ParseProperties(append(testPropertyList(), trailing...)); err == nil {
t.Fatalf("accepted trailing data % x", trailing)
}
}
}
// Bounds safety does not depend on the trailer, so any zero padding is accepted.
func TestParsePropertiesZeroTrailer(t *testing.T) {
for _, trailing := range [][]byte{{0}, {0, 0, 0}, {0, 0, 0, 0, 0, 0, 0, 0}} {
properties, err := ParseProperties(append(testPropertyList(), trailing...))
if err != nil {
t.Fatalf("rejected zero trailer % x: %v", trailing, err)
}
if _, exists := properties["NodeWeight"]; !exists {
t.Fatalf("lost property with zero trailer % x", trailing)
}
}
}
// String contents are not validated while parsing. They decode like WMI strings:
// up to the first NUL, with invalid UTF-16 replaced by U+FFFD.
func TestParsePropertiesLenientStrings(t *testing.T) {
data := testPropertyList()
binary.LittleEndian.PutUint16(data[12:], 0xd800)
properties, err := ParseProperties(data)
if err != nil {
t.Fatal(err)
}
if _, exists := properties["�odeWeight"]; !exists {
t.Fatalf("unpaired surrogate was not replaced: %v", properties)
}
data = testPropertyList()
binary.LittleEndian.PutUint16(data[20:], 0)
properties, err = ParseProperties(data)
if err != nil {
t.Fatal(err)
}
if _, exists := properties["Node"]; !exists {
t.Fatalf("embedded NUL did not end the name: %v", properties)
}
for _, tc := range []struct {
value Property
want string
}{
{Property{Format: formatString, Data: []byte{'a', 0, 0, 0}}, "a"},
{Property{Format: formatExpandString, Data: []byte{'a', 0, 0, 0xd8, 'b', 0}}, "a�b"},
{Property{Format: formatExpandedString, Data: []byte{'a', 0, 'b'}}, "a"},
{Property{Format: formatString, Data: nil}, ""},
} {
got, err := tc.value.String()
if err != nil || got != tc.want {
t.Errorf("String(% x) = %q, %v, want %q", tc.value.Data, got, err, tc.want)
}
}
if _, err := (Property{Format: formatDWORD, Data: make([]byte, 4)}).String(); err == nil {
t.Fatal("decoded DWORD as string")
}
}
// The cluster service terminates the whole list with CLUSPROP_SYNTAX_ENDMARK.
func TestParsePropertiesListEndmark(t *testing.T) {
data := binary.LittleEndian.AppendUint32(testPropertyList(), 0)
properties, err := ParseProperties(data)
if err != nil {
t.Fatal(err)
}
if value, err := properties["NodeWeight"][0].DWORD(); err != nil || value != ^uint32(0) {
t.Fatalf("value = %d, err = %v", value, err)
}
if _, err := ParseProperties(binary.LittleEndian.AppendUint32(nil, 0)); err != nil {
t.Fatalf("empty list: %v", err)
}
if _, err := ParseProperties(binary.LittleEndian.AppendUint32(binary.LittleEndian.AppendUint32(nil, 0), 0)); err != nil {
t.Fatalf("empty list with endmark: %v", err)
}
}
func testLongPropertyList() []byte {
data := testPropertyList()
binary.LittleEndian.PutUint32(data[36:], propertyValue|formatLong)
return data
}
func TestPropertyValue32(t *testing.T) {
properties, err := ParseProperties(testLongPropertyList())
if err != nil {
t.Fatal(err)
}
value := properties["NodeWeight"][0]
if _, err := value.DWORD(); err == nil {
t.Fatal("LONG accepted as DWORD")
}
bits, err := value.Value32()
if err != nil || int32(bits) != -1 {
t.Fatalf("LONG bits = %#x, err = %v", bits, err)
}
if _, err := (Property{Format: formatString, Data: []byte{0, 0, 0, 0}}).Value32(); err == nil {
t.Fatal("string accepted as 32-bit value")
}
if _, err := (Property{Format: formatLong, Data: []byte{0, 0}}).Value32(); err == nil {
t.Fatal("short LONG accepted")
}
invalid := testLongPropertyList()
binary.LittleEndian.PutUint32(invalid[40:], 3)
if _, err := ParseProperties(invalid); err == nil {
t.Fatal("accepted LONG with invalid length")
}
}
func FuzzParseProperties(f *testing.F) {
f.Add(testPropertyList())
f.Add(testLongPropertyList())
f.Add(binary.LittleEndian.AppendUint32(testPropertyList(), 0))
f.Add([]byte{0, 0, 0, 0})
f.Fuzz(func(t *testing.T, data []byte) { _, _ = ParseProperties(data) })
}
func BenchmarkParseProperties(b *testing.B) {
data := testPropertyList()
b.ReportAllocs()
for b.Loop() {
if _, err := ParseProperties(data); err != nil {
b.Fatal(err)
}
}
}
+235
View File
@@ -0,0 +1,235 @@
// 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 clusapi
import (
"encoding/binary"
"errors"
"fmt"
"time"
"golang.org/x/sys/windows"
)
const (
// CLUSCTL_RESOURCE_STORAGE_GET_DISK_INFO_EX, available since Windows
// Server 2008. The EX2 variant needs Windows Server 2016 and adds nothing
// that the collector publishes.
// https://learn.microsoft.com/en-us/previous-versions/windows/desktop/mscs/clusctl-resource-storage-get-disk-info-ex
resourceStorageGetDiskInfoEx = 0x010001f1
)
// Partition is one CLUS_PARTITION_INFO_EX entry of a storage class resource.
type Partition struct {
Resource string
DeviceName string
VolumeLabel string
VolumeGUID windows.GUID
TotalBytes uint64
FreeBytes uint64
}
// DiskPartitions reads the partitions of every storage class resource, the
// source of the MSCluster_DiskPartition WMI class. Resources that do not
// support the disk information control code are skipped. Successful
// partitions are retained alongside joined failures.
func (c *Cluster) DiskPartitions(deadline time.Time) (_ []Partition, resultErr error) {
for _, proc := range []*windows.LazyProc{openCluster, openEnum, nextEnum, closeEnum, openResource, closeResource, resourceControl} {
if err := proc.Find(); err != nil {
return nil, fmt.Errorf("load ClusAPI: %w: %w", errors.ErrUnsupported, err)
}
}
if err := c.begin(deadline); err != nil {
return nil, err
}
defer func() { resultErr = errors.Join(resultErr, c.end()) }()
if err := c.open(); err != nil {
return nil, err
}
var partitions []Partition
err := c.enumerate(enumResource, "resource", deadline, func(name objectName) error {
found, err := c.readResourcePartitions(name, deadline)
partitions = append(partitions, found...)
return err
})
return partitions, err
}
func (c *Cluster) readResourcePartitions(name objectName, deadline time.Time) (_ []Partition, resultErr error) {
if err := checkDeadline(deadline); err != nil {
return nil, err
}
handle, err := openObject(openResource, c.handle, name)
if errors.Is(err, windows.ERROR_RESOURCE_NOT_FOUND) {
return nil, errObjectDeleted
}
if err != nil {
return nil, fmt.Errorf("OpenClusterResourceEx: %w", err)
}
defer func() {
if err := closeObject(closeResource, handle); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("CloseClusterResource: %w", err))
}
}()
data, err := resourceBuffer(handle, resourceGetClassInfo, 8, deadline)
if err != nil {
return nil, fmt.Errorf("class information: %w", err)
}
if len(data) != 8 {
return nil, errors.New("invalid resource class information size")
}
if binary.LittleEndian.Uint32(data) != resourceClassStorage {
return nil, nil
}
data, err = resourceBuffer(handle, resourceStorageGetDiskInfoEx, propertyListBufferSize, deadline)
if errors.Is(err, windows.ERROR_INVALID_FUNCTION) || errors.Is(err, windows.ERROR_NOT_SUPPORTED) {
// Storage class resources such as storage pools have no disk layout.
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("CLUSCTL_RESOURCE_STORAGE_GET_DISK_INFO_EX: %w", err)
}
partitions, err := ParseDiskPartitions(data)
for index := range partitions {
partitions[index].Resource = name.name
}
return partitions, err
}
// CLUSPROP_SYNTAX_PARTITION_INFO_EX: CLUSPROP_TYPE_PARTITION_INFO_EX (13) with
// CLUSPROP_FORMAT_BINARY (1).
const syntaxPartitionInfoEx = 13<<16 | 1
// Byte offsets in CLUS_PARTITION_INFO_EX. MAX_PATH is 260 WCHARs; the
// structure is 1160 bytes with or without natural alignment of the
// ULARGE_INTEGER members.
// https://learn.microsoft.com/en-us/previous-versions/windows/desktop/api/clusapi/ns-clusapi-clus_partition_info_ex
const (
partitionDeviceNameOffset = 4
partitionVolumeLabelOffset = partitionDeviceNameOffset + 260*2
partitionTotalSizeOffset = partitionVolumeLabelOffset + 260*2 + 3*4 + 32*2
partitionFreeSizeOffset = partitionTotalSizeOffset + 8
partitionVolumeGUIDOffset = partitionFreeSizeOffset + 8 + 2*4
partitionInfoExSize = partitionVolumeGUIDOffset + 16
)
// ParseDiskPartitions decodes the CLUSPROP_PARTITION_INFO_EX entries of a
// CLUSCTL_RESOURCE_STORAGE_GET_DISK_INFO_EX value list. Other entries, such as
// the disk signature or size, are skipped.
func ParseDiskPartitions(data []byte) ([]Partition, error) {
values, err := ParseValueList(data)
if err != nil {
return nil, err
}
var partitions []Partition
for _, value := range values {
if value.Syntax != syntaxPartitionInfoEx {
continue
}
partition, err := parsePartitionInfoEx(value.Data)
if err != nil {
return partitions, err
}
partitions = append(partitions, partition)
}
return partitions, nil
}
func parsePartitionInfoEx(data []byte) (Partition, error) {
if len(data) < partitionInfoExSize {
return Partition{}, fmt.Errorf("partition information has %d bytes, want at least %d", len(data), partitionInfoExSize)
}
// Fixed-size WCHAR arrays decode up to their first NUL, like WMI strings.
deviceName := decodeString(data[partitionDeviceNameOffset:partitionVolumeLabelOffset])
volumeLabel := decodeString(data[partitionVolumeLabelOffset : partitionVolumeLabelOffset+260*2])
guid := data[partitionVolumeGUIDOffset:partitionInfoExSize]
return Partition{
DeviceName: deviceName,
VolumeLabel: volumeLabel,
VolumeGUID: windows.GUID{
Data1: binary.LittleEndian.Uint32(guid),
Data2: binary.LittleEndian.Uint16(guid[4:]),
Data3: binary.LittleEndian.Uint16(guid[6:]),
Data4: [8]byte(guid[8:16]),
},
TotalBytes: binary.LittleEndian.Uint64(data[partitionTotalSizeOffset:]),
FreeBytes: binary.LittleEndian.Uint64(data[partitionFreeSizeOffset:]),
}, nil
}
// Value is one entry of a CLUSPROP value list.
type Value struct {
Syntax uint32
Data []byte
}
// ParseValueList decodes a value list: CLUSPROP_VALUE headers (syntax and
// byte length) with DWORD-aligned payloads, terminated by
// CLUSPROP_SYNTAX_ENDMARK. Zero padding after the endmark is accepted.
// https://learn.microsoft.com/en-us/previous-versions/windows/desktop/mscs/value-lists
func ParseValueList(data []byte) ([]Value, error) {
var values []Value
for {
if len(data) < 4 {
return nil, errors.New("value list is missing its endmark")
}
syntax := binary.LittleEndian.Uint32(data)
if syntax == 0 {
for _, b := range data[4:] {
if b != 0 {
return nil, fmt.Errorf("%d bytes of trailing value list data", len(data)-4)
}
}
return values, nil
}
payload, rest, err := propertyPayload(data)
if err != nil {
return nil, fmt.Errorf("value syntax %#x: %w", syntax, err)
}
values = append(values, Value{Syntax: syntax, Data: payload})
data = rest
}
}
+167
View File
@@ -0,0 +1,167 @@
// 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 clusapi
import (
"encoding/binary"
"testing"
"time"
"unicode/utf16"
"golang.org/x/sys/windows"
)
func putFixedString(data []byte, value string) {
for index, unit := range utf16.Encode([]rune(value)) {
binary.LittleEndian.PutUint16(data[index*2:], unit)
}
}
func testPartitionInfoEx() []byte {
data := make([]byte, partitionInfoExSize)
putFixedString(data[partitionDeviceNameOffset:], `C:\ClusterStorage\Volume1`)
putFixedString(data[partitionVolumeLabelOffset:], "CSV01 ")
binary.LittleEndian.PutUint64(data[partitionTotalSizeOffset:], 2<<40)
binary.LittleEndian.PutUint64(data[partitionFreeSizeOffset:], 19008585728+12345)
guid := data[partitionVolumeGUIDOffset:]
binary.LittleEndian.PutUint32(guid, 0xd3dbada3)
binary.LittleEndian.PutUint16(guid[4:], 0x448f)
binary.LittleEndian.PutUint16(guid[6:], 0x4c4c)
copy(guid[8:], []byte{0xbe, 0x4d, 0x12, 0x2b, 0x7f, 0x29, 0x60, 0x07})
return data
}
func appendValue(list []byte, syntax uint32, payload []byte) []byte {
list = binary.LittleEndian.AppendUint32(list, syntax)
list = binary.LittleEndian.AppendUint32(list, uint32(len(payload)))
list = append(list, payload...)
for len(list)%4 != 0 {
list = append(list, 0)
}
return list
}
// testDiskInfo builds the documented GET_DISK_INFO_EX value list: a disk
// signature, a disk size, a partition and the endmark.
func testDiskInfo() []byte {
list := appendValue(nil, 5<<16|2, []byte{1, 2, 3, 4})
list = appendValue(list, 12<<16|6, make([]byte, 8))
list = appendValue(list, syntaxPartitionInfoEx, testPartitionInfoEx())
return binary.LittleEndian.AppendUint32(list, 0)
}
func TestParseDiskPartitions(t *testing.T) {
partitions, err := ParseDiskPartitions(testDiskInfo())
if err != nil {
t.Fatal(err)
}
if len(partitions) != 1 {
t.Fatalf("partitions = %d", len(partitions))
}
partition := partitions[0]
want := windows.GUID{Data1: 0xd3dbada3, Data2: 0x448f, Data3: 0x4c4c, Data4: [8]byte{0xbe, 0x4d, 0x12, 0x2b, 0x7f, 0x29, 0x60, 0x07}}
if partition.DeviceName != `C:\ClusterStorage\Volume1` || partition.VolumeLabel != "CSV01 " || partition.VolumeGUID != want ||
partition.TotalBytes != 2<<40 || partition.FreeBytes != 19008585728+12345 {
t.Fatalf("partition = %+v", partition)
}
data := testDiskInfo()
for i := range data {
if _, err := ParseDiskPartitions(data[:i]); err == nil {
t.Errorf("accepted truncation at %d", i)
}
}
}
func TestParseDiskPartitionsInvalid(t *testing.T) {
short := appendValue(nil, syntaxPartitionInfoEx, make([]byte, partitionInfoExSize-1))
if _, err := ParseDiskPartitions(binary.LittleEndian.AppendUint32(short, 0)); err == nil {
t.Fatal("accepted short partition information")
}
unterminated := testPartitionInfoEx()
for index := partitionVolumeLabelOffset; index < partitionVolumeLabelOffset+260*2; index += 2 {
binary.LittleEndian.PutUint16(unterminated[index:], 'A')
}
// Fixed-size names decode like WMI strings: the whole array when it is not
// terminated, and invalid UTF-16 replaced with U+FFFD.
list := binary.LittleEndian.AppendUint32(appendValue(nil, syntaxPartitionInfoEx, unterminated), 0)
if partitions, err := ParseDiskPartitions(list); err != nil || len(partitions) != 1 || len(partitions[0].VolumeLabel) != 260 {
t.Fatalf("unterminated volume label: %v, %v", partitions, err)
}
surrogate := testPartitionInfoEx()
binary.LittleEndian.PutUint16(surrogate[partitionDeviceNameOffset:], 0xdc00)
list = binary.LittleEndian.AppendUint32(appendValue(nil, syntaxPartitionInfoEx, surrogate), 0)
if partitions, err := ParseDiskPartitions(list); err != nil || len(partitions) != 1 || partitions[0].DeviceName[:3] != "�" {
t.Fatalf("unpaired surrogate: %v, %v", partitions, err)
}
oversized := testDiskInfo()
binary.LittleEndian.PutUint32(oversized[4:], ^uint32(0))
if _, err := ParseDiskPartitions(oversized); err == nil {
t.Fatal("accepted oversized value length")
}
if _, err := ParseDiskPartitions(append(testDiskInfo(), 1, 0, 0, 0)); err == nil {
t.Fatal("accepted trailing data")
}
if partitions, err := ParseDiskPartitions(append(testDiskInfo(), 0, 0, 0, 0)); err != nil || len(partitions) != 1 {
t.Fatalf("zero padding: %v", err)
}
}
func TestParseValueListEmpty(t *testing.T) {
values, err := ParseValueList([]byte{0, 0, 0, 0})
if err != nil || len(values) != 0 {
t.Fatalf("values = %v, err = %v", values, err)
}
if _, err := ParseValueList(nil); err == nil {
t.Fatal("accepted missing endmark")
}
}
func TestClusterClosedDiskPartitions(t *testing.T) {
c := &Cluster{}
if err := c.Close(); err != nil {
t.Fatal(err)
}
if _, err := c.DiskPartitions(time.Time{}); err == nil {
t.Fatal("read closed cluster")
}
}
func FuzzParseDiskPartitions(f *testing.F) {
f.Add(testDiskInfo())
f.Add([]byte{0, 0, 0, 0})
f.Fuzz(func(t *testing.T, data []byte) { _, _ = ParseDiskPartitions(data) })
}
+189
View File
@@ -0,0 +1,189 @@
4a000000030004001c0000004100640064004500760069006300740044006500
6c0061007900000002000100040000003c000000000000000300040022000000
4200610063006b007500700049006e00500072006f0067007200650073007300
0000000002000100040000000000000000000000030004002600000043006c00
75007300530076006300480061006e006700540069006d0065006f0075007400
0000000002000100040000008700000000000000030004003600000043006c00
7500730053007600630052006500670072006f00750070005300740061006700
6500540069006d0065006f00750074000000000002000100040000000f000000
00000000030004004200000043006c0075007300530076006300520065006700
72006f00750070005400690063006b0049006e004d0069006c006c0069007300
650063006f006e00640073000000000002000100040000002c01000000000000
030004002c00000043006c0075007300740065007200470072006f0075007000
5700610069007400440065006c00610079000000020001000400000078000000
0000000003000400320000004d0069006e0069006d0075006d00500072006500
65006d00700074006f0072005000720069006f00720069007400790000000000
0200010004000000010000000000000003000400380000004d0069006e006900
6d0075006d004e00650076006500720050007200650065006d00700074005000
720069006f00720069007400790000000200010004000000b80b000000000000
030004003800000043006c007500730074006500720045006e0066006f007200
63006500640041006e007400690061006600660069006e006900740079000000
02000100040000000000000000000000030004002000000043006c0075007300
7400650072004c006f0067004c006500760065006c0000000200010004000000
0300000000000000030004001e00000043006c00750073007400650072004c00
6f006700530069007a0065000000000002000100040000000006000000000000
0300040022000000430072006f00730073005300750062006e00650074004400
65006c0061007900000000000200010004000000e80300000000000003000400
2a000000430072006f00730073005300750062006e0065007400540068007200
6500730068006f006c0064000000000002000100040000001e00000000000000
030004001e000000430072006f00730073005300690074006500440065006c00
61007900000000000200010004000000e8030000000000000300040026000000
430072006f007300730053006900740065005400680072006500730068006f00
6c00640000000000020001000400000014000000000000000300040026000000
440065006600610075006c0074004e006500740077006f0072006b0052006f00
6c00650000000000020001000400000003000000000000000300040018000000
4400650073006300720069007000740069006f006e0000000300010002000000
0000000000000000030004002800000045006e00610062006c00650053006800
610072006500640056006f006c0075006d006500730000000200010004000000
0100000000000000030004004c00000053006800610072006500640056006f00
6c0075006d0065005600730073005700720069007400650072004f0070006500
72006100740069006f006e00540069006d0065006f0075007400000002000100
040000000807000000000000030004005000000055007300650043006c006900
65006e0074004100630063006500730073004e006500740077006f0072006b00
730046006f00720053006800610072006500640056006f006c0075006d006500
7300000002000100040000000200000000000000030004002600000048006100
6e0067005200650063006f00760065007200790041006300740069006f006e00
000000000200010004000000060000000000000003000400280000004c006f00
67005200650073006f00750072006300650043006f006e00740072006f006c00
7300000002000100040000000000000000000000030004003400000050006c00
75006d00620041006c006c00430072006f00730073005300750062006e006500
740052006f007500740065007300000002000100040000000000000000000000
0300040032000000510075006f00720075006d00410072006200690074007200
6100740069006f006e00540069006d0065004d00610078000000000002000100
0400000014000000000000000300040028000000520065007100750065007300
74005200650070006c007900540069006d0065006f0075007400000002000100
040000003c000000000000000300040020000000530061006d00650053007500
62006e0065007400440065006c006100790000000200010004000000e8030000
000000000300040028000000530061006d0065005300750062006e0065007400
5400680072006500730068006f006c006400000002000100040000001e000000
0000000003000400280000005300650063007500720069007400790020004400
65007300630072006900700074006f0072000000010001008c01000001001480
6c0100007c010000140000001c0000000200080000000000020050010a000000
000328000300000001060000000000055000000050b9fca79985e2c9d62a71ee
d4a97014fb0f3b080003280003000000010600000000000550000000428438f6
a00cf3c69c706cb049cbf7d89a81446f00032800030000000106000000000005
50000000dd2ce03f3162e2db4015aef4571a3e22e81bbe5a0003280003000000
0106000000000005500000003805864282d831674ea6a91735fcf0b852c3fc27
0003280003000000010600000000000550000000da87ba0d52ce1099d8a2ea28
71d717cf58b8647f000318000300000001020000000000052000000046020000
0003180003000000010200000000000520000000200200000003140001000000
0101000000000005140000000003140003000000010100000000000512000000
0000280003000000010600000000000550000000e5320fec28bea2922c2935f1
6342dc6b5a0a2586010200000000000520000000200200000102000000000005
200000002002000000000000030004001c000000530065006300750072006900
740079004c006500760065006c00000002000100040000000100000000000000
0300040030000000530065006300750072006900740079004c00650076006500
6c0046006f007200530074006f00720061006700650000000200010004000000
0000000000000000030004003c00000053006800610072006500640056006f00
6c0075006d00650043006f006d00700061007400690062006c00650046006900
6c00740065007200730000000500010002000000000000000000000003000400
4000000053006800610072006500640056006f006c0075006d00650049006e00
63006f006d00700061007400690062006c006500460069006c00740065007200
7300000005000100020000000000000000000000030004003200000053006800
7500740064006f0077006e00540069006d0065006f007500740049006e004d00
69006e0075007400650073000000000002000100040000001800000000000000
03000400380000005700690074006e0065007300730044006100740061006200
61007300650057007200690074006500540069006d0065006f00750074000000
02000100040000002c01000000000000030004002e0000005700690074006e00
650073007300520065007300740061007200740049006e007400650072007600
61006c000000000002000100040000000f00000000000000030004003e000000
53006800610072006500640056006f006c0075006d0065005300650063007500
7200690074007900440065007300630072006900700074006f00720000000000
0100010018010000010004800c0100000000000000000000140000000200f800
0600000000002800000000100106000000000005500000006084b9dbdd58ffa7
f6a00e57c8f9dc9806ecf3140000280000000010010600000000000550000000
072e3960a0dce3ce1fb141401771f6dc02293b63000028000000001001060000
00000005500000004c58ea82b5845656286e250c7009c7e741f5d9e100002800
000000100106000000000005570000003b3ba0f7f4649ae903ce7141e471f485
91d1a94f0000280000000010010600000000000550000000c75b0a946ed5ffc5
21b1fa2063a9672379b3d51a0000280000000010010600000000000557000000
ac074fae90f9a2122adf50d8df37ff6b1b2b7009010100000000000512000000
00000000030004001e00000042006c006f0063006b0043006100630068006500
530069007a006500000000000200010004000000000400000000000003000400
2600000052006f0075007400650048006900730074006f00720079004c006500
6e00670074006800000000000200010004000000280000000000000003000400
2a000000440079006e0061006d0069006300510075006f00720075006d004500
6e00610062006c00650064000000000002000100040000000100000000000000
030004002c00000052006500630065006e0074004500760065006e0074007300
52006500730065007400540069006d00650000000c00010008000000f0a270a0
c958dd0100000000030004002000000044007200610069006e004f006e005300
68007500740064006f0077006e00000002000100040000000100000000000000
030004002c000000440061007400610062006100730065005200650061006400
570072006900740065004d006f00640065000000020001000400000000000000
0000000003000400240000004e00650074006600740049005000530065006300
45006e00610062006c0065006400000002000100040000000100000000000000
0300040018000000430073007600420061006c0061006e006300650072000000
0200010004000000010000000000000003000400280000004d00650073007300
6100670065004200750066006600650072004c0065006e006700740068000000
0200010004000000320000000000000003000400340000004c006f0077006500
7200510075006f00720075006d005000720069006f0072006900740079004e00
6f00640065004900640000000200010004000000000000000000000003000400
16000000440075006d00700050006f006c006900630079000000000006000100
0800000019110152000000000000000003000400180000005300320044004200
7500730054007900700065007300000002000100040000000000000000000000
03000400220000005300320044004f007000740069006d0069007a0061007400
69006f006e007300000000000200010004000000000000000000000003000400
2c00000053003200440049004f004c006100740065006e006300790054006800
72006500730068006f006c006400000002000100040000001027000000000000
030004002a000000530032004400430061006300680065004400650073006900
7200650064005300740061007400650000000000020001000400000002000000
0000000003000400380000005300320044004300610063006800650046006c00
6100730068005200650073006500720076006500500065007200630065006e00
7400000002000100040000000000000000000000030004002200000053003200
4400430061006300680065004200650068006100760069006f00720000000000
0600010008000000580000000000000000000000030004002e00000053003200
4400430061006300680065005000610067006500530069007a0065004b004200
7900740065007300000000000200010004000000100000000000000003000400
2000000052006500730069006c00690065006e00630079004c00650076006500
6c00000002000100040000000200000000000000030004003000000052006500
730069006c00690065006e0063007900440065006600610075006c0074005000
6500720069006f00640000000200010004000000f00000000000000003000400
26000000510075006100720061006e00740069006e0065004400750072006100
740069006f006e00000000000200010004000000201c00000000000003000400
28000000510075006100720061006e00740069006e0065005400680072006500
730068006f006c00640000000200010004000000030000000000000003000400
1c00000050007200650066006500720072006500640053006900740065000000
0300010002000000000000000000000003000400220000004100750074006f00
420061006c0061006e006300650072004d006f00640065000000000002000100
04000000020000000000000003000400240000004100750074006f0042006100
6c0061006e006300650072004c006500760065006c0000000200010004000000
010000000000000003000400260000004100750074006f004100730073006900
67006e004e006f00640065005300690074006500000000000200010004000000
0100000000000000030004002e000000470072006f0075007000440065007000
65006e00640065006e0063007900540069006d0065006f007500740000000000
02000100040000005802000000000000030004003e000000490067006e006f00
72006500500065007200730069007300740065006e0074005300740061007400
65004f006e005300740061007200740075007000000000000200010004000000
0000000000000000030004002200000050006c006100630065006d0065006e00
74004f007000740069006f006e00730000000000020001000400000000000000
0000000003000400280000004400650074006500630074004d0061006e006100
6700650064004500760065006e00740073000000020001000400000001000000
00000000030004003a0000004400650074006500630074004d0061006e006100
6700650064004500760065006e00740073005400680072006500730068006f00
6c0064000000000002000100040000003c000000000000000300040022000000
45006e00610062006c00650064004500760065006e0074004c006f0067007300
0000000005000100620100004d006900630072006f0073006f00660074002d00
570069006e0064006f00770073002d00480079007000650072002d0056002d00
56006d005300770069007400630068002d0044006900610067006e006f007300
7400690063002c0034002c003000780046004600460046004600460046004400
00004d006900630072006f0073006f00660074002d00570069006e0064006f00
770073002d0053004d0042004400690072006500630074002f00440065006200
750067002c00340000004d006900630072006f0073006f00660074002d005700
69006e0064006f00770073002d0053004d004200530065007200760065007200
2f0041006e0061006c00790074006900630000004d006900630072006f007300
6f00660074002d00570069006e0064006f00770073002d004b00650072006e00
65006c002d004c00690076006500440075006d0070002f0041006e0061006c00
7900740069006300000000000000000000000000030004002400000055007300
6500520064006d00610046006f007200530074006f0072006100670065000000
02000100040000000100000000000000030004002a0000005300650074005300
4d004200420061006e006400770069006400740068004c0069006d0069007400
0000000002000100040000000100000000000000030004003000000053004d00
4200420061006e006400770069006400740068004c0069006d00690074004600
6100630074006f00720000000200010004000000c40900000000000003000400
340000004d006100780069006d0075006d0050006100720061006c006c006500
6c004d006900670072006100740069006f006e00730000000200010004000000
0100000000000000030004002000000057007000720053006500730073006900
6f006e0043006f0075006e007400000002000100040000000200000000000000
030004002c000000570070007200530065007300730069006f006e0043006f00
6f006c004f0066006600540069006d0065000000020001000400000058020000
0000000000000000
@@ -0,0 +1,29 @@
0f000000030004002000000044006500610064006c006f0063006b0054006900
6d0065006f007500740000000200010004000000e09304000000000003000400
180000004400650073006300720069007000740069006f006e00000003000100
02000000000000000000000003000400280000004900730041006c0069007600
650050006f006c006c0049006e00740065007200760061006c00000002000100
04000000ffffffff00000000030004002e0000004c006f006f006b0073004100
6c0069007600650050006f006c006c0049006e00740065007200760061006c00
000000000200010004000000ffffffff00000000030004001e00000050006500
6e00640069006e006700540069006d0065006f00750074000000000002000100
0400000020bf0200000000000300040020000000500065007200730069007300
740065006e007400530074006100740065000000020001000400000001000000
00000000030004001c0000005200650073007400610072007400410063007400
69006f006e00000002000100040000000200000000000000030004001a000000
5200650073007400610072007400440065006c00610079000000000002000100
04000000f401000000000000030004001c000000520065007300740061007200
740050006500720069006f00640000000200010004000000c027090000000000
0300040022000000520065007300740061007200740054006800720065007300
68006f006c006400000000000200010004000000010000000000000003000400
2c00000045006d006200650064006400650064004600610069006c0075007200
650041006300740069006f006e00000002000100040000000200000000000000
030004002a0000005200650074007200790050006500720069006f0064004f00
6e004600610069006c00750072006500000000000200010004000000c0270900
000000000300040020000000530065007000610072006100740065004d006f00
6e00690074006f00720000000200010004000000000000000000000003000400
0a0000005400790070006500000000000300010020000000470065006e006500
7200690063002000530065007200760069006300650000000000000003000400
2e0000005200650073006f007500720063006500530070006500630069006600
6900630053007400610074007500730000000000030001000200000000000000
0000000000000000
@@ -0,0 +1,14 @@
06000000030004000a0000004e0061006d006500000000000300010026000000
430049002000470065006e006500720069006300200053006500720076006900
63006500000000000000000003000400220000004d006f006e00690074006f00
7200500072006f00630065007300730049006400000000000200010004000000
8423000000000000030004002400000053007400610074007500730049006e00
66006f0072006d006100740069006f006e000000060001000800000000000000
000000000000000003000400300000004c006100730074004f00700065007200
6100740069006f006e0053007400610074007500730043006f00640065000000
0600010008000000000000000000000000000000030004002c00000052006500
73006f0075007200630065005300700065006300690066006900630044006100
7400610031000000060001000800000000000000000000000000000003000400
2c0000005200650073006f007500720063006500530070006500630069006600
6900630044006100740061003200000006000100080000000000000000000000
0000000000000000
@@ -0,0 +1,11 @@
0500000003000400240000004c0065006100730065004f006200740061006900
6e0065006400540069006d00650000000c000100080000000000000000000000
0000000003000400220000004c00650061007300650045007800700069007200
65007300540069006d006500000000000c000100080000000000000000000000
0000000003000400160000004400680063007000530065007200760065007200
0000000003000100200000003200350035002e003200350035002e0032003500
35002e0032003500350000000000000003000400180000004400680063007000
41006400640072006500730073000000030001001000000030002e0030002e00
30002e003000000000000000030004001e000000440068006300700053007500
62006e00650074004d00610073006b0000000000030001001400000032003500
35002e0030002e0030002e00300000000000000000000000
+3 -2
View File
@@ -114,9 +114,10 @@ func TestCollector[C collector.Collector, V any](t *testing.T, fn func(*V) C, co
// unsupportedBuild returns true for errors that indicate a collector's WMI
// class or PDH counter is not present on this system. MI_RESULT_INVALID_CLASS
// is only accepted here (Build path) so that a mistyped class in production
// code still fails Collect.
// code still fails Collect. Native collectors report a missing role, such as
// an unconfigured failover cluster, with errors.ErrUnsupported.
func unsupportedBuild(err error) bool {
return errors.Is(err, mi.MI_RESULT_INVALID_CLASS) || unsupportedCollect(err)
return errors.Is(err, mi.MI_RESULT_INVALID_CLASS) || errors.Is(err, errors.ErrUnsupported) || unsupportedCollect(err)
}
// unsupportedCollect returns true for Collect-path errors that indicate a