mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 01:59:04 +02:00
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:
co-authored by
Claude Opus 5.5
parent
f292a6fbcf
commit
1302cf6899
+121
-1
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user