diff --git a/AGENTS.md b/AGENTS.md index 9a3a8eb7..16e762d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,12 @@ in [`Makefile`](Makefile). Do not change versions to work around a local tooling problem. Follow [`.golangci.yaml`](.golangci.yaml), [`.editorconfig`](.editorconfig) and [`.gitattributes`](.gitattributes); keep LF line endings and the existing license headers. Use structured `slog` attributes and the existing Windows API -wrappers (`golang.org/x/sys/windows`, `NewLazySystemDLL`). +wrappers (`golang.org/x/sys/windows`, `NewLazySystemDLL`). `LazyProc.Call` +panics for a missing export: add every proc a binding calls without its own +`Find` check to the package's `lazyproc.Once` list, and call the package's +`Load` in the `Build` of each collector that uses it. Mirrors of native structs +that MI or COM read by offset need `unsafe.Sizeof`/`Offsetof` checks, see +[`internal/mi/layout.go`](internal/mi/layout.go). Before committing, run the repository checks from its root: diff --git a/docs/collector.hyperv.md b/docs/collector.hyperv.md index 222690aa..f4956eed 100644 --- a/docs/collector.hyperv.md +++ b/docs/collector.hyperv.md @@ -121,7 +121,9 @@ The suffix only keeps the series unique and does not identify the VM across scra Metrics about the host's CPU allocation. Processor counts per virtual machine are read from the Host Compute Service API; the logical processor count comes from the -`Hyper-V Hypervisor Logical Processor` performance counter object. +`Hyper-V Hypervisor Logical Processor` performance counter object. The Host Compute +Service API requires Windows Server 2019 or later; on older versions the sub-collector +is skipped with a warning. | Metric Name | Description | Type | Labels | |-------------------------------------------------|------------------------------------------------------------------------|-------|--------------------| diff --git a/internal/collector/container/container.go b/internal/collector/container/container.go index 5bf1d1b0..81d4549d 100644 --- a/internal/collector/container/container.go +++ b/internal/collector/container/container.go @@ -247,7 +247,16 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { // Without the Containers feature, the Host Compute Service is missing and // every scrape would fail. Report the collector as unsupported instead, so // it is skipped once at startup rather than logging a warning per scrape. + if err := kernel32.Load(); err != nil { + return err + } + if slices.Contains(c.config.CollectorsEnabled, subCollectorHCS) { + // hcn.Load also checks the hcs functions. + if err := hcn.Load(); err != nil { + return fmt.Errorf("host compute APIs not available, is the Containers feature installed? %w", err) + } + if _, err := hcs.GetContainers(); errors.Is(err, hcs.ErrServiceNotAvailable) { return fmt.Errorf("host compute service not available, is the Containers feature installed? %w: %w", errors.ErrUnsupported, err) diff --git a/internal/collector/dhcp/dhcp.go b/internal/collector/dhcp/dhcp.go index 98af4942..1f4770fe 100644 --- a/internal/collector/dhcp/dhcp.go +++ b/internal/collector/dhcp/dhcp.go @@ -46,6 +46,11 @@ type Config struct { CollectorsEnabled []string `yaml:"enabled"` } +// loadDHCPServerAPI checks the DHCP server API. Tests replace it. +// +//nolint:gochecknoglobals +var loadDHCPServerAPI = dhcpsapi.Load + //nolint:gochecknoglobals var ConfigDefaults = Config{ CollectorsEnabled: []string{ @@ -164,6 +169,24 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { } } + // Without the DHCP server API only the scope metrics are unavailable; the + // server metrics come from performance counters. + if slices.Contains(c.config.CollectorsEnabled, subCollectorScopeMetrics) { + if err := loadDHCPServerAPI(); err != nil { + if !slices.Contains(c.config.CollectorsEnabled, subCollectorServerMetrics) { + return fmt.Errorf("DHCP server API not available: %w", err) + } + + c.logger.Warn("DHCP server API not available, skipping the scope_metrics sub-collector", + slog.Any("err", err), + ) + + c.config.CollectorsEnabled = slices.DeleteFunc(slices.Clone(c.config.CollectorsEnabled), func(name string) bool { + return name == subCollectorScopeMetrics + }) + } + } + var err error if slices.Contains(c.config.CollectorsEnabled, subCollectorScopeMetrics) { diff --git a/internal/collector/dhcp/dhcpsapi_internal_test.go b/internal/collector/dhcp/dhcpsapi_internal_test.go new file mode 100644 index 00000000..9366355b --- /dev/null +++ b/internal/collector/dhcp/dhcpsapi_internal_test.go @@ -0,0 +1,61 @@ +// 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 dhcp + +import ( + "errors" + "log/slog" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestBuildWithoutDHCPServerAPI checks that a missing DHCP server API only +// disables the scope metrics, unless they are the only sub-collector. +// +//nolint:paralleltest // replaces loadDHCPServerAPI. +func TestBuildWithoutDHCPServerAPI(t *testing.T) { + original := loadDHCPServerAPI + + t.Cleanup(func() { loadDHCPServerAPI = original }) + + loadDHCPServerAPI = func() error { return errors.ErrUnsupported } + + t.Run("server and scope metrics", func(t *testing.T) { + enabled := []string{subCollectorServerMetrics, subCollectorScopeMetrics} + + c := New(&Config{CollectorsEnabled: enabled}) + + // The DHCP Server counters may be missing on this host; only the + // scope metrics decision is checked. + _ = c.Build(slog.New(slog.DiscardHandler), nil) + + t.Cleanup(func() { _ = c.Close() }) + + require.Equal(t, []string{subCollectorServerMetrics}, c.config.CollectorsEnabled) + require.Equal(t, []string{subCollectorServerMetrics, subCollectorScopeMetrics}, enabled, "the caller's slice must not change") + require.Nil(t, c.scopeInfo) + }) + + t.Run("scope metrics only", func(t *testing.T) { + c := New(&Config{CollectorsEnabled: []string{subCollectorScopeMetrics}}) + + err := c.Build(slog.New(slog.DiscardHandler), nil) + require.ErrorIs(t, err, errors.ErrUnsupported) + }) +} diff --git a/internal/collector/dmi/dmi.go b/internal/collector/dmi/dmi.go index 933d6110..ba664f6c 100644 --- a/internal/collector/dmi/dmi.go +++ b/internal/collector/dmi/dmi.go @@ -69,6 +69,10 @@ func (c *Collector) Close() error { } func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { + if err := sysinfoapi.Load(); err != nil { + return err + } + info, err := sysinfoapi.GetSMBIOSInfo() if err != nil { return fmt.Errorf("SMBIOS info unavailable: %w", err) diff --git a/internal/collector/gpu/gpu.go b/internal/collector/gpu/gpu.go index 30c8cad0..603de896 100644 --- a/internal/collector/gpu/gpu.go +++ b/internal/collector/gpu/gpu.go @@ -159,6 +159,10 @@ func (c *Collector) Close() error { } func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { + if err := errors.Join(gdi32.Load(), cfgmgr32.Load()); err != nil { + return fmt.Errorf("GPU APIs not available: %w", err) + } + var err error c.logger = logger.With(slog.String("collector", Name)) diff --git a/internal/collector/hyperv/hyperv.go b/internal/collector/hyperv/hyperv.go index 9e1e6435..b68420da 100644 --- a/internal/collector/hyperv/hyperv.go +++ b/internal/collector/hyperv/hyperv.go @@ -233,9 +233,11 @@ func (c *Collector) Build(logger *slog.Logger, miSession *mi.Session) error { close: func() { c.perfDataCollectorDynamicMemoryVM.Close() }, }, subCollectorHost: { - build: c.buildHost, - collect: c.collectHost, - close: func() { c.perfDataCollectorLogicalProcessor.Close() }, + // The Host Compute Service API (computecore.dll) exists since 1809. + minBuildNumber: osversion.LTSC2019, + build: c.buildHost, + collect: c.collectHost, + close: func() { c.perfDataCollectorLogicalProcessor.Close() }, }, subCollectorHypervisorLogicalProcessor: { build: c.buildHypervisorLogicalProcessor, diff --git a/internal/collector/hyperv/hyperv_host.go b/internal/collector/hyperv/hyperv_host.go index 594998ec..613283d2 100644 --- a/internal/collector/hyperv/hyperv_host.go +++ b/internal/collector/hyperv/hyperv_host.go @@ -70,6 +70,10 @@ type vmVirtualNode struct { } func (c *Collector) buildHost() error { + if err := hcs.Load(); err != nil { + return err + } + var err error c.perfDataCollectorLogicalProcessor, err = pdh.NewCollector[perfDataCounterValuesHost](c.logger, pdh.CounterTypeRaw, "Hyper-V Hypervisor Logical Processor", pdh.InstancesAll) diff --git a/internal/collector/hyperv/hyperv_wmi_health.go b/internal/collector/hyperv/hyperv_wmi_health.go index ff477507..64cf479b 100644 --- a/internal/collector/hyperv/hyperv_wmi_health.go +++ b/internal/collector/hyperv/hyperv_wmi_health.go @@ -52,6 +52,10 @@ func (c *Collector) buildWMIHealth() error { return mi.ErrNotInitialized } + if err := sysinfoapi.Load(); err != nil { + return err + } + // The host's Msvm_ComputerSystem is named after the physical NetBIOS name. // Caption is localized, so it can't be used to select the host. hostname, err := sysinfoapi.GetComputerName(sysinfoapi.ComputerNamePhysicalNetBIOS) diff --git a/internal/collector/license/license.go b/internal/collector/license/license.go index 4649360b..da4d8d7e 100644 --- a/internal/collector/license/license.go +++ b/internal/collector/license/license.go @@ -76,6 +76,10 @@ func (c *Collector) Close() error { } func (c *Collector) Build(_ *slog.Logger, _ *mi.Session) error { + if err := slc.Load(); err != nil { + return err + } + c.licenseStatus = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "status"), "Status of windows license", diff --git a/internal/collector/memory/memory.go b/internal/collector/memory/memory.go index 25aaed0d..dae3376e 100644 --- a/internal/collector/memory/memory.go +++ b/internal/collector/memory/memory.go @@ -115,6 +115,10 @@ func (c *Collector) Close() error { } func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { + if err := sysinfoapi.Load(); err != nil { + return err + } + c.availableBytes = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "available_bytes"), "The amount of physical memory immediately available for allocation to a process or for system use. It is equal to the sum of memory assigned to"+ diff --git a/internal/collector/os/os.go b/internal/collector/os/os.go index f855f4a8..df423175 100644 --- a/internal/collector/os/os.go +++ b/internal/collector/os/os.go @@ -77,6 +77,10 @@ func (c *Collector) Close() error { } func (c *Collector) Build(_ *slog.Logger, _ *mi.Session) error { + if err := sysinfoapi.Load(); err != nil { + return err + } + productName, revision, installationType, err := c.getWindowsVersion() if err != nil { return fmt.Errorf("failed to get Windows version: %w", err) diff --git a/internal/collector/pagefile/pagefile.go b/internal/collector/pagefile/pagefile.go index a9c5ea22..4723f14a 100644 --- a/internal/collector/pagefile/pagefile.go +++ b/internal/collector/pagefile/pagefile.go @@ -79,6 +79,10 @@ func (c *Collector) Close() error { func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { c.logger = logger.With(slog.String("collector", Name)) + if err := psapi.Load(); err != nil { + return err + } + c.pagingLimitBytes = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "limit_bytes"), "Current size of the paging file in bytes", diff --git a/internal/collector/printer/printer.go b/internal/collector/printer/printer.go index bb12b4a4..50954ff0 100644 --- a/internal/collector/printer/printer.go +++ b/internal/collector/printer/printer.go @@ -134,6 +134,10 @@ func (c *Collector) Close() error { } func (c *Collector) Build(_ *slog.Logger, _ *mi.Session) error { + if err := winspool.Load(); err != nil { + return err + } + c.printerJobStatus = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "job_status"), "A counter of printer jobs by status", diff --git a/internal/collector/system/system.go b/internal/collector/system/system.go index 9ea1ba66..697472e6 100644 --- a/internal/collector/system/system.go +++ b/internal/collector/system/system.go @@ -82,6 +82,10 @@ func (c *Collector) Close() error { } func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { + if err := ntdll.Load(); err != nil { + return err + } + c.bootTime = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "boot_time_timestamp"), "Unix timestamp of last system boot. Follows clock corrections.", diff --git a/internal/collector/tcp/tcp.go b/internal/collector/tcp/tcp.go index e00ae357..a6d52834 100644 --- a/internal/collector/tcp/tcp.go +++ b/internal/collector/tcp/tcp.go @@ -143,6 +143,12 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { } } + if slices.Contains(c.config.CollectorsEnabled, subCollectorConnectionsState) { + if err := iphlpapi.Load(); err != nil { + return err + } + } + labels := []string{"af"} c.connectionFailures = prometheus.NewDesc( diff --git a/internal/collector/terminal_services/terminal_services.go b/internal/collector/terminal_services/terminal_services.go index 0e16851a..f9edbef4 100644 --- a/internal/collector/terminal_services/terminal_services.go +++ b/internal/collector/terminal_services/terminal_services.go @@ -289,6 +289,10 @@ func (c *Collector) Build(logger *slog.Logger, miSession *mi.Session) error { c.logger.Debug("host is not a connection broker skipping Connection Broker performance metrics.") } + if err = wtsapi32.Load(); err != nil { + return err + } + c.hServer, err = wtsapi32.WTSOpenServer("") if err != nil { return fmt.Errorf("failed to open WTS server: %w", err) diff --git a/internal/collector/time/time.go b/internal/collector/time/time.go index 7f62dd0f..45e32a29 100644 --- a/internal/collector/time/time.go +++ b/internal/collector/time/time.go @@ -139,6 +139,10 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { } } + if err := kernel32.Load(); err != nil { + return err + } + // https://github.com/prometheus-community/windows_exporter/issues/1891 c.ppbCounterPresent = osversion.Build() >= osversion.LTSC2019 diff --git a/internal/headers/cfgmgr32/syscall.go b/internal/headers/cfgmgr32/syscall.go index 0ecfd1bc..f5046b62 100644 --- a/internal/headers/cfgmgr32/syscall.go +++ b/internal/headers/cfgmgr32/syscall.go @@ -19,6 +19,7 @@ import ( "fmt" "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "github.com/prometheus-community/windows_exporter/internal/headers/win32" "golang.org/x/sys/windows" ) @@ -31,8 +32,14 @@ var ( procCMGetDeviceIDListSize = cfgmgr32.NewProc("CM_Get_Device_ID_List_SizeW") procCMGetDevNodePropertyW = cfgmgr32.NewProc("CM_Get_DevNode_PropertyW") procCMLocateDevNodeW = cfgmgr32.NewProc("CM_Locate_DevNodeW") + + load = lazyproc.Once(cfgmgr32, procCMGetDeviceIDListW, procCMGetDeviceIDListSize, procCMGetDevNodePropertyW, procCMLocateDevNodeW) ) +// Load checks that cfgmgr32.dll exports every function of this package. Call +// it in Build: a missing export panics on the first call otherwise. +func Load() error { return load() } + func CMGetDeviceIDListSize(filter *win32.LPWSTR, size *uint32) error { ret, _, _ := procCMGetDeviceIDListSize.Call( uintptr(unsafe.Pointer(size)), diff --git a/internal/headers/dhcpsapi/dhcpsapi.go b/internal/headers/dhcpsapi/dhcpsapi.go index ff35b447..9f76463e 100644 --- a/internal/headers/dhcpsapi/dhcpsapi.go +++ b/internal/headers/dhcpsapi/dhcpsapi.go @@ -23,6 +23,7 @@ import ( "net" "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -35,8 +36,21 @@ var ( procDhcpV4EnumSubnetReservations = modDhcpServer.NewProc("DhcpV4EnumSubnetReservations") procDhcpV4FailoverGetScopeStatistics = modDhcpServer.NewProc("DhcpV4FailoverGetScopeStatistics") procDhcpGetMibInfoV5 = modDhcpServer.NewProc("DhcpGetMibInfoV5") + + load = lazyproc.Once(modDhcpServer, + procDhcpGetSubnetInfo, + procDhcpGetSuperScopeInfoV4, + procDhcpRpcFreeMemory, + procDhcpV4EnumSubnetReservations, + procDhcpV4FailoverGetScopeStatistics, + procDhcpGetMibInfoV5, + ) ) +// Load checks that dhcpsapi.dll exports every function of this package. Call +// it in Build: a missing export panics on the first call otherwise. +func Load() error { return load() } + func GetDHCPV4ScopeStatistics() ([]DHCPV4Scope, error) { var mibInfo *DHCP_MIB_INFO_V5 diff --git a/internal/headers/gdi32/syscall.go b/internal/headers/gdi32/syscall.go index 5becb299..a233c93b 100644 --- a/internal/headers/gdi32/syscall.go +++ b/internal/headers/gdi32/syscall.go @@ -19,6 +19,7 @@ import ( "fmt" "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "github.com/prometheus-community/windows_exporter/internal/headers/ntdll" "golang.org/x/sys/windows" ) @@ -30,8 +31,26 @@ var ( procD3DKMTQueryAdapterInfo = modGdi32.NewProc("D3DKMTQueryAdapterInfo") procD3DKMTCloseAdapter = modGdi32.NewProc("D3DKMTCloseAdapter") procD3DKMTEnumAdapters2 = modGdi32.NewProc("D3DKMTEnumAdapters2") + + load = lazyproc.Once(modGdi32, + procD3DKMTOpenAdapterFromLuid, + procD3DKMTQueryAdapterInfo, + procD3DKMTCloseAdapter, + procD3DKMTEnumAdapters2, + ) ) +// Load checks that gdi32.dll, and the ntdll functions this package uses, +// export every function of this package. Call it in Build: a missing export +// panics on the first call otherwise. +func Load() error { + if err := load(); err != nil { + return err + } + + return ntdll.Load() +} + func D3DKMTOpenAdapterFromLuid(ptr *D3DKMT_OPENADAPTERFROMLUID) error { ret, _, _ := procD3DKMTOpenAdapterFromLuid.Call( uintptr(unsafe.Pointer(ptr)), diff --git a/internal/headers/hcn/hcn.go b/internal/headers/hcn/hcn.go index 38cdcdc1..a3e05a05 100644 --- a/internal/headers/hcn/hcn.go +++ b/internal/headers/hcn/hcn.go @@ -23,14 +23,27 @@ import ( "unsafe" "github.com/prometheus-community/windows_exporter/internal/headers/hcs" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "github.com/prometheus-community/windows_exporter/internal/utils" "golang.org/x/sys/windows" ) +// Load checks that vmcompute.dll, and the hcs functions this package uses, +// export every function of this package. Call it in Build: a missing export +// panics on the first call otherwise. +func Load() error { + if err := load(); err != nil { + return err + } + + return hcs.Load() +} + //nolint:gochecknoglobals var ( modvmcompute = windows.NewLazySystemDLL("vmcompute.dll") procHNSCall = modvmcompute.NewProc("HNSCall") + load = lazyproc.Once(modvmcompute, procHNSCall) hcnBodyEmpty = utils.Must(windows.UTF16PtrFromString("")) hcnMethodGet = utils.Must(windows.UTF16PtrFromString("GET")) diff --git a/internal/headers/hcs/syscall.go b/internal/headers/hcs/syscall.go index 12b95cf0..87a4b286 100644 --- a/internal/headers/hcs/syscall.go +++ b/internal/headers/hcs/syscall.go @@ -22,6 +22,7 @@ import ( "fmt" "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -36,8 +37,22 @@ var ( procHcsOpenComputeSystem = modComputeCore.NewProc("HcsOpenComputeSystem") procHcsGetComputeSystemProperties = modComputeCore.NewProc("HcsGetComputeSystemProperties") procHcsCloseComputeSystem = modComputeCore.NewProc("HcsCloseComputeSystem") + + load = lazyproc.Once(modComputeCore, + procHcsCreateOperation, + procHcsWaitForOperationResult, + procHcsCloseOperation, + procHcsEnumerateComputeSystems, + procHcsOpenComputeSystem, + procHcsGetComputeSystemProperties, + procHcsCloseComputeSystem, + ) ) +// Load checks that computecore.dll exports every function of this package. +// Call it in Build: a missing export panics on the first call otherwise. +func Load() error { return load() } + // CreateOperation creates a new operation. // // HcsCreateOperation returns the operation handle, or NULL on failure. diff --git a/internal/headers/iphlpapi/iphlpapi.go b/internal/headers/iphlpapi/iphlpapi.go index d6a05483..d8f641ec 100644 --- a/internal/headers/iphlpapi/iphlpapi.go +++ b/internal/headers/iphlpapi/iphlpapi.go @@ -22,6 +22,7 @@ import ( "fmt" "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -29,8 +30,14 @@ import ( var ( modiphlpapi = windows.NewLazySystemDLL("iphlpapi.dll") procGetExtendedTcpTable = modiphlpapi.NewProc("GetExtendedTcpTable") + + load = lazyproc.Once(modiphlpapi, procGetExtendedTcpTable) ) +// Load checks that iphlpapi.dll exports every function of this package. Call +// it in Build: a missing export panics on the first call otherwise. +func Load() error { return load() } + func GetTCPConnectionStates(family uint32) (map[MIB_TCP_STATE]uint32, error) { stateCounts := make(map[MIB_TCP_STATE]uint32) diff --git a/internal/headers/kernel32/kernel32.go b/internal/headers/kernel32/kernel32.go index b0284f92..4f77b18f 100644 --- a/internal/headers/kernel32/kernel32.go +++ b/internal/headers/kernel32/kernel32.go @@ -20,6 +20,7 @@ package kernel32 import ( "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -31,8 +32,21 @@ var ( procKernelLocalFileTimeToFileTime = modkernel32.NewProc("LocalFileTimeToFileTime") procOpenJobObject = modkernel32.NewProc("OpenJobObjectW") procIsProcessInJob = modkernel32.NewProc("IsProcessInJob") + + // GetDiskSpaceInformationW is optional and checked by GetDiskSpaceInformation. + load = lazyproc.Once(modkernel32, + procGetDynamicTimeZoneInformationSys, + procKernelLocalFileTimeToFileTime, + procOpenJobObject, + procIsProcessInJob, + ) ) +// Load checks that kernel32.dll exports every function this package calls +// without a check of its own. Call it in Build: a missing export panics on +// the first call otherwise. +func Load() error { return load() } + // SYSTEMTIME contains a date and time. // 📑 https://docs.microsoft.com/en-us/windows/win32/api/minwinbase/ns-minwinbase-systemtime type SYSTEMTIME struct { diff --git a/internal/headers/lazyproc/bindings_test.go b/internal/headers/lazyproc/bindings_test.go new file mode 100644 index 00000000..628c5df7 --- /dev/null +++ b/internal/headers/lazyproc/bindings_test.go @@ -0,0 +1,85 @@ +// 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 lazyproc_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/prometheus-community/windows_exporter/internal/headers/cfgmgr32" + "github.com/prometheus-community/windows_exporter/internal/headers/dhcpsapi" + "github.com/prometheus-community/windows_exporter/internal/headers/gdi32" + "github.com/prometheus-community/windows_exporter/internal/headers/hcn" + "github.com/prometheus-community/windows_exporter/internal/headers/hcs" + "github.com/prometheus-community/windows_exporter/internal/headers/iphlpapi" + "github.com/prometheus-community/windows_exporter/internal/headers/kernel32" + "github.com/prometheus-community/windows_exporter/internal/headers/ntdll" + "github.com/prometheus-community/windows_exporter/internal/headers/psapi" + "github.com/prometheus-community/windows_exporter/internal/headers/slc" + "github.com/prometheus-community/windows_exporter/internal/headers/sysinfoapi" + "github.com/prometheus-community/windows_exporter/internal/headers/winspool" + "github.com/prometheus-community/windows_exporter/internal/headers/wtsapi32" + "github.com/prometheus-community/windows_exporter/internal/pdh" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// TestBindingsLoad checks the export names of every binding package against +// the DLLs of this host. A DLL that belongs to an optional Windows feature +// skips its binding when the DLL is not installed. +func TestBindingsLoad(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + load func() error + // optionalDLL is set for DLLs of optional Windows features. + optionalDLL string + }{ + {name: "cfgmgr32", load: cfgmgr32.Load}, + {name: "dhcpsapi", load: dhcpsapi.Load, optionalDLL: "dhcpsapi.dll"}, + {name: "gdi32", load: gdi32.Load}, + {name: "hcn", load: hcn.Load, optionalDLL: "vmcompute.dll"}, + {name: "hcs", load: hcs.Load, optionalDLL: "computecore.dll"}, + {name: "iphlpapi", load: iphlpapi.Load}, + {name: "kernel32", load: kernel32.Load}, + {name: "ntdll", load: ntdll.Load}, + {name: "psapi", load: psapi.Load}, + {name: "slc", load: slc.Load}, + {name: "sysinfoapi", load: sysinfoapi.Load}, + {name: "winspool", load: winspool.Load}, + {name: "wtsapi32", load: wtsapi32.Load}, + {name: "pdh", load: pdh.Load}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if tc.optionalDLL != "" { + systemDirectory, err := windows.GetSystemDirectory() + require.NoError(t, err) + + if _, err := os.Stat(filepath.Join(systemDirectory, tc.optionalDLL)); err != nil { + t.Skipf("%s is not installed: %v", tc.optionalDLL, err) + } + } + + require.NoError(t, tc.load()) + }) + } +} diff --git a/internal/headers/lazyproc/lazyproc.go b/internal/headers/lazyproc/lazyproc.go new file mode 100644 index 00000000..9f58d34b --- /dev/null +++ b/internal/headers/lazyproc/lazyproc.go @@ -0,0 +1,95 @@ +// 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 lazyproc checks that lazily loaded native functions exist. +// +// windows.LazyProc.Call panics when its DLL or export is missing, e.g. on a +// Windows version or edition without the subsystem. A binding package lists +// the functions it calls without a check of its own in a Load function built +// with [Once], and collectors call it in Build, so a missing export fails +// Build instead of panicking on the first scrape. +package lazyproc + +import ( + "errors" + "fmt" + "strings" + "sync" + + "golang.org/x/sys/windows" +) + +// Error reports a missing DLL or missing exports. It matches +// [errors.ErrUnsupported] and has no other cause, so a collector that fails +// Build with it is skipped as an unavailable subsystem: the loader errors, +// e.g. ERROR_MOD_NOT_FOUND, are only kept as text and in Missing. +type Error struct { + // DLL is the name of the DLL. + DLL string + // Missing holds the error of the DLL, if it could not be loaded, or of + // each missing export. + Missing []error +} + +func (e *Error) Error() string { + var message strings.Builder + + fmt.Fprintf(&message, "%s: %s", errors.ErrUnsupported, e.DLL) + + for _, err := range e.Missing { + message.WriteString(": ") + message.WriteString(err.Error()) + } + + return message.String() +} + +// Is reports whether target is [errors.ErrUnsupported]. +func (e *Error) Is(target error) bool { + return target == errors.ErrUnsupported +} + +// Find loads dll and every proc, which must belong to dll. It returns nil if +// all of them exist, and otherwise an [*Error] that names the missing DLL or +// each missing export. +func Find(dll *windows.LazyDLL, procs ...*windows.LazyProc) error { + if err := dll.Load(); err != nil { + return &Error{DLL: dll.Name, Missing: []error{err}} + } + + var missing []error + + for _, proc := range procs { + if err := proc.Find(); err != nil { + missing = append(missing, err) + } + } + + if len(missing) == 0 { + return nil + } + + return &Error{DLL: dll.Name, Missing: missing} +} + +// Once returns a function that runs [Find] once and then returns its result. +// A DLL or export that is missing stays missing for the life of the process. +func Once(dll *windows.LazyDLL, procs ...*windows.LazyProc) func() error { + return sync.OnceValue(func() error { + return Find(dll, procs...) + }) +} diff --git a/internal/headers/lazyproc/lazyproc_test.go b/internal/headers/lazyproc/lazyproc_test.go new file mode 100644 index 00000000..1fddac2d --- /dev/null +++ b/internal/headers/lazyproc/lazyproc_test.go @@ -0,0 +1,78 @@ +// 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 lazyproc_test + +import ( + "errors" + "strings" + "testing" + + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func TestFind(t *testing.T) { + t.Parallel() + + kernel32 := windows.NewLazySystemDLL("kernel32.dll") + missingDLL := windows.NewLazySystemDLL("windows_exporter_missing.dll") + + t.Run("all present", func(t *testing.T) { + t.Parallel() + + require.NoError(t, lazyproc.Find(kernel32, kernel32.NewProc("GetTickCount64"), kernel32.NewProc("GetSystemInfo"))) + require.NoError(t, lazyproc.Find(kernel32)) + }) + + t.Run("missing export", func(t *testing.T) { + t.Parallel() + + err := lazyproc.Find(kernel32, kernel32.NewProc("GetTickCount64"), kernel32.NewProc("WindowsExporterMissingExport")) + require.ErrorIs(t, err, errors.ErrUnsupported) + require.ErrorContains(t, err, "WindowsExporterMissingExport") + }) + + t.Run("missing DLL is reported once", func(t *testing.T) { + t.Parallel() + + err := lazyproc.Find(missingDLL, missingDLL.NewProc("A"), missingDLL.NewProc("B")) + require.ErrorIs(t, err, errors.ErrUnsupported) + require.Equal(t, 1, strings.Count(err.Error(), "windows_exporter_missing.dll"), err.Error()) + + // The loader error stays text only, so the error has a single cause. + require.NotErrorIs(t, err, windows.ERROR_MOD_NOT_FOUND) + + var lazyErr *lazyproc.Error + + require.ErrorAs(t, err, &lazyErr) + require.Equal(t, "windows_exporter_missing.dll", lazyErr.DLL) + require.Len(t, lazyErr.Missing, 1) + }) + + t.Run("Once", func(t *testing.T) { + t.Parallel() + + load := lazyproc.Once(kernel32, kernel32.NewProc("WindowsExporterMissingExport")) + + first := load() + require.ErrorIs(t, first, errors.ErrUnsupported) + // The cached error is returned again. + require.Same(t, first, load()) + }) +} diff --git a/internal/headers/ntdll/ntdll.go b/internal/headers/ntdll/ntdll.go index 2a7c72d0..e62fa7de 100644 --- a/internal/headers/ntdll/ntdll.go +++ b/internal/headers/ntdll/ntdll.go @@ -18,6 +18,7 @@ package ntdll import ( + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -25,8 +26,14 @@ import ( var ( modNtdll = windows.NewLazySystemDLL("ntdll.dll") procRtlNtStatusToDosError = modNtdll.NewProc("RtlNtStatusToDosError") + + load = lazyproc.Once(modNtdll, procRtlNtStatusToDosError) ) +// Load checks that ntdll.dll exports every function of this package. Call it +// in Build: a missing export panics on the first call otherwise. +func Load() error { return load() } + func RtlNtStatusToDosError(status uintptr) error { ret, _, _ := procRtlNtStatusToDosError.Call(status) if ret == 0 { diff --git a/internal/headers/psapi/psapi.go b/internal/headers/psapi/psapi.go index bf2a4a4e..e7faeaa8 100644 --- a/internal/headers/psapi/psapi.go +++ b/internal/headers/psapi/psapi.go @@ -20,6 +20,7 @@ package psapi import ( "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -46,8 +47,14 @@ type PerformanceInformation struct { var ( psapi = windows.NewLazySystemDLL("psapi.dll") procGetPerformanceInfo = psapi.NewProc("GetPerformanceInfo") + + load = lazyproc.Once(psapi, procGetPerformanceInfo) ) +// Load checks that psapi.dll exports every function of this package. Call it +// in Build: a missing export panics on the first call otherwise. +func Load() error { return load() } + // GetPerformanceInfo returns the dereferenced version of GetLPPerformanceInfo. func GetPerformanceInfo() (PerformanceInformation, error) { var lppi PerformanceInformation diff --git a/internal/headers/slc/slc.go b/internal/headers/slc/slc.go index afdd70c7..37178fc6 100644 --- a/internal/headers/slc/slc.go +++ b/internal/headers/slc/slc.go @@ -21,6 +21,7 @@ import ( "errors" "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -28,8 +29,14 @@ import ( var ( slc = windows.NewLazySystemDLL("slc.dll") procSLIsWindowsGenuineLocal = slc.NewProc("SLIsWindowsGenuineLocal") + + load = lazyproc.Once(slc, procSLIsWindowsGenuineLocal) ) +// Load checks that slc.dll exports every function of this package. Call it in +// Build: a missing export panics on the first call otherwise. +func Load() error { return load() } + // SL_GENUINE_STATE enumeration // // https://learn.microsoft.com/en-us/windows/win32/api/slpublic/ne-slpublic-sl_genuine_state diff --git a/internal/headers/sysinfoapi/sysinfoapi.go b/internal/headers/sysinfoapi/sysinfoapi.go index 6f529c4c..6d08b6d2 100644 --- a/internal/headers/sysinfoapi/sysinfoapi.go +++ b/internal/headers/sysinfoapi/sysinfoapi.go @@ -21,6 +21,7 @@ import ( "unicode/utf16" "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -121,8 +122,19 @@ var ( procGetSystemInfo = kernel32.NewProc("GetSystemInfo") procGlobalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") procGetComputerNameExW = kernel32.NewProc("GetComputerNameExW") + + load = lazyproc.Once(kernel32, + procGetSystemInfo, + procGlobalMemoryStatusEx, + procGetComputerNameExW, + procGetSystemFirmwareTable, + ) ) +// Load checks that kernel32.dll exports every function of this package. Call +// it in Build: a missing export panics on the first call otherwise. +func Load() error { return load() } + // GlobalMemoryStatusEx retrieves information about the system's current usage of both physical and virtual memory. // https://docs.microsoft.com/en-us/windows/win32/api/sysinfoapi/nf-sysinfoapi-globalmemorystatusex func GlobalMemoryStatusEx() (MemoryStatus, error) { diff --git a/internal/headers/winspool/winspool.go b/internal/headers/winspool/winspool.go index 8fedba56..80e2298c 100644 --- a/internal/headers/winspool/winspool.go +++ b/internal/headers/winspool/winspool.go @@ -27,6 +27,7 @@ import ( "fmt" "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -38,8 +39,14 @@ var ( procOpenPrinterW = modWinspool.NewProc("OpenPrinterW") procClosePrinter = modWinspool.NewProc("ClosePrinter") procEnumJobsW = modWinspool.NewProc("EnumJobsW") + + load = lazyproc.Once(modWinspool, procEnumPrintersW, procOpenPrinterW, procClosePrinter, procEnumJobsW) ) +// Load checks that winspool.drv exports every function of this package. Call +// it in Build: a missing export panics on the first call otherwise. +func Load() error { return load() } + // EnumPrinters returns the printers selected by flags, a combination of PRINTER_ENUM_* values. func EnumPrinters(flags uint32) ([]Printer, error) { buf, count, err := enum(func(buf *byte, size uint32, needed, returned *uint32) (uintptr, error) { diff --git a/internal/headers/wtsapi32/wtsapi32.go b/internal/headers/wtsapi32/wtsapi32.go index 24d322f2..856fcef2 100644 --- a/internal/headers/wtsapi32/wtsapi32.go +++ b/internal/headers/wtsapi32/wtsapi32.go @@ -22,9 +22,14 @@ import ( "log/slog" "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) +// Load checks that wtsapi32.dll exports every function of this package. Call +// it in Build: a missing export panics on the first call otherwise. +func Load() error { return load() } + type WTSTypeClass int // The valid values for the WTSTypeClass enumeration. @@ -107,6 +112,8 @@ var ( procWTSFreeMemoryEx = wtsapi32.NewProc("WTSFreeMemoryExW") procWTSCloseServer = wtsapi32.NewProc("WTSCloseServer") + load = lazyproc.Once(wtsapi32, procWTSOpenServerEx, procWTSEnumerateSessionsEx, procWTSFreeMemoryEx, procWTSCloseServer) + WTSSessionStates = map[WTSConnectState]string{ wtsActive: "active", wtsConnected: "connected", diff --git a/internal/mi/layout.go b/internal/mi/layout.go new file mode 100644 index 00000000..40e6f35e --- /dev/null +++ b/internal/mi/layout.go @@ -0,0 +1,80 @@ +// 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 "unsafe" + +// Compile-time checks of the Go mirrors of native MI structures against +// mi.h. MI reads and writes these structures by offset, so a missing or +// reordered field corrupts memory instead of failing. +// +// Each check indexes a one-element array with the difference of two +// constants: it compiles only when both are equal. A smaller left side +// overflows uintptr, a larger one is out of bounds. + +const ptrSize = unsafe.Sizeof(uintptr(0)) + +// handleSize is the size of the MI handle structures MI_Application, +// MI_Session, MI_Operation, MI_OperationOptions and MI_DestinationOptions: +// MI_Uint64 reserved1, ptrdiff_t reserved2 and a function table pointer. +const handleSize = 8 + 2*ptrSize + +// miValueSize is the size of the MI_Value union. Its largest member is +// MI_Datetime: an MI_Boolean, padded to 4 bytes, and a 32-byte MI_Timestamp or +// MI_Interval union, 36 bytes, rounded up to the 8-byte alignment of the +// 64-bit members. Instance.GetElementByName passes a buffer of this size. +const miValueSize = 40 + +var ( + // Handles without Go-only fields. + _ = [1]struct{}{}[unsafe.Sizeof(Application{})-handleSize] + _ = [1]struct{}{}[unsafe.Sizeof(OperationOptions{})-handleSize] + _ = [1]struct{}{}[unsafe.Sizeof(DestinationOptions{})-handleSize] + + // Session and Operation append Go-only fields after the native layout, + // which MI never writes past. The first Go-only field must start right + // after the native fields, with the function table at the native offset. + _ = [1]struct{}{}[unsafe.Offsetof(Session{}.ft)-(8+ptrSize)] + _ = [1]struct{}{}[unsafe.Offsetof(Session{}.application)-handleSize] + _ = [1]struct{}{}[unsafe.Offsetof(Operation{}.ft)-(8+ptrSize)] + _ = [1]struct{}{}[unsafe.Offsetof(Operation{}.completed)-handleSize] + + // MI_Instance: four pointers and ptrdiff_t reserved[4]. + _ = [1]struct{}{}[unsafe.Sizeof(Instance{})-8*ptrSize] + + // MI_OperationCallbacks: the callback context and eight callbacks. + _ = [1]struct{}{}[unsafe.Sizeof(operationCallbacks{})-9*ptrSize] + _ = [1]struct{}{}[unsafe.Offsetof(operationCallbacks{}.instanceResult)-5*ptrSize] + + // MI_Timestamp and MI_Interval: eight 32-bit fields each. + _ = [1]struct{}{}[unsafe.Sizeof(Timestamp{})-32] + _ = [1]struct{}{}[unsafe.Sizeof(Interval{})-32] + + // The MI_Value buffer of Instance.GetElementByName, kept in Element.raw. + _ = [1]struct{}{}[unsafe.Sizeof(Element{}.raw)-miValueSize] + _ = [1]struct{}{}[unsafe.Sizeof(ValueType(0))-4] + + // Function tables, by the number of entries in mi.h. Calls use the slot + // offsets, so a missing entry would call the wrong function. + _ = [1]struct{}{}[unsafe.Sizeof(ApplicationFT{})-11*ptrSize] + _ = [1]struct{}{}[unsafe.Sizeof(SessionFT{})-15*ptrSize] + _ = [1]struct{}{}[unsafe.Sizeof(OperationFT{})-6*ptrSize] + _ = [1]struct{}{}[unsafe.Sizeof(OperationOptionsFT{})-13*ptrSize] + _ = [1]struct{}{}[unsafe.Sizeof(DestinationOptionsFT{})-15*ptrSize] + _ = [1]struct{}{}[unsafe.Sizeof(InstanceFT{})-18*ptrSize] +) diff --git a/internal/ole/com_windows.go b/internal/ole/com_windows.go index d19a11d1..98fb8fa4 100644 --- a/internal/ole/com_windows.go +++ b/internal/ole/com_windows.go @@ -27,6 +27,7 @@ import ( "unicode/utf8" "unsafe" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -40,12 +41,21 @@ var ( sysAllocStringLen = oleaut32.NewProc("SysAllocStringLen") sysFreeString = oleaut32.NewProc("SysFreeString") sysStringLen = oleaut32.NewProc("SysStringLen") + + loadOle32 = lazyproc.Once(ole32, coInitializeEx, coUninitialize, coCreateInstance) + loadOleaut32 = lazyproc.Once(oleaut32, sysAllocStringLen, sysFreeString, sysStringLen) ) // Initialize initializes the MTA and disables legacy OLE 1.0 DDE. The caller // must lock its OS thread before calling and release all objects before // Uninitialize on that same thread. S_FALSE is successful initialization. +// A missing ole32 or oleaut32 export fails Initialize instead of panicking +// in a later call. func Initialize() error { + if err := errors.Join(loadOle32(), loadOleaut32()); err != nil { + return fmt.Errorf("initialize COM: %w", err) + } + hr, _, _ := coInitializeEx.Call(0, windows.COINIT_MULTITHREADED|windows.COINIT_DISABLE_OLE1DDE) if err := ResultError(hr); err != nil { return fmt.Errorf("initialize COM: %w", err) @@ -88,15 +98,37 @@ type nativeScalar interface { // nativeObject requires the common COM interface layout without extra Go data. type nativeObject interface{ ~struct{ Object } } -// Method returns the address of the zero-based native vtable slot. -func (o *Object) Method(slot uintptr) uintptr { - return *(*uintptr)(unsafe.Add(unsafe.Pointer(o.VTable), slot*unsafe.Sizeof(uintptr(0)))) +// ErrNilObject reports a call on a nil interface pointer, an interface without +// a vtable, or an empty vtable slot. Dereferencing them would crash the process +// instead of failing the call. +var ErrNilObject = errors.New("COM interface or vtable slot is nil") + +// Method returns the address of the zero-based native vtable slot. It returns +// ErrNilObject instead of dereferencing a nil interface, a nil vtable or +// calling an empty slot. +func (o *Object) Method(slot uintptr) (uintptr, error) { + if o == nil || o.VTable == nil { + return 0, ErrNilObject + } + + fn := *(*uintptr)(unsafe.Add(unsafe.Pointer(o.VTable), slot*unsafe.Sizeof(uintptr(0)))) + if fn == 0 { + return 0, fmt.Errorf("COM vtable slot %d: %w", slot, ErrNilObject) + } + + return fn, nil } // Release relinquishes an owned interface reference. Do not call it on borrowed -// iterator items. Like IUnknown::Release, it does not return an HRESULT. +// iterator items. Like IUnknown::Release, it does not return an HRESULT, and +// releasing a nil interface does nothing. func (o *Object) Release() { - _ = Call(o.Method(2), uintptr(unsafe.Pointer(o))) + fn, err := o.Method(2) + if err != nil { + return + } + + _ = Call(fn, uintptr(unsafe.Pointer(o))) runtime.KeepAlive(o) } @@ -108,7 +140,12 @@ func (o *Object) Release() { func (o *Object) Get[T nativeScalar](slot uintptr) (T, error) { var value T - hr := Call(o.Method(slot), uintptr(unsafe.Pointer(o)), uintptr(unsafe.Pointer(&value))) + fn, err := o.Method(slot) + if err != nil { + return value, err + } + + hr := Call(fn, uintptr(unsafe.Pointer(o)), uintptr(unsafe.Pointer(&value))) runtime.KeepAlive(o) return value, ResultError(hr) @@ -122,8 +159,13 @@ func (o *Object) Get[T nativeScalar](slot uintptr) (T, error) { func (o *Object) GetArg[T nativeScalar](slot, arg uintptr) (T, error) { var value T + fn, err := o.Method(slot) + if err != nil { + return value, err + } + hr := Call( - o.Method(slot), + fn, uintptr(unsafe.Pointer(o)), arg, uintptr(unsafe.Pointer(&value)), @@ -138,7 +180,12 @@ func (o *Object) GetArg[T nativeScalar](slot, arg uintptr) (T, error) { func (o *Object) GetObject[T nativeObject](slot uintptr) (*T, error) { var value *T - hr := Call(o.Method(slot), uintptr(unsafe.Pointer(o)), uintptr(unsafe.Pointer(&value))) + fn, err := o.Method(slot) + if err != nil { + return nil, err + } + + hr := Call(fn, uintptr(unsafe.Pointer(o)), uintptr(unsafe.Pointer(&value))) runtime.KeepAlive(o) if err := ResultError(hr); err != nil { @@ -160,8 +207,13 @@ func (o *Object) GetObject[T nativeObject](slot uintptr) (*T, error) { func (o *Object) GetObjectArg[T nativeObject](slot, arg uintptr) (*T, error) { var value *T + fn, err := o.Method(slot) + if err != nil { + return nil, err + } + hr := Call( - o.Method(slot), + fn, uintptr(unsafe.Pointer(o)), arg, uintptr(unsafe.Pointer(&value)), @@ -181,7 +233,12 @@ func (o *Object) GetObjectArg[T nativeObject](slot, arg uintptr) (*T, error) { // Put calls a native setter with a scalar argument. func (o *Object) Put(slot, arg uintptr) error { - hr := Call(o.Method(slot), uintptr(unsafe.Pointer(o)), arg) + fn, err := o.Method(slot) + if err != nil { + return err + } + + hr := Call(fn, uintptr(unsafe.Pointer(o)), arg) runtime.KeepAlive(o) return ResultError(hr) diff --git a/internal/ole/com_windows_test.go b/internal/ole/com_windows_test.go index 05fe5dd1..ed933e86 100644 --- a/internal/ole/com_windows_test.go +++ b/internal/ole/com_windows_test.go @@ -264,3 +264,39 @@ func TestVariantLayout(t *testing.T) { require.Equal(t, uintptr(24), unsafe.Sizeof(Variant{})) require.Equal(t, uintptr(8), unsafe.Offsetof(Variant{}.Value)) } + +// TestNilObject checks that calls on a nil interface, a nil vtable or an empty +// vtable slot fail instead of crashing the process. +func TestNilObject(t *testing.T) { + var methods [8]uintptr + + for name, object := range map[string]*Object{ + "nil interface": nil, + "nil vtable": {}, + "empty slot": {VTable: &methods[0]}, + } { + t.Run(name, func(t *testing.T) { + _, err := object.Method(7) + require.ErrorIs(t, err, ErrNilObject) + + _, err = object.Get[int32](7) + require.ErrorIs(t, err, ErrNilObject) + + _, err = object.GetArg[int32](7, 0) + require.ErrorIs(t, err, ErrNilObject) + + _, err = object.GetObject[nativeTestObject](7) + require.ErrorIs(t, err, ErrNilObject) + + _, err = object.GetObjectArg[nativeTestObject](7, 0) + require.ErrorIs(t, err, ErrNilObject) + + require.ErrorIs(t, object.Put(7, 0), ErrNilObject) + + _, err = object.String(7) + require.ErrorIs(t, err, ErrNilObject) + + require.NotPanics(t, object.Release) + }) + } +} diff --git a/internal/ole/taskschd/taskschd_windows.go b/internal/ole/taskschd/taskschd_windows.go index 735324f0..cdd6c7a9 100644 --- a/internal/ole/taskschd/taskschd_windows.go +++ b/internal/ole/taskschd/taskschd_windows.go @@ -67,8 +67,13 @@ func (s *TaskService) Connect() error { domain := ole.NewEmptyVariant() password := ole.NewEmptyVariant() + fn, err := s.Method(taskServiceConnect) + if err != nil { + return err + } + hr := ole.Call( - s.Method(taskServiceConnect), + fn, uintptr(unsafe.Pointer(s)), uintptr(unsafe.Pointer(server)), uintptr(unsafe.Pointer(user)), diff --git a/internal/pdh/collector.go b/internal/pdh/collector.go index 30ed5600..06836cf4 100644 --- a/internal/pdh/collector.go +++ b/internal/pdh/collector.go @@ -321,6 +321,10 @@ func newCollector[T any](logger *slog.Logger, query *Query, resultType CounterTy return nil, fmt.Errorf("invalid result type: %v", resultType) } + if err := Load(); err != nil { + return nil, err + } + var handle pdhQueryHandle if query != nil { diff --git a/internal/pdh/pdh.go b/internal/pdh/pdh.go index 752008fa..895a031d 100644 --- a/internal/pdh/pdh.go +++ b/internal/pdh/pdh.go @@ -55,6 +55,7 @@ import ( "unsafe" "github.com/prometheus-community/windows_exporter/internal/headers/kernel32" + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" "golang.org/x/sys/windows" ) @@ -292,8 +293,39 @@ var ( pdhGetRawCounterArrayW = libPdhDll.NewProc("PdhGetRawCounterArrayW") pdhPdhGetCounterTimeBase = libPdhDll.NewProc("PdhGetCounterTimeBase") pdhRemoveCounter = libPdhDll.NewProc("PdhRemoveCounter") + + // The localized-name procs are optional and checked where they are used. + loadPdh = lazyproc.Once(libPdhDll, + pdhAddCounterW, + pdhAddEnglishCounterW, + pdhCloseQuery, + pdhCollectQueryData, + pdhCollectQueryDataWithTime, + pdhGetFormattedCounterValue, + pdhGetFormattedCounterArrayW, + pdhOpenQuery, + pdhValidatePathW, + pdhExpandWildCardPathW, + pdhGetCounterInfoW, + pdhGetRawCounterValue, + pdhGetRawCounterArrayW, + pdhPdhGetCounterTimeBase, + pdhRemoveCounter, + ) ) +// Load checks that pdh.dll, and the kernel32 functions this package uses, +// export every function this package calls without a check of its own. +// [NewQuery] and the collector constructors call it, so a missing export +// fails Build instead of panicking on the first scrape. +func Load() error { + if err := loadPdh(); err != nil { + return err + } + + return kernel32.Load() +} + // AddCounter adds the specified counter to the query. This is the internationalized version. Preferably, use the // function AddEnglishCounter instead. hQuery is the query handle, which has been fetched by OpenQuery. // szFullCounterPath is a full, internationalized counter path (this will differ per Windows language version). diff --git a/internal/pdh/query.go b/internal/pdh/query.go index 5209386e..ff522ceb 100644 --- a/internal/pdh/query.go +++ b/internal/pdh/query.go @@ -39,6 +39,10 @@ type Query struct { // NewQuery opens a query for [NewQueryCollector]. The caller must Close it // after the collectors in it. func NewQuery() (*Query, error) { + if err := Load(); err != nil { + return nil, err + } + var handle pdhQueryHandle if ret := OpenQuery(0, 0, &handle); ret != ErrorSuccess { diff --git a/pkg/collector/build_error_internal_test.go b/pkg/collector/build_error_internal_test.go new file mode 100644 index 00000000..f0ec3e9c --- /dev/null +++ b/pkg/collector/build_error_internal_test.go @@ -0,0 +1,82 @@ +// 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 collector + +import ( + "errors" + "fmt" + "testing" + + "github.com/prometheus-community/windows_exporter/internal/headers/lazyproc" + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +// TestExpectedBuildError_MissingNativeAPI checks that a collector whose +// binding Load fails because of a missing DLL or export is skipped, also +// inside the joined errors of collectors with sub-collectors. +func TestExpectedBuildError_MissingNativeAPI(t *testing.T) { + t.Parallel() + + kernel32 := windows.NewLazySystemDLL("kernel32.dll") + missingDLL := windows.NewLazySystemDLL("windows_exporter_missing.dll") + + missingExport := lazyproc.Find(kernel32, kernel32.NewProc("GetTickCount64"), kernel32.NewProc("WindowsExporterMissingExport")) + require.Error(t, missingExport) + + missingLibrary := lazyproc.Find(missingDLL, missingDLL.NewProc("A"), missingDLL.NewProc("B")) + require.Error(t, missingLibrary) + + for name, tc := range map[string]struct { + err error + expected bool + }{ + "missing export": { + err: fmt.Errorf("error build collector printer: %w", missingExport), + expected: true, + }, + "missing DLL": { + err: fmt.Errorf("error build collector dhcp: DHCP server API not available: %w", missingLibrary), + expected: true, + }, + "joined bindings": { + err: fmt.Errorf("error build collector gpu: GPU APIs not available: %w", errors.Join(missingExport, missingLibrary)), + expected: true, + }, + "hyperv sub-collectors": { + err: fmt.Errorf("error build collector hyperv: %w", errors.Join( + fmt.Errorf("failed to build host collector: %w", missingLibrary), + fmt.Errorf("failed to build wmi_health collector: %w", missingExport), + )), + expected: true, + }, + "hyperv sub-collectors with an unexpected failure": { + err: fmt.Errorf("error build collector hyperv: %w", errors.Join( + fmt.Errorf("failed to build host collector: %w", missingLibrary), + fmt.Errorf("failed to build datastore collector: %w", windows.ERROR_ACCESS_DENIED), + )), + expected: false, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.expected, expectedBuildError(tc.err), tc.err) + }) + } +}