mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-20 03:29:05 +02:00
wmi: add generic wmi collector (#2530)
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
a67a64c55c
commit
88034b818e
@@ -0,0 +1,62 @@
|
||||
// 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 wmi
|
||||
|
||||
import (
|
||||
"github.com/prometheus-community/windows_exporter/internal/mi"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
type Query struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Namespace string `json:"namespace" yaml:"namespace"`
|
||||
Class string `json:"class" yaml:"class"`
|
||||
Where string `json:"where" yaml:"where"`
|
||||
LabelProperties []LabelProperty `json:"label_properties" yaml:"label_properties"`
|
||||
Properties []Property `json:"properties" yaml:"properties"`
|
||||
|
||||
// Resolved at Build time.
|
||||
namespace mi.Namespace
|
||||
query mi.Query
|
||||
wql string
|
||||
labelNames []string
|
||||
}
|
||||
|
||||
type LabelProperty struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Label string `json:"label" yaml:"label"`
|
||||
}
|
||||
|
||||
type Property struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Metric string `json:"metric" yaml:"metric"`
|
||||
Help string `json:"help" yaml:"help"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Labels map[string]string `json:"labels" yaml:"labels"`
|
||||
|
||||
// Resolved at Build time.
|
||||
desc *prometheus.Desc
|
||||
metricType prometheus.ValueType
|
||||
}
|
||||
|
||||
// UnmarshalYAML is a no-op, so the strict config file validation accepts the
|
||||
// queries being provided as a string. See the performancecounter collector.
|
||||
func (*Config) UnmarshalYAML(*yaml.Node) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
// 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 wmi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
"github.com/prometheus-community/windows_exporter/internal/mi"
|
||||
"github.com/prometheus-community/windows_exporter/internal/types"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
const (
|
||||
Name = "wmi"
|
||||
|
||||
defaultNamespace = "root/CIMv2"
|
||||
|
||||
// minQueryTimeout is the least time left in the scrape for which a query
|
||||
// is still started.
|
||||
minQueryTimeout = time.Millisecond
|
||||
)
|
||||
|
||||
var errScrapeTimeout = errors.New("scrape timeout exceeded")
|
||||
|
||||
var (
|
||||
reNonAlphaNum = regexp.MustCompile(`[^a-zA-Z0-9]`)
|
||||
|
||||
// reIdentifier matches WQL class and property names. They are interpolated
|
||||
// into the generated SELECT statement, so anything else is rejected.
|
||||
reIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Queries []Query `yaml:"queries"`
|
||||
}
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var ConfigDefaults = Config{
|
||||
Queries: make([]Query, 0),
|
||||
}
|
||||
|
||||
// A Collector is a Prometheus collector for user-defined WMI queries.
|
||||
type Collector struct {
|
||||
config Config
|
||||
|
||||
logger *slog.Logger
|
||||
miSession *mi.Session
|
||||
|
||||
queries []Query
|
||||
|
||||
querySuccessDesc *prometheus.Desc
|
||||
queryDurationDesc *prometheus.Desc
|
||||
}
|
||||
|
||||
// metricSignature is what every property sharing a metric name must agree
|
||||
// on. Prometheus rejects a metric family whose members differ in help text,
|
||||
// type or label names.
|
||||
type metricSignature struct {
|
||||
help string
|
||||
metricType prometheus.ValueType
|
||||
labelNames string
|
||||
}
|
||||
|
||||
func New(config *Config) *Collector {
|
||||
if config == nil {
|
||||
config = &ConfigDefaults
|
||||
}
|
||||
|
||||
if config.Queries == nil {
|
||||
config.Queries = ConfigDefaults.Queries
|
||||
}
|
||||
|
||||
c := &Collector{
|
||||
config: *config,
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func NewWithFlags(app *kingpin.Application) *Collector {
|
||||
c := &Collector{
|
||||
config: ConfigDefaults,
|
||||
}
|
||||
|
||||
var queries string
|
||||
|
||||
app.Flag(
|
||||
"collector.wmi.queries",
|
||||
"WMI queries to collect numeric properties from. See docs for more information on how to use this flag. By default, no queries are run.",
|
||||
).Default("").StringVar(&queries)
|
||||
|
||||
app.Action(func(*kingpin.ParseContext) error {
|
||||
if queries == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal([]byte(queries), &c.config.Queries); err != nil {
|
||||
return fmt.Errorf("failed to parse queries %s: %w", queries, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Collector) GetName() string {
|
||||
return Name
|
||||
}
|
||||
|
||||
func (c *Collector) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:gocognit,cyclop,funlen,maintidx // validation of the user configuration is a flat list of checks
|
||||
func (c *Collector) Build(logger *slog.Logger, miSession *mi.Session) error {
|
||||
c.logger = logger.With(slog.String("collector", Name))
|
||||
|
||||
c.logger.Info("wmi collector is in an experimental state! It may subject to change.")
|
||||
|
||||
c.querySuccessDesc = prometheus.NewDesc(
|
||||
prometheus.BuildFQName(types.Namespace, Name, "query_success"),
|
||||
"Whether the WMI query and all of its configured properties could be read successfully.",
|
||||
[]string{"name"},
|
||||
nil,
|
||||
)
|
||||
c.queryDurationDesc = prometheus.NewDesc(
|
||||
prometheus.BuildFQName(types.Namespace, Name, "query_duration_seconds"),
|
||||
"Duration of the WMI query.",
|
||||
[]string{"name"},
|
||||
nil,
|
||||
)
|
||||
|
||||
c.queries = make([]Query, 0, len(c.config.Queries))
|
||||
|
||||
if len(c.config.Queries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if miSession == nil {
|
||||
return errors.New("miSession is nil")
|
||||
}
|
||||
|
||||
c.miSession = miSession
|
||||
|
||||
// names maps the sanitized query name, which seeds auto-generated metric
|
||||
// names, to the configured one. Names that only differ in case or special
|
||||
// characters would produce the same metrics.
|
||||
names := make(map[string]string, len(c.config.Queries))
|
||||
signatures := make(map[string]metricSignature)
|
||||
|
||||
// seriesQueries maps the series of queries without label properties to
|
||||
// the query producing it. Such a query emits exactly one series per
|
||||
// property, so a collision with another query is certain. Series of
|
||||
// queries with label properties depend on the instances returned at
|
||||
// runtime and cannot be checked here.
|
||||
seriesQueries := make(map[string]string)
|
||||
|
||||
reservedMetrics := []string{
|
||||
prometheus.BuildFQName(types.Namespace, Name, "query_success"),
|
||||
prometheus.BuildFQName(types.Namespace, Name, "query_duration_seconds"),
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
for _, query := range c.config.Queries {
|
||||
if query.Name == "" {
|
||||
errs = append(errs, errors.New("query name is required"))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if prev, ok := names[sanitizeMetricName(query.Name)]; ok {
|
||||
if prev == query.Name {
|
||||
errs = append(errs, fmt.Errorf("query %s: name is duplicated", query.Name))
|
||||
} else {
|
||||
errs = append(errs, fmt.Errorf("query %s: name produces the same metric names as query %s", query.Name, prev))
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
names[sanitizeMetricName(query.Name)] = query.Name
|
||||
|
||||
if !reIdentifier.MatchString(query.Class) {
|
||||
errs = append(errs, fmt.Errorf("query %s: class %q must be a valid WMI class name", query.Name, query.Class))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if len(query.Properties) == 0 {
|
||||
errs = append(errs, fmt.Errorf("query %s: no properties configured", query.Name))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if query.Namespace == "" {
|
||||
query.Namespace = defaultNamespace
|
||||
}
|
||||
|
||||
// MI expects forward slashes, but backslashes are the common notation
|
||||
// in WMI tooling.
|
||||
query.Namespace = strings.ReplaceAll(query.Namespace, `\`, "/")
|
||||
|
||||
var err error
|
||||
|
||||
query.namespace, err = mi.NewNamespace(query.Namespace)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("query %s: invalid namespace %q: %w", query.Name, query.Namespace, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// selected collects the property names for the SELECT statement. WMI
|
||||
// property names are case-insensitive, so they are compared lowercased.
|
||||
selected := make([]string, 0, len(query.LabelProperties)+len(query.Properties))
|
||||
selectedLower := make([]string, 0, cap(selected))
|
||||
addSelected := func(name string) {
|
||||
if !slices.Contains(selectedLower, strings.ToLower(name)) {
|
||||
selected = append(selected, name)
|
||||
selectedLower = append(selectedLower, strings.ToLower(name))
|
||||
}
|
||||
}
|
||||
|
||||
queryErrs := len(errs)
|
||||
labelProperties := make([]LabelProperty, 0, len(query.LabelProperties))
|
||||
query.labelNames = make([]string, 0, len(query.LabelProperties))
|
||||
|
||||
for _, labelProperty := range query.LabelProperties {
|
||||
if !reIdentifier.MatchString(labelProperty.Name) {
|
||||
errs = append(errs, fmt.Errorf("query %s: label property %q must be a valid WMI property name", query.Name, labelProperty.Name))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if labelProperty.Label == "" {
|
||||
labelProperty.Label = sanitizeMetricName(labelProperty.Name)
|
||||
}
|
||||
|
||||
if slices.Contains(query.labelNames, labelProperty.Label) {
|
||||
errs = append(errs, fmt.Errorf("query %s: label %q is duplicated", query.Name, labelProperty.Label))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
query.labelNames = append(query.labelNames, labelProperty.Label)
|
||||
labelProperties = append(labelProperties, labelProperty)
|
||||
|
||||
addSelected(labelProperty.Name)
|
||||
}
|
||||
|
||||
properties := make([]Property, 0, len(query.Properties))
|
||||
propertyNames := make([]string, 0, len(query.Properties))
|
||||
|
||||
for _, property := range query.Properties {
|
||||
if !reIdentifier.MatchString(property.Name) {
|
||||
errs = append(errs, fmt.Errorf("query %s: property %q must be a valid WMI property name", query.Name, property.Name))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if slices.Contains(propertyNames, strings.ToLower(property.Name)) {
|
||||
errs = append(errs, fmt.Errorf("query %s: property %q is duplicated", query.Name, property.Name))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
propertyNames = append(propertyNames, strings.ToLower(property.Name))
|
||||
|
||||
// If no metric name is given, derive one from the query name and
|
||||
// property name, mirroring the registry collector.
|
||||
if property.Metric == "" {
|
||||
property.Metric = sanitizeMetricName(
|
||||
fmt.Sprintf("%s_%s_%s_%s", types.Namespace, Name, query.Name, property.Name),
|
||||
)
|
||||
}
|
||||
|
||||
if slices.Contains(reservedMetrics, property.Metric) {
|
||||
errs = append(errs, fmt.Errorf("query %s: property %q: metric %q is reserved by the wmi collector", query.Name, property.Name, property.Metric))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
switch property.Type {
|
||||
case "", "gauge":
|
||||
property.metricType = prometheus.GaugeValue
|
||||
case "counter":
|
||||
property.metricType = prometheus.CounterValue
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("query %s: property %q has invalid type %q, must be \"gauge\" or \"counter\"", query.Name, property.Name, property.Type))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if label, ok := firstCommonLabel(query.labelNames, property.Labels); ok {
|
||||
errs = append(errs, fmt.Errorf("query %s: property %q: constant label %q is already set by a label property", query.Name, property.Name, label))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if k := slices.IndexFunc(properties, func(other Property) bool {
|
||||
return other.Metric == property.Metric && maps.Equal(other.Labels, property.Labels)
|
||||
}); k != -1 {
|
||||
errs = append(errs, fmt.Errorf("query %s: properties %q and %q produce identical series %s, set a different metric name or labels",
|
||||
query.Name, properties[k].Name, property.Name, property.Metric,
|
||||
))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
series := seriesKey(property.Metric, property.Labels)
|
||||
|
||||
if prev, ok := seriesQueries[series]; ok && len(query.labelNames) == 0 {
|
||||
errs = append(errs, fmt.Errorf("query %s: property %q produces the same series %s as query %s, set a different metric name or labels",
|
||||
query.Name, property.Name, property.Metric, prev,
|
||||
))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if property.Help == "" {
|
||||
property.Help = "windows_exporter: custom WMI metric"
|
||||
}
|
||||
|
||||
labelNames := append(slices.Collect(maps.Keys(property.Labels)), query.labelNames...)
|
||||
slices.Sort(labelNames)
|
||||
|
||||
signature := metricSignature{
|
||||
help: property.Help,
|
||||
metricType: property.metricType,
|
||||
labelNames: strings.Join(labelNames, ","),
|
||||
}
|
||||
|
||||
if prev, seen := signatures[property.Metric]; seen && prev != signature {
|
||||
errs = append(errs, fmt.Errorf(
|
||||
"query %s: property %q: metric %q must have the same help text, type and label names as other properties sharing this metric name",
|
||||
query.Name, property.Name, property.Metric,
|
||||
))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
property.desc = prometheus.NewDesc(
|
||||
property.Metric,
|
||||
property.Help,
|
||||
query.labelNames,
|
||||
property.Labels,
|
||||
)
|
||||
|
||||
// Creating a sample metric surfaces invalid metric or label names,
|
||||
// such as reserved "__" labels, now instead of panicking at scrape
|
||||
// time.
|
||||
if _, err := prometheus.NewConstMetric(property.desc, property.metricType, 0, make([]string, len(query.labelNames))...); err != nil {
|
||||
errs = append(errs, fmt.Errorf("query %s: property %q: %w", query.Name, property.Name, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
signatures[property.Metric] = signature
|
||||
|
||||
if len(query.labelNames) == 0 {
|
||||
seriesQueries[series] = query.Name
|
||||
}
|
||||
|
||||
properties = append(properties, property)
|
||||
|
||||
addSelected(property.Name)
|
||||
}
|
||||
|
||||
if len(errs) > queryErrs {
|
||||
continue
|
||||
}
|
||||
|
||||
query.LabelProperties = labelProperties
|
||||
query.Properties = properties
|
||||
|
||||
query.Where = strings.TrimSpace(query.Where)
|
||||
|
||||
query.wql = fmt.Sprintf("SELECT %s FROM %s", strings.Join(selected, ", "), query.Class)
|
||||
if query.Where != "" {
|
||||
query.wql += " WHERE " + query.Where
|
||||
}
|
||||
|
||||
query.query, err = mi.NewQuery(query.wql)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("query %s: invalid query %q: %w", query.Name, query.wql, err))
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Build only validates the static configuration and does not run the
|
||||
// query. Whether a namespace, class or property exists and is readable
|
||||
// is a runtime condition, reported per query via query_success.
|
||||
c.queries = append(c.queries, query)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Collect sends the metric values for each configured WMI query
|
||||
// to the provided prometheus Metric channel.
|
||||
func (c *Collector) Collect(ch chan<- prometheus.Metric, maxScrapeDuration time.Duration) error {
|
||||
// The queries run one after another and share the scrape timeout, so each
|
||||
// query only gets the time that is left. A non-positive timeout means no
|
||||
// deadline.
|
||||
var deadline time.Time
|
||||
|
||||
if maxScrapeDuration > 0 {
|
||||
deadline = time.Now().Add(maxScrapeDuration)
|
||||
}
|
||||
|
||||
var errs []error
|
||||
|
||||
for _, query := range c.queries {
|
||||
startTime := time.Now()
|
||||
err := c.collectQuery(ch, query, deadline)
|
||||
duration := time.Since(startTime)
|
||||
success := 1.0
|
||||
|
||||
if err != nil {
|
||||
// A failing query is reported via query_success. Like the registry
|
||||
// collector, it does not fail the whole collector.
|
||||
errs = append(errs, fmt.Errorf("failed to collect query %s: %w", query.Name, errors.Join(err, types.ErrNoData)))
|
||||
success = 0.0
|
||||
|
||||
c.logger.Warn("wmi query failed",
|
||||
slog.String("query", query.Name),
|
||||
slog.String("wql", query.wql),
|
||||
slog.Duration("duration", duration),
|
||||
slog.Any("err", err),
|
||||
)
|
||||
} else {
|
||||
c.logger.Debug(fmt.Sprintf("wmi query %s succeeded after %s", query.Name, duration))
|
||||
}
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
c.querySuccessDesc,
|
||||
prometheus.GaugeValue,
|
||||
success,
|
||||
query.Name,
|
||||
)
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
c.queryDurationDesc,
|
||||
prometheus.GaugeValue,
|
||||
duration.Seconds(),
|
||||
query.Name,
|
||||
)
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (c *Collector) collectQuery(ch chan<- prometheus.Metric, query Query, deadline time.Time) error {
|
||||
// Zero means no timeout, which leaves the MI default in place.
|
||||
var timeout time.Duration
|
||||
|
||||
if !deadline.IsZero() {
|
||||
timeout = time.Until(deadline)
|
||||
|
||||
// MI timeouts have microsecond resolution and treat zero as unset,
|
||||
// so a query is not started without a usable amount of time left.
|
||||
if timeout < minQueryTimeout {
|
||||
return errScrapeTimeout
|
||||
}
|
||||
}
|
||||
|
||||
labelValues := make([]string, len(query.LabelProperties))
|
||||
|
||||
// A property that cannot be read usually fails for every instance, so
|
||||
// each property reports at most one error per scrape.
|
||||
propertyErrs := make(map[string]error)
|
||||
|
||||
err := c.miSession.QueryFunc(query.namespace, query.query, timeout, func(instance *mi.Instance) error {
|
||||
// The MI timeout does not stop a query that keeps returning
|
||||
// instances, so the deadline is also checked per instance.
|
||||
if !deadline.IsZero() && time.Now().After(deadline) {
|
||||
return errScrapeTimeout
|
||||
}
|
||||
|
||||
for i, labelProperty := range query.LabelProperties {
|
||||
element, err := instance.GetElement(labelProperty.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read label property %s: %w", labelProperty.Name, err)
|
||||
}
|
||||
|
||||
if element.IsNull() {
|
||||
labelValues[i] = ""
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
labelValues[i], err = element.String()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read label property %s: %w", labelProperty.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, property := range query.Properties {
|
||||
if _, failed := propertyErrs[property.Name]; failed {
|
||||
continue
|
||||
}
|
||||
|
||||
element, err := instance.GetElement(property.Name)
|
||||
if err != nil {
|
||||
propertyErrs[property.Name] = fmt.Errorf("failed to read property %s: %w", property.Name, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// WMI reports properties without a value as null. Exporting them
|
||||
// as 0 would be indistinguishable from a real 0, so skip them.
|
||||
if element.IsNull() {
|
||||
continue
|
||||
}
|
||||
|
||||
value, err := element.Float64()
|
||||
if err != nil {
|
||||
propertyErrs[property.Name] = fmt.Errorf("failed to read property %s: %w", property.Name, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
ch <- prometheus.MustNewConstMetric(
|
||||
property.desc,
|
||||
property.metricType,
|
||||
value,
|
||||
labelValues...,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
errs := make([]error, 0, len(propertyErrs))
|
||||
|
||||
for _, property := range query.Properties {
|
||||
if err, ok := propertyErrs[property.Name]; ok {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// firstCommonLabel returns the first label name in labelNames that is also a
|
||||
// key of labels.
|
||||
func firstCommonLabel(labelNames []string, labels map[string]string) (string, bool) {
|
||||
for _, name := range labelNames {
|
||||
if _, ok := labels[name]; ok {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// sanitizeMetricName turns an arbitrary string into a valid Prometheus metric
|
||||
// or label name by lowercasing it, replacing every non-alphanumeric character
|
||||
// with an underscore, and trimming leading and trailing underscores.
|
||||
func sanitizeMetricName(name string) string {
|
||||
return strings.Trim(reNonAlphaNum.ReplaceAllString(strings.ToLower(name), "_"), "_")
|
||||
}
|
||||
|
||||
// seriesKey identifies the series of a property without label properties: its
|
||||
// metric name and constant labels.
|
||||
func seriesKey(metric string, labels map[string]string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(metric)
|
||||
|
||||
for _, name := range slices.Sorted(maps.Keys(labels)) {
|
||||
sb.WriteString("\xff" + name + "=" + labels[name])
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
// 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 wmi_test
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
"github.com/prometheus-community/windows_exporter/internal/collector/wmi"
|
||||
"github.com/prometheus-community/windows_exporter/internal/types"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// collectorAdapter bridges wmi.Collector's Collect(ch, time.Duration) error
|
||||
// signature to the prometheus.Collector interface. The Collect error is
|
||||
// discarded: these tests deliberately configure failing queries, which are
|
||||
// reported via query_success=0 while every other metric is still emitted.
|
||||
type collectorAdapter struct {
|
||||
*wmi.Collector
|
||||
|
||||
maxScrapeDuration time.Duration
|
||||
}
|
||||
|
||||
// Describe implements the prometheus.Collector interface.
|
||||
func (collectorAdapter) Describe(chan<- *prometheus.Desc) {}
|
||||
|
||||
// Collect implements the prometheus.Collector interface.
|
||||
func (a collectorAdapter) Collect(ch chan<- prometheus.Metric) {
|
||||
_ = a.Collector.Collect(ch, a.maxScrapeDuration)
|
||||
}
|
||||
|
||||
// scrape builds c against a live MI session and returns its exposition.
|
||||
func scrape(t *testing.T, c *wmi.Collector) string {
|
||||
t.Helper()
|
||||
|
||||
return scrapeWithTimeout(t, c, 5*time.Second)
|
||||
}
|
||||
|
||||
// scrapeWithTimeout is scrape with a custom scrape timeout.
|
||||
func scrapeWithTimeout(t *testing.T, c *wmi.Collector, maxScrapeDuration time.Duration) string {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), newSession(t)))
|
||||
|
||||
reg := prometheus.NewRegistry()
|
||||
reg.MustRegister(collectorAdapter{c, maxScrapeDuration})
|
||||
|
||||
rw := httptest.NewRecorder()
|
||||
promhttp.HandlerFor(reg, promhttp.HandlerOpts{ErrorHandling: promhttp.ContinueOnError}).ServeHTTP(rw, &http.Request{})
|
||||
|
||||
return rw.Body.String()
|
||||
}
|
||||
|
||||
// collect builds c against a live MI session and returns the number of emitted
|
||||
// metrics and the Collect error.
|
||||
func collect(t *testing.T, c *wmi.Collector) (int, error) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), newSession(t)))
|
||||
|
||||
ch := make(chan prometheus.Metric, 1000)
|
||||
err := c.Collect(ch, 5*time.Second)
|
||||
close(ch)
|
||||
|
||||
return len(ch), err
|
||||
}
|
||||
|
||||
// TestCollectorMetrics asserts the property→Desc→metric wiring end to end
|
||||
// against WMI instances whose values are identical on every Windows host: the
|
||||
// System Idle Process (Handle 0) and the System process (Handle 4), both with
|
||||
// parent PID 0 and without an executable path or install date, and the
|
||||
// primary operating system.
|
||||
//
|
||||
// It covers multiple instances per query, auto-generated and explicit metric
|
||||
// names, gauge vs counter, label properties with default and explicit label
|
||||
// names, a property used as both label and value, null labels and values,
|
||||
// constant labels, boolean conversion, a metric name shared by two queries,
|
||||
// and the query_success semantics for an unknown class, a non-numeric
|
||||
// property next to a numeric one, and a label property of unsupported type.
|
||||
func TestCollectorMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := scrape(t, wmi.New(&wmi.Config{
|
||||
Queries: []wmi.Query{
|
||||
{
|
||||
Name: "kernel",
|
||||
Class: "Win32_Process",
|
||||
Where: "Handle = 0 OR Handle = 4",
|
||||
LabelProperties: []wmi.LabelProperty{
|
||||
{Name: "Name", Label: "process"},
|
||||
// Default label name: the sanitized, lowercased property name.
|
||||
{Name: "Handle"},
|
||||
// Null for both processes, so the label value is empty.
|
||||
{Name: "ExecutablePath", Label: "path"},
|
||||
// Also exported as a value below; selected only once.
|
||||
{Name: "ProcessId", Label: "pid"},
|
||||
},
|
||||
Properties: []wmi.Property{
|
||||
// Auto-named gauge → windows_wmi_kernel_processid.
|
||||
{Name: "ProcessId"},
|
||||
// Explicit metric + counter type + help + constant label.
|
||||
{
|
||||
Name: "ParentProcessId",
|
||||
Metric: "windows_wmi_test_parent_pid",
|
||||
Help: "custom wmi help text",
|
||||
Type: "counter",
|
||||
Labels: map[string]string{"foo": "bar"},
|
||||
},
|
||||
// Null for every process, so no sample is exported.
|
||||
{Name: "InstallDate"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "os",
|
||||
Class: "Win32_OperatingSystem",
|
||||
// Whitespace only, so no WHERE clause is generated.
|
||||
Where: " \t ",
|
||||
Properties: []wmi.Property{{Name: "Primary"}},
|
||||
},
|
||||
{
|
||||
Name: "shared_a",
|
||||
Class: "Win32_OperatingSystem",
|
||||
Properties: []wmi.Property{{Name: "Primary", Metric: "windows_wmi_test_shared", Labels: map[string]string{"query": "a"}}},
|
||||
},
|
||||
{
|
||||
Name: "shared_b",
|
||||
Class: "Win32_OperatingSystem",
|
||||
Properties: []wmi.Property{{Name: "Primary", Metric: "windows_wmi_test_shared", Labels: map[string]string{"query": "b"}}},
|
||||
},
|
||||
{
|
||||
Name: "missing_class",
|
||||
Class: "Win32_WindowsExporterDoesNotExist",
|
||||
Properties: []wmi.Property{{Name: "Value"}},
|
||||
},
|
||||
{
|
||||
// Caption is a string, which cannot be exported as a sample.
|
||||
// Primary is still exported.
|
||||
Name: "partial",
|
||||
Class: "Win32_OperatingSystem",
|
||||
Properties: []wmi.Property{{Name: "Caption"}, {Name: "Primary"}},
|
||||
},
|
||||
{
|
||||
// A datetime cannot be used as a label value.
|
||||
Name: "unsupported_label",
|
||||
Class: "Win32_OperatingSystem",
|
||||
LabelProperties: []wmi.LabelProperty{{Name: "LastBootUpTime"}},
|
||||
Properties: []wmi.Property{{Name: "Primary"}},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Durations vary between runs; their presence is asserted separately.
|
||||
var lines []string
|
||||
|
||||
for line := range strings.SplitSeq(got, "\n") {
|
||||
if !strings.Contains(line, "windows_wmi_query_duration_seconds") {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
expected := `# HELP windows_wmi_kernel_processid windows_exporter: custom WMI metric
|
||||
# TYPE windows_wmi_kernel_processid gauge
|
||||
windows_wmi_kernel_processid{handle="0",path="",pid="0",process="System Idle Process"} 0
|
||||
windows_wmi_kernel_processid{handle="4",path="",pid="4",process="System"} 4
|
||||
# HELP windows_wmi_os_primary windows_exporter: custom WMI metric
|
||||
# TYPE windows_wmi_os_primary gauge
|
||||
windows_wmi_os_primary 1
|
||||
# HELP windows_wmi_partial_primary windows_exporter: custom WMI metric
|
||||
# TYPE windows_wmi_partial_primary gauge
|
||||
windows_wmi_partial_primary 1
|
||||
# HELP windows_wmi_query_success Whether the WMI query and all of its configured properties could be read successfully.
|
||||
# TYPE windows_wmi_query_success gauge
|
||||
windows_wmi_query_success{name="kernel"} 1
|
||||
windows_wmi_query_success{name="missing_class"} 0
|
||||
windows_wmi_query_success{name="os"} 1
|
||||
windows_wmi_query_success{name="partial"} 0
|
||||
windows_wmi_query_success{name="shared_a"} 1
|
||||
windows_wmi_query_success{name="shared_b"} 1
|
||||
windows_wmi_query_success{name="unsupported_label"} 0
|
||||
# HELP windows_wmi_test_parent_pid custom wmi help text
|
||||
# TYPE windows_wmi_test_parent_pid counter
|
||||
windows_wmi_test_parent_pid{foo="bar",handle="0",path="",pid="0",process="System Idle Process"} 0
|
||||
windows_wmi_test_parent_pid{foo="bar",handle="4",path="",pid="4",process="System"} 0
|
||||
# HELP windows_wmi_test_shared windows_exporter: custom WMI metric
|
||||
# TYPE windows_wmi_test_shared gauge
|
||||
windows_wmi_test_shared{query="a"} 1
|
||||
windows_wmi_test_shared{query="b"} 1
|
||||
`
|
||||
|
||||
require.Equal(t, expected, strings.Join(lines, "\n"))
|
||||
|
||||
for _, name := range []string{"kernel", "missing_class", "os", "partial", "shared_a", "shared_b", "unsupported_label"} {
|
||||
require.Regexp(t, `(?m)^windows_wmi_query_duration_seconds\{name="`+name+`"\} \d`, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectorDatetime asserts that a DATETIME timestamp is exported as
|
||||
// seconds since the Unix epoch, including the UTC offset WMI reports.
|
||||
func TestCollectorDatetime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := scrape(t, wmi.New(&wmi.Config{
|
||||
Queries: []wmi.Query{
|
||||
{
|
||||
Name: "os",
|
||||
Class: "Win32_OperatingSystem",
|
||||
Properties: []wmi.Property{{Name: "LocalDateTime", Metric: "windows_wmi_test_now_timestamp_seconds"}},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
match := regexp.MustCompile(`(?m)^windows_wmi_test_now_timestamp_seconds (\S+)$`).FindStringSubmatch(got)
|
||||
require.Len(t, match, 2, got)
|
||||
|
||||
value, err := strconv.ParseFloat(match[1], 64)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, float64(time.Now().Unix()), value, 60)
|
||||
}
|
||||
|
||||
// TestCollectPropertyErrorReportedOnce verifies that a property failing for
|
||||
// every instance is reported once per scrape, not once per instance, and that
|
||||
// the other properties of those instances are still exported.
|
||||
func TestCollectPropertyErrorReportedOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
metrics, err := collect(t, wmi.New(&wmi.Config{
|
||||
Queries: []wmi.Query{
|
||||
{
|
||||
Name: "kernel",
|
||||
Class: "Win32_Process",
|
||||
Where: "Handle = 0 OR Handle = 4",
|
||||
LabelProperties: []wmi.LabelProperty{{Name: "Handle"}},
|
||||
Properties: []wmi.Property{{Name: "Name"}, {Name: "ProcessId"}},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// Like the registry collector, a failing query is reported via
|
||||
// query_success and does not fail the whole collector.
|
||||
require.ErrorIs(t, err, types.ErrNoData)
|
||||
require.Equal(t, 1, strings.Count(err.Error(), "failed to read property Name"), err.Error())
|
||||
|
||||
// Two ProcessId samples plus query_success and query_duration_seconds.
|
||||
require.Equal(t, 4, metrics)
|
||||
}
|
||||
|
||||
// TestCollectScrapeTimeout verifies that queries are not started once the
|
||||
// scrape timeout is used up, and are reported as failed.
|
||||
func TestCollectScrapeTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := wmi.New(&wmi.Config{
|
||||
Queries: []wmi.Query{
|
||||
{Name: "a", Class: "Win32_OperatingSystem", Properties: []wmi.Property{{Name: "Primary"}}},
|
||||
{Name: "b", Class: "Win32_OperatingSystem", Properties: []wmi.Property{{Name: "Primary"}}, LabelProperties: []wmi.LabelProperty{{Name: "Caption"}}},
|
||||
},
|
||||
})
|
||||
|
||||
got := scrapeWithTimeout(t, c, time.Nanosecond)
|
||||
|
||||
require.Contains(t, got, `windows_wmi_query_success{name="a"} 0`)
|
||||
require.Contains(t, got, `windows_wmi_query_success{name="b"} 0`)
|
||||
require.NotContains(t, got, "windows_wmi_a_primary")
|
||||
require.NotContains(t, got, "windows_wmi_b_primary")
|
||||
|
||||
ch := make(chan prometheus.Metric, 10)
|
||||
err := c.Collect(ch, time.Nanosecond)
|
||||
close(ch)
|
||||
|
||||
require.ErrorIs(t, err, types.ErrNoData)
|
||||
require.Equal(t, 2, strings.Count(err.Error(), "scrape timeout exceeded"), err.Error())
|
||||
}
|
||||
|
||||
// TestNewWithFlags verifies that the queries flag is parsed as YAML and JSON,
|
||||
// and that invalid input is rejected at parse time.
|
||||
func TestNewWithFlags(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
queries string
|
||||
}{
|
||||
{
|
||||
name: "yaml",
|
||||
queries: `- name: os
|
||||
class: Win32_OperatingSystem
|
||||
properties:
|
||||
- name: Primary`,
|
||||
},
|
||||
{
|
||||
name: "json",
|
||||
queries: `[{"name":"os","class":"Win32_OperatingSystem","properties":[{"name":"Primary"}]}]`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := kingpin.New("test", "")
|
||||
c := wmi.NewWithFlags(app)
|
||||
|
||||
// The = form is required, because kingpin reads a separate value
|
||||
// starting with "-", like a YAML list, as another flag.
|
||||
_, err := app.Parse([]string{"--collector.wmi.queries=" + tc.queries})
|
||||
require.NoError(t, err)
|
||||
|
||||
metrics, err := collect(t, c)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Primary plus query_success and query_duration_seconds.
|
||||
require.Equal(t, 3, metrics)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app := kingpin.New("test", "")
|
||||
_ = wmi.NewWithFlags(app)
|
||||
|
||||
_, err := app.Parse([]string{"--collector.wmi.queries", `[{"name": `})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCollectConcurrent runs overlapping scrapes, as concurrent HTTP requests
|
||||
// do, so the race detector in CI can catch state shared between them.
|
||||
func TestCollectConcurrent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := wmi.New(&wmi.Config{
|
||||
Queries: []wmi.Query{
|
||||
{
|
||||
Name: "kernel",
|
||||
Class: "Win32_Process",
|
||||
Where: "Handle = 0 OR Handle = 4",
|
||||
LabelProperties: []wmi.LabelProperty{{Name: "Handle"}},
|
||||
Properties: []wmi.Property{{Name: "ProcessId"}, {Name: "Name"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), newSession(t)))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for range 8 {
|
||||
wg.Go(func() {
|
||||
ch := make(chan prometheus.Metric, 100)
|
||||
err := c.Collect(ch, 5*time.Second)
|
||||
close(ch)
|
||||
|
||||
// Name is a string, so every scrape reports exactly that error.
|
||||
if err == nil || len(ch) != 4 {
|
||||
t.Errorf("unexpected scrape result: %d metrics, err=%v", len(ch), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// 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 wmi_test
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/alecthomas/kingpin/v2"
|
||||
"github.com/prometheus-community/windows_exporter/internal/collector/wmi"
|
||||
"github.com/prometheus-community/windows_exporter/internal/mi"
|
||||
"github.com/prometheus-community/windows_exporter/internal/utils/testutils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func BenchmarkCollector(b *testing.B) {
|
||||
queries := `[{"name":"logical_disk","class":"Win32_LogicalDisk","where":"DriveType = 3","label_properties":[{"name":"DeviceID"}],"properties":[{"name":"FreeSpace"},{"name":"Size"}]}]`
|
||||
|
||||
testutils.FuncBenchmarkCollector(b, wmi.Name, wmi.NewWithFlags, func(app *kingpin.Application) {
|
||||
// The queries are only read by the application action, which runs on parse.
|
||||
_, err := app.Parse([]string{"--collector.wmi.queries", queries})
|
||||
require.NoError(b, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCollector(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testutils.TestCollector(t, wmi.New, &wmi.Config{
|
||||
Queries: []wmi.Query{
|
||||
{
|
||||
Name: "os",
|
||||
Class: "Win32_OperatingSystem",
|
||||
Properties: []wmi.Property{
|
||||
{Name: "NumberOfProcesses"},
|
||||
{Name: "LastBootUpTime", Metric: "windows_wmi_os_last_boot_timestamp_seconds"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "logical_disk",
|
||||
Namespace: `root\CIMv2`,
|
||||
Class: "Win32_LogicalDisk",
|
||||
Where: "DriveType = 3",
|
||||
LabelProperties: []wmi.LabelProperty{{Name: "DeviceID", Label: "volume"}},
|
||||
Properties: []wmi.Property{{Name: "FreeSpace"}, {Name: "Size"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// newSession returns a live MI session. Build must be validated against a real
|
||||
// session, because a nil session is rejected before the configuration is
|
||||
// looked at.
|
||||
func newSession(t *testing.T) *mi.Session {
|
||||
t.Helper()
|
||||
|
||||
app, err := mi.ApplicationInitialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { require.NoError(t, app.Close()) })
|
||||
|
||||
session, err := app.NewSession(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { require.NoError(t, session.Close()) })
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
func TestCollectorBuildEmptyConfigWithoutSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, wmi.New(nil).Build(slog.New(slog.DiscardHandler), nil))
|
||||
require.Error(t, wmi.New(&wmi.Config{Queries: []wmi.Query{
|
||||
{Name: "os", Class: "Win32_OperatingSystem", Properties: []wmi.Property{{Name: "NumberOfProcesses"}}},
|
||||
}}).Build(slog.New(slog.DiscardHandler), nil))
|
||||
}
|
||||
|
||||
func TestCollectorBuildErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := newSession(t)
|
||||
|
||||
valid := func(mutate func(*wmi.Query)) []wmi.Query {
|
||||
query := wmi.Query{
|
||||
Name: "disk",
|
||||
Class: "Win32_LogicalDisk",
|
||||
LabelProperties: []wmi.LabelProperty{{Name: "DeviceID"}},
|
||||
Properties: []wmi.Property{{Name: "FreeSpace"}},
|
||||
}
|
||||
|
||||
mutate(&query)
|
||||
|
||||
return []wmi.Query{query}
|
||||
}
|
||||
|
||||
// Guard against the table passing for the wrong reason.
|
||||
require.NoError(t, wmi.New(&wmi.Config{Queries: valid(func(*wmi.Query) {})}).Build(slog.New(slog.DiscardHandler), session))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
queries []wmi.Query
|
||||
err string
|
||||
}{
|
||||
{
|
||||
name: "missing name",
|
||||
queries: valid(func(q *wmi.Query) { q.Name = "" }),
|
||||
err: "query name is required",
|
||||
},
|
||||
{
|
||||
name: "duplicate name",
|
||||
queries: append(
|
||||
valid(func(*wmi.Query) {}),
|
||||
valid(func(q *wmi.Query) { q.Class = "Win32_Volume" })...,
|
||||
),
|
||||
err: "query disk: name is duplicated",
|
||||
},
|
||||
{
|
||||
name: "names differing only by case",
|
||||
queries: append(
|
||||
valid(func(*wmi.Query) {}),
|
||||
valid(func(q *wmi.Query) { q.Name = "DISK" })...,
|
||||
),
|
||||
err: "query DISK: name produces the same metric names as query disk",
|
||||
},
|
||||
{
|
||||
name: "names differing only by special characters",
|
||||
queries: append(
|
||||
valid(func(q *wmi.Query) { q.Name = "my_disk" }),
|
||||
valid(func(q *wmi.Query) { q.Name = "my-disk" })...,
|
||||
),
|
||||
err: "query my-disk: name produces the same metric names as query my_disk",
|
||||
},
|
||||
{
|
||||
name: "missing class",
|
||||
queries: valid(func(q *wmi.Query) { q.Class = "" }),
|
||||
err: `class "" must be a valid WMI class name`,
|
||||
},
|
||||
{
|
||||
name: "class with WQL injection",
|
||||
queries: valid(func(q *wmi.Query) { q.Class = "Win32_LogicalDisk WHERE 1=1" }),
|
||||
err: "must be a valid WMI class name",
|
||||
},
|
||||
{
|
||||
name: "no properties",
|
||||
queries: valid(func(q *wmi.Query) { q.Properties = nil }),
|
||||
err: "no properties configured",
|
||||
},
|
||||
{
|
||||
name: "invalid property name",
|
||||
queries: valid(func(q *wmi.Query) { q.Properties = []wmi.Property{{Name: "Free Space"}} }),
|
||||
err: `property "Free Space" must be a valid WMI property name`,
|
||||
},
|
||||
{
|
||||
name: "duplicate property differing only by case",
|
||||
queries: valid(func(q *wmi.Query) {
|
||||
q.Properties = []wmi.Property{{Name: "FreeSpace", Metric: "windows_a"}, {Name: "freespace", Metric: "windows_b"}}
|
||||
}),
|
||||
err: `property "freespace" is duplicated`,
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
queries: valid(func(q *wmi.Query) { q.Properties[0].Type = "histogram" }),
|
||||
err: `invalid type "histogram"`,
|
||||
},
|
||||
{
|
||||
name: "invalid label property name",
|
||||
queries: valid(func(q *wmi.Query) { q.LabelProperties = []wmi.LabelProperty{{Name: ""}} }),
|
||||
err: `label property "" must be a valid WMI property name`,
|
||||
},
|
||||
{
|
||||
name: "reserved label name",
|
||||
queries: valid(func(q *wmi.Query) { q.LabelProperties[0].Label = "__volume" }),
|
||||
err: `"__volume"`,
|
||||
},
|
||||
{
|
||||
name: "reserved constant label name",
|
||||
queries: valid(func(q *wmi.Query) { q.Properties[0].Labels = map[string]string{"__foo": "bar"} }),
|
||||
err: `"__foo"`,
|
||||
},
|
||||
{
|
||||
name: "duplicate label",
|
||||
queries: valid(func(q *wmi.Query) {
|
||||
q.LabelProperties = []wmi.LabelProperty{{Name: "DeviceID", Label: "volume"}, {Name: "VolumeName", Label: "volume"}}
|
||||
}),
|
||||
err: `label "volume" is duplicated`,
|
||||
},
|
||||
{
|
||||
name: "constant label collides with label property",
|
||||
queries: valid(func(q *wmi.Query) { q.Properties[0].Labels = map[string]string{"deviceid": "x"} }),
|
||||
err: `constant label "deviceid" is already set by a label property`,
|
||||
},
|
||||
{
|
||||
name: "properties producing identical series",
|
||||
queries: valid(func(q *wmi.Query) {
|
||||
q.Properties = []wmi.Property{{Name: "FreeSpace", Metric: "windows_disk"}, {Name: "Size", Metric: "windows_disk"}}
|
||||
}),
|
||||
err: `properties "FreeSpace" and "Size" produce identical series windows_disk`,
|
||||
},
|
||||
{
|
||||
name: "queries without label properties producing identical series",
|
||||
queries: append(
|
||||
valid(func(q *wmi.Query) {
|
||||
q.LabelProperties = nil
|
||||
q.Properties[0].Metric = "windows_disk"
|
||||
}),
|
||||
valid(func(q *wmi.Query) {
|
||||
q.Name = "volume"
|
||||
q.LabelProperties = nil
|
||||
q.Properties[0].Metric = "windows_disk"
|
||||
})...,
|
||||
),
|
||||
err: `query volume: property "FreeSpace" produces the same series windows_disk as query disk`,
|
||||
},
|
||||
{
|
||||
name: "explicit metric reserved by the collector",
|
||||
queries: valid(func(q *wmi.Query) { q.Properties[0].Metric = "windows_wmi_query_success" }),
|
||||
err: `metric "windows_wmi_query_success" is reserved`,
|
||||
},
|
||||
{
|
||||
name: "derived metric reserved by the collector",
|
||||
queries: valid(func(q *wmi.Query) {
|
||||
q.Name = "query"
|
||||
q.Properties = []wmi.Property{{Name: "Duration_Seconds"}}
|
||||
}),
|
||||
err: `metric "windows_wmi_query_duration_seconds" is reserved`,
|
||||
},
|
||||
{
|
||||
name: "shared metric name with inconsistent help text",
|
||||
queries: valid(func(q *wmi.Query) {
|
||||
q.Properties = []wmi.Property{
|
||||
{Name: "FreeSpace", Metric: "windows_disk", Help: "a", Labels: map[string]string{"kind": "free"}},
|
||||
{Name: "Size", Metric: "windows_disk", Help: "b", Labels: map[string]string{"kind": "size"}},
|
||||
}
|
||||
}),
|
||||
err: `metric "windows_disk" must have the same help text, type and label names`,
|
||||
},
|
||||
{
|
||||
name: "shared metric name with inconsistent label names",
|
||||
queries: append(
|
||||
valid(func(q *wmi.Query) { q.Properties[0].Metric = "windows_disk" }),
|
||||
valid(func(q *wmi.Query) {
|
||||
q.Name = "volume"
|
||||
q.LabelProperties = nil
|
||||
q.Properties[0].Metric = "windows_disk"
|
||||
})...,
|
||||
),
|
||||
err: `metric "windows_disk" must have the same help text, type and label names`,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := wmi.New(&wmi.Config{Queries: tc.queries})
|
||||
require.ErrorContains(t, c.Build(slog.New(slog.DiscardHandler), session), tc.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectorBuildValid covers configurations close to the rejected ones in
|
||||
// TestCollectorBuildErrors that must still be accepted.
|
||||
func TestCollectorBuildValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
session := newSession(t)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
queries []wmi.Query
|
||||
}{
|
||||
{
|
||||
name: "whitespace-only where",
|
||||
queries: []wmi.Query{
|
||||
{Name: "os", Class: "Win32_OperatingSystem", Where: " \t ", Properties: []wmi.Property{{Name: "Primary"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "queries with label properties sharing a metric",
|
||||
queries: []wmi.Query{
|
||||
{Name: "fixed", Class: "Win32_LogicalDisk", Where: "DriveType = 3", LabelProperties: []wmi.LabelProperty{{Name: "DeviceID"}}, Properties: []wmi.Property{{Name: "Size", Metric: "windows_disk_size_bytes"}}},
|
||||
{Name: "removable", Class: "Win32_LogicalDisk", Where: "DriveType = 2", LabelProperties: []wmi.LabelProperty{{Name: "DeviceID"}}, Properties: []wmi.Property{{Name: "Size", Metric: "windows_disk_size_bytes"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "queries without label properties sharing a metric with distinct constant labels",
|
||||
queries: []wmi.Query{
|
||||
{Name: "a", Class: "Win32_OperatingSystem", Properties: []wmi.Property{{Name: "Primary", Metric: "windows_os_primary", Labels: map[string]string{"query": "a"}}}},
|
||||
{Name: "b", Class: "Win32_OperatingSystem", Properties: []wmi.Property{{Name: "Primary", Metric: "windows_os_primary", Labels: map[string]string{"query": "b"}}}},
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := wmi.New(&wmi.Config{Queries: tc.queries})
|
||||
require.NoError(t, c.Build(slog.New(slog.DiscardHandler), session))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user