From 88034b818ea68fbdbfefe2711fd575f02bdbad2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Wed, 7 Oct 2026 23:22:11 +0200 Subject: [PATCH] wmi: add generic wmi collector (#2530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 Signed-off-by: Jan-Otto Kröpke --- README.md | 1 + docs/README.md | 1 + docs/collector.wmi.md | 353 +++++++++++++ internal/collector/wmi/types.go | 62 +++ internal/collector/wmi/wmi.go | 604 ++++++++++++++++++++++ internal/collector/wmi/wmi_output_test.go | 385 ++++++++++++++ internal/collector/wmi/wmi_test.go | 314 +++++++++++ internal/mi/instance.go | 5 +- internal/mi/mi_test.go | 116 +++++ internal/mi/operation.go | 29 +- internal/mi/session.go | 124 ++++- internal/mi/value.go | 164 +++++- internal/mi/value_internal_test.go | 176 +++++++ pkg/collector/collection.go | 2 + pkg/collector/config.go | 3 + pkg/collector/map.go | 2 + 16 files changed, 2309 insertions(+), 32 deletions(-) create mode 100644 docs/collector.wmi.md create mode 100644 internal/collector/wmi/types.go create mode 100644 internal/collector/wmi/wmi.go create mode 100644 internal/collector/wmi/wmi_output_test.go create mode 100644 internal/collector/wmi/wmi_test.go create mode 100644 internal/mi/value_internal_test.go diff --git a/README.md b/README.md index f8c1aac6..59bee312 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ A Prometheus exporter for Windows machines. | [udp](docs/collector.udp.md) | UDP connections | | | [update](docs/collector.update.md) | Windows Update Service | | | [vmware](docs/collector.vmware.md) | Performance counters installed by the Vmware Guest agent | | +| [wmi](docs/collector.wmi.md) | Custom WMI query metrics | | See the linked documentation on each collector for more information on reported metrics, configuration settings and usage examples. diff --git a/docs/README.md b/docs/README.md index 849181d5..4723fe8f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -47,3 +47,4 @@ This directory contains documentation of the collectors in the windows_exporter, - [`udp`](collector.udp.md) - [`update`](collector.update.md) - [`vmware`](collector.vmware.md) +- [`wmi`](collector.wmi.md) diff --git a/docs/collector.wmi.md b/docs/collector.wmi.md new file mode 100644 index 00000000..5df23bdb --- /dev/null +++ b/docs/collector.wmi.md @@ -0,0 +1,353 @@ +# wmi collector + +The wmi collector runs user-defined WMI queries and exposes numeric properties of +the returned instances as named metrics. + +Like the [performancecounter](collector.performancecounter.md) and +[registry](collector.registry.md) collectors, each property is mapped to its own +metric: the query is a grouping container, and every property under it declares +the metric name, type, and labels it is exported as. Unlike those collectors, a +query usually returns several instances, so properties can also be turned into +labels that tell the instances apart. + +> [!NOTE] +> Prefer the [performancecounter](collector.performancecounter.md) collector when +> the data is also available as a performance counter. WMI queries are +> considerably more expensive, and some WMI providers take hundreds of +> milliseconds per query. + +| | | +|---------------------|----------| +| Metric name prefix | `wmi` | +| Data source | WMI (MI) | +| Enabled by default? | No | + +## Flags + +### `--collector.wmi.queries` + +Queries is a list of WMI queries to run. The value takes the form of a JSON array +of objects. YAML is supported. + +> [!CAUTION] +> If you are using a configuration file, the value must be kept as a string. +> +> Use a `|-` to keep the value as a string. + +#### Example + +```yaml +collector: + wmi: + queries: |- + - name: logical_disk + class: Win32_LogicalDisk + where: DriveType = 3 + label_properties: + - name: DeviceID + label: volume + properties: + - name: FreeSpace + metric: windows_wmi_logical_disk_free_bytes + help: Free space on the logical disk in bytes. + - name: Size + metric: windows_wmi_logical_disk_size_bytes + help: Size of the logical disk in bytes. + - name: os + class: Win32_OperatingSystem + properties: + - name: LastBootUpTime + metric: windows_wmi_os_last_boot_timestamp_seconds + help: Time of the last boot as a Unix timestamp. + - name: NumberOfProcesses +``` + +#### Schema + +YAML: + +```yaml +- name: logical_disk # required, unique ID for the query + namespace: root/CIMv2 # optional + class: Win32_LogicalDisk # required + where: DriveType = 3 # optional + label_properties: # optional + - name: DeviceID # WMI property name + label: volume # optional + properties: + - name: FreeSpace # WMI property name + metric: windows_wmi_logical_disk_free_bytes # optional + help: Free space on the logical disk in bytes. # optional + type: gauge # optional + labels: # optional + source: wmi +``` + +JSON: + +```json +[ + { + "name": "logical_disk", + "namespace": "root/CIMv2", + "class": "Win32_LogicalDisk", + "where": "DriveType = 3", + "label_properties": [ + { "name": "DeviceID", "label": "volume" } + ], + "properties": [ + { + "name": "FreeSpace", + "metric": "windows_wmi_logical_disk_free_bytes", + "help": "Free space on the logical disk in bytes.", + "type": "gauge", + "labels": { "source": "wmi" } + } + ] + } +] +``` + +The collector builds the WQL statement from the configuration: + +``` +SELECT , FROM [WHERE ] +``` + +#### name + +Required, unique ID for the query. It is used as the `name` label on +`windows_wmi_query_success` and `windows_wmi_query_duration_seconds`, to identify +the query in logs, and to seed auto-generated metric names (see +[Metric naming](#metric-naming)). Duplicates are rejected at build time, and so +are names that only differ in case or special characters (`os` and `OS`, or +`my_disk` and `my-disk`), because they produce the same metric names. + +#### namespace + +The WMI namespace of the class. Optional — defaults to `root/CIMv2`. Backslashes +are accepted as well, for example `root\Microsoft\Windows\Storage`. + +#### class + +The WMI class to query, for example `Win32_LogicalDisk`. Required. Only letters, +digits and `_` are allowed. + +#### where + +An optional WQL condition appended as the `WHERE` clause, for example +`DriveType = 3` or `Name LIKE 'sql%'`. It is passed to WMI verbatim. + +#### label_properties + +The list of properties exported as labels on every metric of the query. Use them +to tell the returned instances apart, typically with the key property of the +class (`DeviceID`, `Name`, …). Optional. + +String, boolean and integer properties are supported. A property without a value +(`null`) becomes an empty label value. A label property of any other type, for +example a datetime, fails the whole query at scrape time, and none of its +metrics are exported. + +The label properties must uniquely identify every returned instance. Two +instances with identical label values produce duplicate series, which are +dropped and logged at scrape time. + +#### label_properties Sub-Schema + +##### name + +The name of the WMI property. Required. + +##### label + +The name of the label. Optional — defaults to the property name, lowercased, with +every character that is not a letter or digit replaced by `_` and leading and +trailing `_` trimmed. Each label must be unique within a query. + +#### properties + +The list of WMI properties to export as metrics. At least one property must be +listed. Property names are matched case-insensitively, and a property listed more +than once under one query is rejected as a duplicate. + +The following property types are supported: + +| WMI type | Exported value | +| --- | --- | +| `boolean` | `1` for true, `0` for false | +| `uint8`/`sint8` … `uint64`/`sint64` | the number | +| `real32`, `real64` | the number | +| `datetime` (timestamp) | seconds since the Unix epoch | +| `datetime` (interval) | seconds | + +A property without a value (`null`) is skipped for that instance rather than +exported as `0`. A property of any other type, for example a string or an array, +fails the query at scrape time (see `windows_wmi_query_success`). + +#### properties Sub-Schema + +##### name + +The name of the WMI property. Required. + +##### metric + +The name of the metric to expose. Optional — if omitted, a name is generated +automatically. See [Metric naming](#metric-naming) for the exact rules and +examples. + +Properties may deliberately share a `metric` name, also across queries, as long +as they agree on help text, type and label names, and their labels keep the +series apart. Build rejects identical series within a query, and across queries +without label properties. For queries with label properties, the series depend +on the instances returned at runtime; duplicates are dropped and logged at scrape +time. + +The names `windows_wmi_query_success` and `windows_wmi_query_duration_seconds` +are reserved for the collector's own metrics. + +##### help + +The metric `# HELP` text. Optional — if omitted, it defaults to +`windows_exporter: custom WMI metric`. + +##### type + +The metric type. The value can be `gauge` or `counter`. If not specified, it +defaults to `gauge`. + +This key is optional. + +##### labels + +Labels is a map of key-value pairs that will be added as constant labels to the +metric. A constant label may not use the name of a label property. + +This key is optional. + +## Metrics + +The wmi collector returns one metric per configured property and returned +instance, named and typed according to the configuration, plus a success and a +duration metric per query. + +| Name | Description | Type | Labels | +| --- | --- | --- | --- | +| *user defined* | Numeric value of a configured WMI property | gauge / counter | *label properties*, *user defined* | +| `windows_wmi_query_success` | Whether the query ran and all of its configured properties could be read (0, 1) | gauge | `name` | +| `windows_wmi_query_duration_seconds` | Duration of the query | gauge | `name` | + +A `windows_wmi_query_success` value of `0` means one of: + +- The query failed, for example because the namespace, class or a property does + not exist, the `where` clause is invalid, or a label property has an + unsupported type. Nothing is exported for the query. +- The scrape timeout ran out (see [Notes](#notes)). Instances read before that are + exported. +- At least one configured property could not be converted to a number. All other + properties are still exported. + +Failing queries are logged as warnings, but do not mark the whole collector as +failed: `windows_exporter_collector_success{collector="wmi"}` stays `1`, like for +the [registry](collector.registry.md) collector. Alert on +`windows_wmi_query_success` instead. + +Build only validates the configuration; it does not run the queries. A class that +is missing on a host is therefore reported via `windows_wmi_query_success` rather +than preventing the exporter from starting. + +### Metric naming + +Each property is exported under its own metric name. You can set it explicitly +with the `metric` field, or let the collector generate one. + +**Explicit name.** When `metric` is set, it is used verbatim. You are responsible +for following the Prometheus +[naming conventions](https://prometheus.io/docs/practices/naming/) (a `windows_` +prefix, and a unit suffix such as `_bytes`, `_seconds` or `_timestamp_seconds` +where applicable). + +**Auto-generated name.** When `metric` is omitted, the name is assembled as: + +``` +windows_wmi__ +``` + +where `` is the query [`name`](#name) and `` is the WMI +property name. The whole string is then lowercased, every character that is not a +letter or digit is replaced with `_`, and leading/trailing `_` are trimmed. + +Example: + +```yaml +- name: os + class: Win32_OperatingSystem + properties: + - name: NumberOfProcesses +``` + +→ `windows_wmi_os_numberofprocesses` + +### Example metric + +For the example configuration above: + +``` +# HELP windows_wmi_logical_disk_free_bytes Free space on the logical disk in bytes. +# TYPE windows_wmi_logical_disk_free_bytes gauge +windows_wmi_logical_disk_free_bytes{volume="C:"} 4.20703092736e+11 +windows_wmi_logical_disk_free_bytes{volume="D:"} 2.9705715712e+10 +# HELP windows_wmi_logical_disk_size_bytes Size of the logical disk in bytes. +# TYPE windows_wmi_logical_disk_size_bytes gauge +windows_wmi_logical_disk_size_bytes{volume="C:"} 9.99132491776e+11 +windows_wmi_logical_disk_size_bytes{volume="D:"} 4.60898430976e+11 +# HELP windows_wmi_os_last_boot_timestamp_seconds Time of the last boot as a Unix timestamp. +# TYPE windows_wmi_os_last_boot_timestamp_seconds gauge +windows_wmi_os_last_boot_timestamp_seconds 1.791272732355078e+09 +# HELP windows_wmi_os_numberofprocesses windows_exporter: custom WMI metric +# TYPE windows_wmi_os_numberofprocesses gauge +windows_wmi_os_numberofprocesses 478 +# HELP windows_wmi_query_duration_seconds Duration of the WMI query. +# TYPE windows_wmi_query_duration_seconds gauge +windows_wmi_query_duration_seconds{name="logical_disk"} 0.0375302 +windows_wmi_query_duration_seconds{name="os"} 0.1690887 +# 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="logical_disk"} 1 +windows_wmi_query_success{name="os"} 1 +``` + +### Notes + +- The queries run one after another and share the scrape timeout. A query that + is still running when it runs out is cancelled, and the remaining queries are + skipped; all of them report `windows_wmi_query_success` `0`. Keep queries + narrow with `where`, avoid slow providers, and check + `windows_wmi_query_duration_seconds`. +- `uint64` and `sint64` values larger than 2^53 lose precision when converted to + the 64-bit float used by Prometheus. +- WQL `ASSOCIATORS OF` and `REFERENCES OF` queries are not supported. + +## Useful queries + +Free space ratio per volume: + +``` +windows_wmi_logical_disk_free_bytes / windows_wmi_logical_disk_size_bytes +``` + +## Alerting examples + +**prometheus.rules** + +```yaml + - alert: WMIQueryFailure + expr: windows_wmi_query_success == 0 + for: 15m + labels: + severity: warning + annotations: + summary: "WMI query failed (instance {{ $labels.instance }})" + description: "The WMI query {{ $labels.name }} failed for 15 minutes." +``` diff --git a/internal/collector/wmi/types.go b/internal/collector/wmi/types.go new file mode 100644 index 00000000..f843ff1b --- /dev/null +++ b/internal/collector/wmi/types.go @@ -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 +} diff --git a/internal/collector/wmi/wmi.go b/internal/collector/wmi/wmi.go new file mode 100644 index 00000000..0f21e743 --- /dev/null +++ b/internal/collector/wmi/wmi.go @@ -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() +} diff --git a/internal/collector/wmi/wmi_output_test.go b/internal/collector/wmi/wmi_output_test.go new file mode 100644 index 00000000..a1f36157 --- /dev/null +++ b/internal/collector/wmi/wmi_output_test.go @@ -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() +} diff --git a/internal/collector/wmi/wmi_test.go b/internal/collector/wmi/wmi_test.go new file mode 100644 index 00000000..54b6591c --- /dev/null +++ b/internal/collector/wmi/wmi_test.go @@ -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)) + }) + } +} diff --git a/internal/mi/instance.go b/internal/mi/instance.go index a3e81a93..b49a6c2b 100644 --- a/internal/mi/instance.go +++ b/internal/mi/instance.go @@ -107,6 +107,7 @@ func (instance *Instance) GetElement(elementName string) (*Element, error) { var ( valueBuf [5]uint64 valueType ValueType + flags uint32 ) r0, _, _ := syscall.SyscallN( @@ -115,7 +116,7 @@ func (instance *Instance) GetElement(elementName string) (*Element, error) { uintptr(unsafe.Pointer(elementNameUTF16)), uintptr(unsafe.Pointer(&valueBuf)), uintptr(unsafe.Pointer(&valueType)), - 0, + uintptr(unsafe.Pointer(&flags)), 0, ) @@ -127,6 +128,8 @@ func (instance *Instance) GetElement(elementName string) (*Element, error) { value: uintptr(valueBuf[0]), arrayLen: uint32(valueBuf[1]), valueType: valueType, + flags: flags, + raw: valueBuf, }, nil } diff --git a/internal/mi/mi_test.go b/internal/mi/mi_test.go index 5ae9616f..34c59621 100644 --- a/internal/mi/mi_test.go +++ b/internal/mi/mi_test.go @@ -873,3 +873,119 @@ func Test_MI_QueryUnmarshal_Uint16Array_WrongType(t *testing.T) { err = application.Close() require.NoError(t, err) } + +func Test_MI_QueryFunc(t *testing.T) { + application, err := mi.ApplicationInitialize() + require.NoError(t, err) + + t.Cleanup(func() { require.NoError(t, application.Close()) }) + + session, err := application.NewSession(nil) + require.NoError(t, err) + + t.Cleanup(func() { require.NoError(t, session.Close()) }) + + query, err := mi.NewQuery("SELECT LocalDateTime, NumberOfProcesses, Caption FROM Win32_OperatingSystem") + require.NoError(t, err) + + var calls int + + err = session.QueryFunc(mi.NamespaceRootCIMv2, query, 5*time.Second, func(instance *mi.Instance) error { + calls++ + + // LocalDateTime is a DATETIME timestamp carrying the local UTC offset; + // decoding it must land close to the current wall clock. + element, err := instance.GetElement("LocalDateTime") + require.NoError(t, err) + require.False(t, element.IsNull()) + + localDateTime, err := element.Float64() + require.NoError(t, err) + require.InDelta(t, float64(time.Now().Unix()), localDateTime, 60) + + element, err = instance.GetElement("NumberOfProcesses") + require.NoError(t, err) + + processes, err := element.Float64() + require.NoError(t, err) + require.Positive(t, processes) + + element, err = instance.GetElement("Caption") + require.NoError(t, err) + + caption, err := element.String() + require.NoError(t, err) + require.Contains(t, caption, "Windows") + + return nil + }) + require.NoError(t, err) + require.Equal(t, 1, calls) + + // An error returned by the callback aborts the query and is passed through. + errStop := errors.New("stop") + + query, err = mi.NewQuery("SELECT Name FROM Win32_Process") + require.NoError(t, err) + + calls = 0 + err = session.QueryFunc(mi.NamespaceRootCIMv2, query, 5*time.Second, func(*mi.Instance) error { + calls++ + + return errStop + }) + require.ErrorIs(t, err, errStop) + require.Equal(t, 1, calls) + + // The cancelled query must leave the session usable. + calls = 0 + err = session.QueryFunc(mi.NamespaceRootCIMv2, query, 5*time.Second, func(*mi.Instance) error { + calls++ + + return nil + }) + require.NoError(t, err) + require.Greater(t, calls, 1) + + // Invalid classes surface as an error rather than an empty result. + query, err = mi.NewQuery("SELECT Name FROM Win32_DoesNotExist") + require.NoError(t, err) + + err = session.QueryFunc(mi.NamespaceRootCIMv2, query, 5*time.Second, func(*mi.Instance) error { + return nil + }) + require.Error(t, err) +} + +func Test_MI_Operation_CloseEarly(t *testing.T) { + application, err := mi.ApplicationInitialize() + require.NoError(t, err) + + t.Cleanup(func() { require.NoError(t, application.Close()) }) + + session, err := application.NewSession(nil) + require.NoError(t, err) + + t.Cleanup(func() { require.NoError(t, session.Close()) }) + + // Closing an operation with pending results cancels it instead of reading + // them, and must leave the session usable. + for range 3 { + operation, err := session.QueryInstances(mi.OperationFlagsStandardRTTI, nil, mi.NamespaceRootCIMv2, mi.QueryDialectWQL, "SELECT Name FROM Win32_Process") + require.NoError(t, err) + + instance, moreResults, err := operation.GetInstance() + require.NoError(t, err) + require.NotNil(t, instance) + require.True(t, moreResults) + + require.NoError(t, operation.Close()) + } + + var processes []win32Process + + query, err := mi.NewQuery("SELECT Name FROM Win32_Process WHERE Handle = 0") + require.NoError(t, err) + require.NoError(t, session.Query(&processes, mi.NamespaceRootCIMv2, query, 5*time.Second)) + require.Equal(t, []win32Process{{Name: "System Idle Process"}}, processes) +} diff --git a/internal/mi/operation.go b/internal/mi/operation.go index f3a4e8ee..5c67dd0d 100644 --- a/internal/mi/operation.go +++ b/internal/mi/operation.go @@ -51,6 +51,11 @@ type Operation struct { reserved1 uint64 reserved2 uintptr ft *OperationFT + + // completed is set once GetInstance has returned the final result. It is + // Go-only state after the MI_Operation fields, which MI never writes past; + // every Operation is allocated in Go. + completed bool } // OperationFT represents the function table for Operation. @@ -106,14 +111,17 @@ func (o *Operation) Close() error { return ErrNotInitialized } - moreResults := true + // MI_Operation_Close blocks until the final result has been delivered, so + // pending results must be read first. Reading them would cost the full + // remaining result set, so the operation is cancelled, which makes the + // final result arrive right away. A fully read operation needs neither. + if !o.completed { + _ = o.Cancel() - var err error - - for moreResults { - _, moreResults, err = o.GetInstance() - if err != nil { - break + for !o.completed { + if _, _, err := o.GetInstance(); err != nil { + break + } } } @@ -190,6 +198,9 @@ func (o *Operation) GetInstance() (*Instance, bool, error) { errorMessage = fmt.Sprintf(" (%s)", errorMessage) } + // A failed result is always the final one. + o.completed = true + return nil, false, fmt.Errorf("instance result: %w%s", instanceResult, errorMessage) } @@ -197,6 +208,10 @@ func (o *Operation) GetInstance() (*Instance, bool, error) { return nil, false, result } + if moreResults != True { + o.completed = true + } + return instance, moreResults == True, nil } diff --git a/internal/mi/session.go b/internal/mi/session.go index b335f92f..be6afa07 100644 --- a/internal/mi/session.go +++ b/internal/mi/session.go @@ -398,29 +398,115 @@ func (s *Session) QueryUnmarshal(dst any, // Query queries for a set of instances based on a query expression. func (s *Session) Query(dst any, namespaceName Namespace, queryExpression Query, queryTimeout time.Duration) error { - if queryTimeout < 0 { - return s.QueryUnmarshal(dst, OperationFlagsStandardRTTI, nil, namespaceName, QueryDialectWQL, queryExpression) - } - - app, err := s.GetApplication() + operationOptions, err := s.newOperationOptions(queryTimeout) if err != nil { - return fmt.Errorf("failed to get application: %w", err) + return err } - operationOptions, err := app.NewOperationOptions() - if err != nil { - return fmt.Errorf("failed to create operation options: %w", err) - } - - defer func() { - _ = operationOptions.Delete() - }() - - if queryTimeout > 0 { - if err = operationOptions.SetTimeout(queryTimeout); err != nil { - return fmt.Errorf("failed to set timeout: %w", err) - } + if operationOptions != nil { + defer func() { + _ = operationOptions.Delete() + }() } return s.QueryUnmarshal(dst, OperationFlagsStandardRTTI, operationOptions, namespaceName, QueryDialectWQL, queryExpression) } + +// QueryFunc runs a WQL query and calls fn for every returned instance, in +// order. It is meant for queries whose result shape is only known at runtime, +// where [Session.Query] cannot unmarshal into a struct. +// +// The instance, and every element read from it, is owned by the operation and +// only valid until fn returns. If fn returns an error, the query is cancelled +// and that error is returned. +func (s *Session) QueryFunc(namespaceName Namespace, queryExpression Query, queryTimeout time.Duration, fn func(*Instance) error) error { + if s == nil || s.ft == nil { + return ErrNotInitialized + } + + operationOptions, err := s.newOperationOptions(queryTimeout) + if err != nil { + return err + } + + if operationOptions != nil { + // Deferred first so the options outlive the operation that uses them. + defer func() { + _ = operationOptions.Delete() + }() + } else { + operationOptions = s.defaultOperationOptions + } + + operation := &Operation{} + + r0, _, _ := syscall.SyscallN( + s.ft.QueryInstances, + uintptr(unsafe.Pointer(s)), + uintptr(OperationFlagsStandardRTTI), + uintptr(unsafe.Pointer(operationOptions)), + uintptr(unsafe.Pointer(namespaceName)), + uintptr(unsafe.Pointer(QueryDialectWQL)), + uintptr(unsafe.Pointer(queryExpression)), + 0, + uintptr(unsafe.Pointer(operation)), + ) + + if result := ResultError(r0); !errors.Is(result, MI_RESULT_OK) { + return fmt.Errorf("failed to query instances: %w", result) + } + + // Close cancels the operation if fn aborted it early. + defer func() { + _ = operation.Close() + }() + + for { + instance, moreResults, err := operation.GetInstance() + if err != nil { + return fmt.Errorf("failed to get instance: %w", err) + } + + if instance == nil { + return nil + } + + if err := fn(instance); err != nil { + return err + } + + if !moreResults { + return nil + } + } +} + +// newOperationOptions creates operation options carrying queryTimeout. A zero +// timeout leaves the MI default in place; a negative timeout returns nil +// options, so the session defaults are used. The caller must delete non-nil +// options once the operation is closed. +func (s *Session) newOperationOptions(queryTimeout time.Duration) (*OperationOptions, error) { + if queryTimeout < 0 { + return nil, nil //nolint:nilnil + } + + app, err := s.GetApplication() + if err != nil { + return nil, fmt.Errorf("failed to get application: %w", err) + } + + operationOptions, err := app.NewOperationOptions() + if err != nil { + return nil, fmt.Errorf("failed to create operation options: %w", err) + } + + if queryTimeout > 0 { + if err = operationOptions.SetTimeout(queryTimeout); err != nil { + _ = operationOptions.Delete() + + return nil, fmt.Errorf("failed to set timeout: %w", err) + } + } + + return operationOptions, nil +} diff --git a/internal/mi/value.go b/internal/mi/value.go index b0a56a87..2fed2c11 100644 --- a/internal/mi/value.go +++ b/internal/mi/value.go @@ -21,6 +21,8 @@ import ( "errors" "fmt" "math" + "strconv" + "time" "unsafe" "golang.org/x/sys/windows" @@ -65,10 +67,166 @@ const ( ValueTypeARRAY ValueType = 16 ) +// flagNull is MI_FLAG_NULL. MI_Instance_GetElement sets it in the returned +// flags when the element has no value. +const flagNull uint32 = 0x20000000 + type Element struct { value uintptr arrayLen uint32 valueType ValueType + flags uint32 + + // raw holds the complete MI_Value union. Scalars and pointers live in the + // first word, but MI_Datetime is stored inline and spans 36 bytes. + raw [5]uint64 +} + +// IsNull reports whether the element has no value. +func (e *Element) IsNull() bool { + return e.flags&flagNull != 0 +} + +// Float64 converts a boolean, numeric or datetime element to a float64. +// Booleans become 0 or 1, timestamps become seconds since the Unix epoch and +// intervals become seconds. +func (e *Element) Float64() (float64, error) { + switch e.valueType { + case ValueTypeBOOLEAN: + if e.value == 1 { + return 1, nil + } + + return 0, nil + case ValueTypeUINT8: + return float64(uint8(e.value)), nil + case ValueTypeSINT8: + return float64(int8(e.value)), nil + case ValueTypeUINT16: + return float64(uint16(e.value)), nil + case ValueTypeSINT16: + return float64(int16(e.value)), nil + case ValueTypeUINT32: + return float64(uint32(e.value)), nil + case ValueTypeSINT32: + return float64(int32(e.value)), nil + case ValueTypeUINT64: + return float64(uint64(e.value)), nil + case ValueTypeSINT64: + return float64(int64(e.value)), nil + case ValueTypeREAL32: + return float64(math.Float32frombits(uint32(e.value))), nil + case ValueTypeREAL64: + return math.Float64frombits(uint64(e.value)), nil + case ValueTypeDATETIME: + dt := e.datetime() + if dt.IsTimestamp { + return float64(dt.Timestamp.Time().UnixMicro()) / 1e6, nil + } + + return dt.Interval.TotalSeconds(), nil + default: + return 0, fmt.Errorf("unsupported value type for numeric conversion: %d", e.valueType) + } +} + +// String converts a string, boolean or integer element to its string form. +func (e *Element) String() (string, error) { + switch e.valueType { + case ValueTypeSTRING: + if e.value == 0 { + return "", nil + } + + return windows.UTF16PtrToString((*uint16)(unsafe.Pointer(e.value))), nil + case ValueTypeBOOLEAN: + return strconv.FormatBool(e.value == 1), nil + case ValueTypeUINT8: + return strconv.FormatUint(uint64(uint8(e.value)), 10), nil + case ValueTypeUINT16: + return strconv.FormatUint(uint64(uint16(e.value)), 10), nil + case ValueTypeUINT32: + return strconv.FormatUint(uint64(uint32(e.value)), 10), nil + case ValueTypeUINT64: + return strconv.FormatUint(uint64(e.value), 10), nil + case ValueTypeSINT8: + return strconv.FormatInt(int64(int8(e.value)), 10), nil + case ValueTypeSINT16: + return strconv.FormatInt(int64(int16(e.value)), 10), nil + case ValueTypeSINT32: + return strconv.FormatInt(int64(int32(e.value)), 10), nil + case ValueTypeSINT64: + return strconv.FormatInt(int64(e.value), 10), nil + default: + return "", fmt.Errorf("unsupported value type for string conversion: %d", e.valueType) + } +} + +// datetime decodes the inline MI_Datetime: an MI_Uint32 isTimestamp followed +// by an MI_Timestamp or an MI_Interval of eight 32-bit words. +func (e *Element) datetime() Datetime { + words := (*[9]uint32)(unsafe.Pointer(&e.raw)) + + if words[0] != 0 { + return Datetime{ + IsTimestamp: true, + Timestamp: &Timestamp{ + Year: words[1], + Month: words[2], + Day: words[3], + Hour: words[4], + Minute: words[5], + Second: words[6], + Microseconds: words[7], + UTC: int32(words[8]), + }, + } + } + + return Datetime{ + Interval: &Interval{ + Days: words[1], + Hours: words[2], + Minutes: words[3], + Seconds: words[4], + Microseconds: words[5], + }, + } +} + +// Time converts the timestamp to a time.Time. UTC is the offset of the +// recorded local time from UTC, in minutes. +func (t *Timestamp) Time() time.Time { + return time.Date( + int(t.Year), time.Month(t.Month), int(t.Day), + int(t.Hour), int(t.Minute), int(t.Second), int(t.Microseconds)*int(time.Microsecond), + time.FixedZone("", int(t.UTC)*60), + ) +} + +// TotalSeconds returns the length of the interval in seconds. Unlike +// [Interval.Duration], it covers the full range of an MI_Interval, including +// the CIM "infinite" interval of 99999999 days. +func (i *Interval) TotalSeconds() float64 { + return float64(i.Days)*86400 + + float64(i.Hours)*3600 + + float64(i.Minutes)*60 + + float64(i.Seconds) + + float64(i.Microseconds)/1e6 +} + +// Duration converts the interval to a time.Duration. Intervals longer than +// about 292 years exceed time.Duration and are capped at its maximum. +func (i *Interval) Duration() time.Duration { + if i.TotalSeconds() >= float64(math.MaxInt64)/float64(time.Second) { + return time.Duration(math.MaxInt64) + } + + return time.Duration(i.Days)*24*time.Hour + + time.Duration(i.Hours)*time.Hour + + time.Duration(i.Minutes)*time.Minute + + time.Duration(i.Seconds)*time.Second + + time.Duration(i.Microseconds)*time.Microsecond } func (e *Element) GetValue() (any, error) { @@ -98,11 +256,7 @@ func (e *Element) GetValue() (any, error) { case ValueTypeCHAR16: return uint16(e.value), nil case ValueTypeDATETIME: - if e.value == 0 { - return nil, errors.New("invalid pointer: value is nil") - } - - return *(*Datetime)(unsafe.Pointer(e.value)), nil + return e.datetime(), nil case ValueTypeSTRING: if e.value == 0 { return nil, errors.New("invalid pointer: value is nil") diff --git a/internal/mi/value_internal_test.go b/internal/mi/value_internal_test.go new file mode 100644 index 00000000..98bea619 --- /dev/null +++ b/internal/mi/value_internal_test.go @@ -0,0 +1,176 @@ +// 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 mi + +import ( + "math" + "testing" + "time" + "unsafe" + + "github.com/stretchr/testify/require" +) + +// newElement builds an Element the way Instance.GetElement does, from a raw +// MI_Value buffer. +func newElement(valueType ValueType, raw [5]uint64, flags uint32) *Element { + return &Element{ + value: uintptr(raw[0]), + arrayLen: uint32(raw[1]), + valueType: valueType, + flags: flags, + raw: raw, + } +} + +func TestElementFloat64(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + valueType ValueType + raw uint64 + want float64 + }{ + {"bool true", ValueTypeBOOLEAN, 1, 1}, + {"bool false", ValueTypeBOOLEAN, 0, 0}, + {"uint8 ignores high bits", ValueTypeUINT8, 0xFF01, 1}, + {"sint8 negative", ValueTypeSINT8, 0xFF, -1}, + {"sint16 negative", ValueTypeSINT16, 0xFFFE, -2}, + {"uint32", ValueTypeUINT32, math.MaxUint32, math.MaxUint32}, + {"sint32 negative", ValueTypeSINT32, 0xFFFFFFFD, -3}, + {"uint64", ValueTypeUINT64, 5_000_000_000, 5_000_000_000}, + {"sint64 negative", ValueTypeSINT64, math.MaxUint64, -1}, + {"real32", ValueTypeREAL32, uint64(math.Float32bits(7.5)), 7.5}, + {"real64", ValueTypeREAL64, math.Float64bits(-0.125), -0.125}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := newElement(tc.valueType, [5]uint64{tc.raw}, 0).Float64() + require.NoError(t, err) + require.InDelta(t, tc.want, got, 0) + }) + } + + _, err := newElement(ValueTypeSTRING, [5]uint64{}, 0).Float64() + require.Error(t, err) +} + +func TestElementFloat64Datetime(t *testing.T) { + t.Parallel() + + t.Run("timestamp", func(t *testing.T) { + t.Parallel() + + var raw [5]uint64 + + // 2026-10-07 14:30:15.25 at UTC+120 minutes is 12:30:15.25 UTC. + words := (*[10]uint32)(unsafe.Pointer(&raw)) + *words = [10]uint32{1, 2026, 10, 7, 14, 30, 15, 250000, 120} + + got, err := newElement(ValueTypeDATETIME, raw, 0).Float64() + require.NoError(t, err) + + want := time.Date(2026, 10, 7, 12, 30, 15, 250_000_000, time.UTC) + require.InDelta(t, float64(want.UnixMilli())/1e3, got, 1e-6) + }) + + t.Run("interval", func(t *testing.T) { + t.Parallel() + + var raw [5]uint64 + + words := (*[10]uint32)(unsafe.Pointer(&raw)) + *words = [10]uint32{0, 1, 2, 3, 4, 500000} + + got, err := newElement(ValueTypeDATETIME, raw, 0).Float64() + require.NoError(t, err) + require.InDelta(t, (26*time.Hour + 3*time.Minute + 4500*time.Millisecond).Seconds(), got, 1e-9) + }) + + t.Run("timestamp west of UTC", func(t *testing.T) { + t.Parallel() + + var raw [5]uint64 + + // The CIM_DATETIME 20231015123000.000000-420 is 12:30 at UTC-7, + // which is 19:30 UTC. GitHub runners use UTC, so this sign is only + // covered here. + words := (*[10]uint32)(unsafe.Pointer(&raw)) + utcOffset := int32(-420) + *words = [10]uint32{1, 2023, 10, 15, 12, 30, 0, 0, uint32(utcOffset)} + + got, err := newElement(ValueTypeDATETIME, raw, 0).Float64() + require.NoError(t, err) + require.InDelta(t, float64(time.Date(2023, 10, 15, 19, 30, 0, 0, time.UTC).Unix()), got, 1e-6) + }) + + t.Run("infinite interval", func(t *testing.T) { + t.Parallel() + + var raw [5]uint64 + + // The CIM "infinite" interval 99999999235959.000000:000 overflows + // time.Duration, which must not wrap around. + words := (*[10]uint32)(unsafe.Pointer(&raw)) + *words = [10]uint32{0, 99999999, 23, 59, 59, 0} + + element := newElement(ValueTypeDATETIME, raw, 0) + + got, err := element.Float64() + require.NoError(t, err) + require.InDelta(t, 99999999*86400.0+86399, got, 1) + + require.Equal(t, time.Duration(math.MaxInt64), element.datetime().Interval.Duration()) + }) +} + +func TestElementString(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + valueType ValueType + raw uint64 + want string + }{ + {"bool", ValueTypeBOOLEAN, 1, "true"}, + {"uint16", ValueTypeUINT16, 0x1_0005, "5"}, + {"uint64", ValueTypeUINT64, math.MaxUint64, "18446744073709551615"}, + {"sint32 negative", ValueTypeSINT32, 0xFFFFFFFF, "-1"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := newElement(tc.valueType, [5]uint64{tc.raw}, 0).String() + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } + + _, err := newElement(ValueTypeREAL64, [5]uint64{}, 0).String() + require.Error(t, err) +} + +func TestElementIsNull(t *testing.T) { + t.Parallel() + + require.True(t, newElement(ValueTypeUINT32, [5]uint64{}, flagNull).IsNull()) + require.False(t, newElement(ValueTypeUINT32, [5]uint64{}, 0).IsNull()) +} diff --git a/pkg/collector/collection.go b/pkg/collector/collection.go index 6bb998ce..d2f2d553 100644 --- a/pkg/collector/collection.go +++ b/pkg/collector/collection.go @@ -77,6 +77,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/udp" "github.com/prometheus-community/windows_exporter/internal/collector/update" "github.com/prometheus-community/windows_exporter/internal/collector/vmware" + "github.com/prometheus-community/windows_exporter/internal/collector/wmi" "github.com/prometheus-community/windows_exporter/internal/mi" "github.com/prometheus-community/windows_exporter/internal/pdh" "github.com/prometheus-community/windows_exporter/internal/types" @@ -149,6 +150,7 @@ func NewWithConfig(config Config) *Collection { collectors[udp.Name] = udp.New(&config.UDP) collectors[update.Name] = update.New(&config.Update) collectors[vmware.Name] = vmware.New(&config.Vmware) + collectors[wmi.Name] = wmi.New(&config.WMI) return New(collectors) } diff --git a/pkg/collector/config.go b/pkg/collector/config.go index 67cdad36..1aa60300 100644 --- a/pkg/collector/config.go +++ b/pkg/collector/config.go @@ -67,6 +67,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/udp" "github.com/prometheus-community/windows_exporter/internal/collector/update" "github.com/prometheus-community/windows_exporter/internal/collector/vmware" + "github.com/prometheus-community/windows_exporter/internal/collector/wmi" ) type Config struct { @@ -119,6 +120,7 @@ type Config struct { UDP udp.Config `yaml:"udp"` Update update.Config `yaml:"update"` Vmware vmware.Config `yaml:"vmware"` + WMI wmi.Config `yaml:"wmi"` } // ConfigDefaults Is an interface to be used by the external libraries. It holds all ConfigDefaults form all collectors @@ -175,4 +177,5 @@ var ConfigDefaults = Config{ UDP: udp.ConfigDefaults, Update: update.ConfigDefaults, Vmware: vmware.ConfigDefaults, + WMI: wmi.ConfigDefaults, } diff --git a/pkg/collector/map.go b/pkg/collector/map.go index b590efde..5f4e90aa 100644 --- a/pkg/collector/map.go +++ b/pkg/collector/map.go @@ -71,6 +71,7 @@ import ( "github.com/prometheus-community/windows_exporter/internal/collector/udp" "github.com/prometheus-community/windows_exporter/internal/collector/update" "github.com/prometheus-community/windows_exporter/internal/collector/vmware" + "github.com/prometheus-community/windows_exporter/internal/collector/wmi" ) func NewBuilderWithFlags[C Collector](fn BuilderWithFlags[C]) BuilderWithFlags[Collector] { @@ -130,6 +131,7 @@ var BuildersWithFlags = map[string]BuilderWithFlags[Collector]{ udp.Name: NewBuilderWithFlags(udp.NewWithFlags), update.Name: NewBuilderWithFlags(update.NewWithFlags), vmware.Name: NewBuilderWithFlags(vmware.NewWithFlags), + wmi.Name: NewBuilderWithFlags(wmi.NewWithFlags), } // Available returns a sorted list of available collectors.