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:
Jan-Otto Kröpke
2026-10-07 23:22:11 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a67a64c55c
commit 88034b818e
16 changed files with 2309 additions and 32 deletions
+4 -1
View File
@@ -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
}
+116
View File
@@ -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)
}
+22 -7
View File
@@ -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
}
+105 -19
View File
@@ -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
}
+159 -5
View File
@@ -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")
+176
View File
@@ -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())
}