From ae58ced3b4b21a05ee7e62bee6e0cbef9bfa10f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan-Otto=20Kr=C3=B6pke?= Date: Sun, 11 Oct 2026 21:10:02 +0200 Subject: [PATCH] logical_disk: cache volume info, read free space live, tolerate failing volumes (#2691) Co-authored-by: Claude Signed-off-by: Claude --- docs/collector.logical_disk.md | 23 +- .../collector/logical_disk/logical_disk.go | 236 ++++------ .../collector/logical_disk/volume_info.go | 411 ++++++++++++++++++ .../logical_disk/volume_info_test.go | 251 +++++++++++ internal/headers/kernel32/disk_space.go | 81 ++++ 5 files changed, 837 insertions(+), 165 deletions(-) create mode 100644 internal/collector/logical_disk/volume_info.go create mode 100644 internal/collector/logical_disk/volume_info_test.go create mode 100644 internal/headers/kernel32/disk_space.go diff --git a/docs/collector.logical_disk.md b/docs/collector.logical_disk.md index 0a19efd5..82266c47 100644 --- a/docs/collector.logical_disk.md +++ b/docs/collector.logical_disk.md @@ -37,8 +37,8 @@ Comma-separated list of collectors to use. Available collectors: metrics, bitloc | `windows_logical_disk_writes_total` | Rate of write operations on the disk | counter | `volume` | | `windows_logical_disk_read_seconds_total` | Seconds the disk was busy servicing read requests | counter | `volume` | | `windows_logical_disk_write_seconds_total` | Seconds the disk was busy servicing write requests | counter | `volume` | -| `windows_logical_disk_free_bytes` | Unused space of the disk in bytes (not real time, updates every 10-15 min) | gauge | `volume` | -| `windows_logical_disk_size_bytes` | Total size of the disk in bytes (not real time, updates every 10-15 min) | gauge | `volume` | +| `windows_logical_disk_free_bytes` | Unused space of the disk in bytes | gauge | `volume` | +| `windows_logical_disk_size_bytes` | Total size of the disk in bytes | gauge | `volume` | | `windows_logical_disk_idle_seconds_total` | Seconds the disk was idle (not servicing read/write requests) | counter | `volume` | | `windows_logical_disk_split_ios_total` | Number of I/Os to the disk split into multiple I/Os | counter | `volume` | | `windows_logical_disk_read_latency_seconds_total` | Shows the average time, in seconds, of a read operation from the disk | counter | `volume` | @@ -61,9 +61,18 @@ rate(windows_logical_disk_read_seconds_total[5m]) rate(windows_logical_disk_write_seconds_total[5m]) ``` -### Warning about size metrics -The `free_bytes` and `size_bytes` metrics are not updated in real time and might have a delay of 10-15min. -This is the same behavior as the Windows performance counters. +### Size metrics +The `free_bytes` and `size_bytes` metrics are read from the volume on every scrape. Both are the free and total bytes +of the whole volume, regardless of NTFS disk quotas of the account running windows_exporter. + +If the volume can't be queried, they fall back to the `Free Megabytes` and `% Free Space` performance counters, +which Windows updates only every 10-15 minutes and in whole megabytes. On Windows Server 2016, which lacks +`GetDiskSpaceInformation`, `size_bytes` also falls back to the performance counter if a disk quota applies to the account. + +### Volume information +The `disk` and `type` labels of `windows_logical_disk_info` are cached for up to 10 minutes. +The other labels and `windows_logical_disk_readonly` are read on every scrape. `windows_logical_disk_readonly` is not +reported for removable and CD-ROM drives without media. ### BitLocker status The `bitlocker_status` sub-collector reads the BitLocker status through `fveapi.dll`, the BitLocker API of Windows. @@ -94,8 +103,8 @@ rate(windows_logical_disk_read_bytes_total{instance="localhost", volume=~"C:"}[2 Logical Volume information ``` -windows_logical_disk_info{disk="0",filesystem="",serial_number="",type="",volume="HarddiskVolume2",volume_name=""} 1 -windows_logical_disk_info{disk="0",filesystem="",serial_number="",type="",volume="HarddiskVolume3",volume_name=""} 1 +windows_logical_disk_info{disk="0",filesystem="FAT32",serial_number="5C1A2B3D",type="fixed",volume="HarddiskVolume2",volume_name=""} 1 +windows_logical_disk_info{disk="0",filesystem="NTFS",serial_number="BC9D5FA8",type="fixed",volume="HarddiskVolume3",volume_name=""} 1 windows_logical_disk_info{disk="0",filesystem="NTFS",serial_number="668EEC37",type="fixed",volume="C:",volume_name="Windows"} 1 windows_logical_disk_info{disk="1",filesystem="NTFS",serial_number="50AE953B",type="fixed",volume="D:",volume_name="Temporary Storage"} 1 windows_logical_disk_info{disk="1",filesystem="ReFS",serial_number="C69B59AD",type="fixed",volume="G:",volume_name="Volume"} 1 diff --git a/internal/collector/logical_disk/logical_disk.go b/internal/collector/logical_disk/logical_disk.go index d3d32084..d58bc879 100644 --- a/internal/collector/logical_disk/logical_disk.go +++ b/internal/collector/logical_disk/logical_disk.go @@ -19,13 +19,11 @@ package logical_disk import ( "context" - "encoding/binary" "errors" "fmt" "log/slog" "regexp" "slices" - "strconv" "strings" "time" @@ -34,7 +32,6 @@ import ( "github.com/prometheus-community/windows_exporter/internal/mi" "github.com/prometheus-community/windows_exporter/internal/pdh" "github.com/prometheus-community/windows_exporter/internal/types" - "github.com/prometheus-community/windows_exporter/internal/utils" "github.com/prometheus/client_golang/prometheus" "golang.org/x/sys/windows" ) @@ -89,6 +86,13 @@ type Collector struct { perfDataCollector *pdh.Collector[perfDataCounterValues] perfDataObject []perfDataCounterValues + metricsEnabled bool + bitlockerEnabled bool + + volumeInfo *volumeInfoReader + // lastMountErr is the last logged error of getAllMountedVolumes. + lastMountErr string + avgReadQueue *prometheus.Desc avgWriteQueue *prometheus.Desc freeSpace *prometheus.Desc @@ -111,15 +115,6 @@ type Collector struct { bitlockerStatus *prometheus.Desc } -type volumeInfo struct { - diskIDs string - filesystem string - serialNumber string - label string - volumeType string - readonly float64 -} - func New(config *Config) *Collector { if config == nil { config = &ConfigDefaults @@ -212,6 +207,10 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { } } + c.metricsEnabled = slices.Contains(c.config.CollectorsEnabled, subCollectorMetrics) + c.bitlockerEnabled = slices.Contains(c.config.CollectorsEnabled, subCollectorBitlocker) + c.volumeInfo = newVolumeInfoReader() + c.information = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "info"), "A metric with a constant '1' value labeled with logical disk information", @@ -291,14 +290,14 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { c.freeSpace = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "free_bytes"), - "Free space in bytes, updates every 10-15 min (LogicalDisk.PercentFreeSpace)", + "Free space in bytes", []string{"volume"}, nil, ) c.totalSpace = prometheus.NewDesc( prometheus.BuildFQName(types.Namespace, Name, "size_bytes"), - "Total space in bytes, updates every 10-15 min (LogicalDisk.PercentFreeSpace_Base)", + "Total space in bytes", []string{"volume"}, nil, ) @@ -358,31 +357,36 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error { // Collect sends the metric values for each metric // to the provided prometheus Metric channel. func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error { - var info volumeInfo - err := c.perfDataCollector.Collect(&c.perfDataObject) if err != nil { return fmt.Errorf("failed to collect LogicalDisk metrics: %w", err) } + // The performance counters don't depend on the mount points. Volumes that can't be + // resolved fall back to their instance name. volumes, err := getAllMountedVolumes() - if err != nil { - return fmt.Errorf("failed to get volumes: %w", err) - } + c.logMountError(err) + + c.volumeInfo.beginScrape() + defer c.volumeInfo.endScrape() for _, data := range c.perfDataObject { if c.config.VolumeExclude.MatchString(data.Name) || !c.config.VolumeInclude.MatchString(data.Name) { continue } - info, err = getVolumeInfo(volumes, data.Name) - if err != nil { + info, err := c.volumeInfo.read(volumes, data.Name, c.metricsEnabled) + if c.volumeInfo.shouldWarn(data.Name, err) { c.logger.Warn("failed to get volume information for "+data.Name, slog.Any("err", err), ) + } else if err != nil { + c.logger.Debug("failed to get volume information for "+data.Name, + slog.Any("err", err), + ) } - if err == nil { + if info.hasReadonly { ch <- prometheus.MustNewConstMetric(c.readOnly, prometheus.GaugeValue, info.readonly, data.Name) } @@ -398,7 +402,18 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error info.serialNumber, ) - if slices.Contains(c.config.CollectorsEnabled, subCollectorMetrics) { + if c.metricsEnabled { + // The Free Megabytes counter is cached by Windows and only updated every 10-15 minutes, + // in whole megabytes. It's the fallback if the volume can't be queried directly. + freeBytes, sizeBytes := data.FreeSpace*1024*1024, data.PercentFreeSpace*1024*1024 + if info.hasFreeBytes { + freeBytes = info.freeBytes + } + + if info.hasSizeBytes { + sizeBytes = info.sizeBytes + } + ch <- prometheus.MustNewConstMetric( c.requestsQueued, prometheus.GaugeValue, @@ -469,14 +484,14 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error ch <- prometheus.MustNewConstMetric( c.freeSpace, prometheus.GaugeValue, - data.FreeSpace*1024*1024, + freeBytes, data.Name, ) ch <- prometheus.MustNewConstMetric( c.totalSpace, prometheus.GaugeValue, - data.PercentFreeSpace*1024*1024, + sizeBytes, data.Name, ) @@ -516,12 +531,12 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error ) } - if slices.Contains(c.config.CollectorsEnabled, subCollectorBitlocker) { + if c.bitlockerEnabled { c.collectBitlocker(ch, volumes, data.Name, false) } } - if slices.Contains(c.config.CollectorsEnabled, subCollectorBitlocker) { + if c.bitlockerEnabled { // Locked BitLocker volumes have no LogicalDisk performance instance. seen := make(map[string]struct{}, len(c.perfDataObject)) for _, data := range c.perfDataObject { @@ -548,6 +563,26 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error return nil } +// logMountError logs an error of getAllMountedVolumes. Repeats of the last error are logged at debug level. +func (c *Collector) logMountError(err error) { + if err == nil { + c.lastMountErr = "" + + return + } + + level := slog.LevelWarn + if msg := err.Error(); msg == c.lastMountErr { + level = slog.LevelDebug + } else { + c.lastMountErr = msg + } + + c.logger.Log(context.Background(), level, "failed to get the mount points of all volumes", + slog.Any("err", err), + ) +} + // collectBitlocker emits the BitLocker status of a volume. With lockedOnly, // volumes in any other state are skipped. func (c *Collector) collectBitlocker(ch chan<- prometheus.Metric, volumes map[string]string, volume string, lockedOnly bool) { @@ -597,160 +632,45 @@ func (c *Collector) collectBitlocker(ch chan<- prometheus.Metric, volumes map[st // getFveVolumeName returns the name of a volume in the Win32 device namespace, // as required by fveapi.dll. func getFveVolumeName(volumes map[string]string, volume string) string { - // The volume GUID path (\\?\Volume{GUID}) is known for every mounted volume, including mount points. - if volumeGUID, ok := volumes[volume]; ok { - return `\\.\` + strings.TrimPrefix(volumeGUID, `\\?\`) - } + devicePath, _ := getVolumePaths(volumes, volume) - // Volumes without a mount point are reported by their device name, e.g. HarddiskVolume1. - if strings.HasPrefix(volume, "HarddiskVolume") { - return `\\.\GLOBALROOT\Device\` + volume - } - - return `\\.\` + volume -} - -func getDriveType(driveType uint32) string { - switch driveType { - case windows.DRIVE_UNKNOWN: - return "unknown" - case windows.DRIVE_NO_ROOT_DIR: - return "norootdir" - case windows.DRIVE_REMOVABLE: - return "removable" - case windows.DRIVE_FIXED: - return "fixed" - case windows.DRIVE_REMOTE: - return "remote" - case windows.DRIVE_CDROM: - return "cdrom" - case windows.DRIVE_RAMDISK: - return "ramdisk" - default: - return "unknown" - } -} - -// diskExtentSize Size of the DiskExtent structure in bytes. -const diskExtentSize = 24 - -// getDiskIDByVolume returns the disk ID for a given volume. -func getVolumeInfo(volumes map[string]string, rootDrive string) (volumeInfo, error) { - volumePath := rootDrive - - // If rootDrive is a NTFS directory, convert it to a volume GUID. - if volumeGUID, ok := volumes[rootDrive]; ok { - // GetVolumeNameForVolumeMountPoint returns the volume GUID path as \\?\Volume{GUID}\ - // According https://learn.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-deviceiocontrol#remarks - // Win32 Drive Namespace is prefixed with \\.\, so we need to remove the \\?\ prefix. - volumePath, _ = strings.CutPrefix(volumeGUID, `\\?\`) - } - - volumePathPtr := windows.StringToUTF16Ptr(`\\.\` + volumePath) - - // mode has to include FILE_SHARE permission to allow concurrent access to the disk. - // use 0 as access mode to avoid admin permission. - mode := uint32(windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE) - attr := uint32(windows.FILE_ATTRIBUTE_READONLY) - - volumeHandle, err := windows.CreateFile(volumePathPtr, 0, mode, nil, windows.OPEN_EXISTING, attr, 0) - if err != nil { - return volumeInfo{}, fmt.Errorf("could not open volume for %s: %w", rootDrive, err) - } - - defer func(fd windows.Handle) { - _ = windows.Close(fd) - }(volumeHandle) - - controlCode := uint32(5636096) // IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS - volumeDiskExtents := make([]byte, 16*1024) - - var bytesReturned uint32 - - err = windows.DeviceIoControl(volumeHandle, controlCode, nil, 0, &volumeDiskExtents[0], uint32(len(volumeDiskExtents)), &bytesReturned, nil) - if err != nil { - return volumeInfo{}, fmt.Errorf("could not identify physical drive for %s: %w", rootDrive, err) - } - - numDiskIDs := uint(binary.LittleEndian.Uint32(volumeDiskExtents)) - if numDiskIDs < 1 { - return volumeInfo{}, fmt.Errorf("could not identify physical drive for %s: no disk IDs returned", rootDrive) - } - - diskIDs := make([]string, numDiskIDs) - - for i := range numDiskIDs { - diskIDs[i] = strconv.FormatUint(uint64(binary.LittleEndian.Uint32(volumeDiskExtents[8+i*diskExtentSize:])), 10) - } - - slices.Sort(diskIDs) - diskIDs = slices.Compact(diskIDs) - - volumeInformationRootDrive := volumePath + `\` - - if strings.Contains(volumePath, `Volume`) { - volumeInformationRootDrive = `\\?\` + volumeInformationRootDrive - } - - volumeInformationRootDrivePtr := windows.StringToUTF16Ptr(volumeInformationRootDrive) - driveType := windows.GetDriveType(volumeInformationRootDrivePtr) - volBufLabel := make([]uint16, windows.MAX_PATH+1) - volSerialNum := uint32(0) - fsFlags := uint32(0) - volBufType := make([]uint16, windows.MAX_PATH+1) - - err = windows.GetVolumeInformation( - volumeInformationRootDrivePtr, - &volBufLabel[0], uint32(len(volBufLabel)), - &volSerialNum, nil, &fsFlags, - &volBufType[0], uint32(len(volBufType)), - ) - if err != nil { - if driveType == windows.DRIVE_CDROM || driveType == windows.DRIVE_REMOVABLE { - return volumeInfo{}, nil - } - - return volumeInfo{}, fmt.Errorf("could not get volume information for %s: %w", volumeInformationRootDrive, err) - } - - return volumeInfo{ - diskIDs: strings.Join(diskIDs, ";"), - volumeType: getDriveType(driveType), - label: windows.UTF16PtrToString(&volBufLabel[0]), - filesystem: windows.UTF16PtrToString(&volBufType[0]), - serialNumber: fmt.Sprintf("%X", volSerialNum), - readonly: utils.BoolToFloat(fsFlags&windows.FILE_READ_ONLY_VOLUME != 0), - }, nil + return devicePath } +// getAllMountedVolumes maps each mount point (e.g. C: or C:\mnt\data) to its volume GUID path. +// A volume whose mount points can't be read is skipped and reported in the returned error, +// together with the mount points of all other volumes. func getAllMountedVolumes() (map[string]string, error) { guidBuf := make([]uint16, windows.MAX_PATH+1) guidBufLen := uint32(len(guidBuf)) + volumes := map[string]string{} + hFindVolume, err := windows.FindFirstVolume(&guidBuf[0], guidBufLen) if err != nil { - return nil, fmt.Errorf("FindFirstVolume: %w", err) + return volumes, fmt.Errorf("FindFirstVolume: %w", err) } defer func() { _ = windows.FindVolumeClose(hFindVolume) }() - volumes := map[string]string{} + var errs []error for ; ; err = windows.FindNextVolume(hFindVolume, &guidBuf[0], guidBufLen) { if err != nil { - switch { - case errors.Is(err, windows.ERROR_NO_MORE_FILES): - return volumes, nil - default: - return nil, fmt.Errorf("FindNextVolume: %w", err) + if !errors.Is(err, windows.ERROR_NO_MORE_FILES) { + errs = append(errs, fmt.Errorf("FindNextVolume: %w", err)) } + + return volumes, errors.Join(errs...) } mountPoints, err := getVolumeMountPaths(&guidBuf[0], windows.GetVolumePathNamesForVolumeName) if err != nil { - return nil, fmt.Errorf("GetVolumePathNamesForVolumeName: %w", err) + errs = append(errs, fmt.Errorf("GetVolumePathNamesForVolumeName(%s): %w", windows.UTF16ToString(guidBuf), err)) + + continue } volumeName := strings.TrimSuffix(windows.UTF16ToString(guidBuf), `\`) diff --git a/internal/collector/logical_disk/volume_info.go b/internal/collector/logical_disk/volume_info.go new file mode 100644 index 00000000..ab858233 --- /dev/null +++ b/internal/collector/logical_disk/volume_info.go @@ -0,0 +1,411 @@ +// 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 logical_disk + +import ( + "encoding/binary" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + + "github.com/prometheus-community/windows_exporter/internal/headers/kernel32" + "github.com/prometheus-community/windows_exporter/internal/utils" + "golang.org/x/sys/windows" +) + +const ( + // ioctlVolumeGetVolumeDiskExtents is IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS. + ioctlVolumeGetVolumeDiskExtents = 0x00560000 + + // diskExtentsHeaderSize is the offset of the first DISK_EXTENT in VOLUME_DISK_EXTENTS: + // NumberOfDiskExtents (DWORD) padded to the 8-byte alignment of DISK_EXTENT. + diskExtentsHeaderSize = 8 + // diskExtentSize is the size of the DISK_EXTENT structure in bytes. + diskExtentSize = 24 + // maxDiskExtents bounds the buffer for the disk extents of a volume, against a bogus count from a driver. + maxDiskExtents = 1024 + + // staticVolumeInfoMaxAge bounds how long the disk IDs and the drive type of a volume are reused. + // Disk numbers can change while a volume stays mounted, e.g. after an iSCSI or USB reconnect. + staticVolumeInfoMaxAge = 10 * time.Minute +) + +type volumeInfo struct { + diskIDs string + filesystem string + serialNumber string + label string + volumeType string + readonly float64 + // hasReadonly is false if the volume information, including the read-only flag, is unknown. + hasReadonly bool + + freeBytes float64 + sizeBytes float64 + // hasFreeBytes and hasSizeBytes are false if freeBytes or sizeBytes are unknown. + hasFreeBytes bool + hasSizeBytes bool +} + +// volumeState is what volumeInfoReader keeps per volume between scrapes. +type volumeState struct { + // generation is the scrape that last read the volume. Volumes not read in a scrape are dropped. + generation uint64 + + // devicePath, diskIDs and driveType are the cached disk extents and drive type, read at readAt. + // A different device path (another volume mounted under the same name) invalidates them. + hasStatic bool + devicePath string + diskIDs string + driveType uint32 + readAt time.Time + + // lastErr is the last error logged as warning, to log repeats at debug level. + lastErr string +} + +// volumeInfoReader reads the information of volumes. It caches the disk IDs and the drive type, +// which rarely change, and reuses its buffers. It must not be used concurrently; the Collect calls +// of a collector never overlap. +type volumeInfoReader struct { + volumes map[string]*volumeState + generation uint64 + now func() time.Time + + extents []byte + label []uint16 + fsName []uint16 +} + +func newVolumeInfoReader() *volumeInfoReader { + return &volumeInfoReader{ + volumes: make(map[string]*volumeState), + now: time.Now, + // Room for 4 extents. Volumes spanning more disks grow the buffer once. + extents: make([]byte, diskExtentsHeaderSize+4*diskExtentSize), + label: make([]uint16, windows.MAX_PATH+1), + fsName: make([]uint16, windows.MAX_PATH+1), + } +} + +// beginScrape starts a scrape. Every volume read before the next endScrape is kept. +func (r *volumeInfoReader) beginScrape() { + r.generation++ +} + +// endScrape drops the state of all volumes that were not read since beginScrape. +func (r *volumeInfoReader) endScrape() { + for name, state := range r.volumes { + if state.generation != r.generation { + delete(r.volumes, name) + } + } +} + +func (r *volumeInfoReader) state(name string) *volumeState { + state, ok := r.volumes[name] + if !ok { + state = &volumeState{} + r.volumes[name] = state + } + + state.generation = r.generation + + return state +} + +// shouldWarn reports whether err should be logged as warning for the volume name. It returns +// false if the same error was already logged since the volume last read without error. +func (r *volumeInfoReader) shouldWarn(name string, err error) bool { + state := r.state(name) + + if err == nil { + state.lastErr = "" + + return false + } + + msg := err.Error() + if state.lastErr == msg { + return false + } + + state.lastErr = msg + + return true +} + +// read returns the information of the volume name, a LogicalDisk instance name. +// mountedVolumes maps mount points to volume GUID paths, see getAllMountedVolumes. +// With withSpace, it also reads the free and total bytes of the volume. +// +// The returned volumeInfo holds everything that could be read, also if err is not nil. +func (r *volumeInfoReader) read(mountedVolumes map[string]string, name string, withSpace bool) (volumeInfo, error) { + state := r.state(name) + devicePath, rootPath := getVolumePaths(mountedVolumes, name) + + rootPathPtr, err := windows.UTF16PtrFromString(rootPath) + if err != nil { + return volumeInfo{}, fmt.Errorf("invalid volume path %q: %w", rootPath, err) + } + + var errs []error + + if !state.hasStatic || state.devicePath != devicePath || r.now().Sub(state.readAt) >= staticVolumeInfoMaxAge { + driveType := windows.GetDriveType(rootPathPtr) + + diskIDs, err := r.readDiskIDs(devicePath) + if err != nil && !isMediaOptional(driveType) { + state.hasStatic = false + + errs = append(errs, fmt.Errorf("could not identify physical drive for %s: %w", name, err)) + } else { + // Removable and CD-ROM drives without media have no disk extents. They are cached + // without disk IDs, to not open the drive on every scrape. + state.hasStatic = true + state.devicePath = devicePath + state.diskIDs = diskIDs + state.driveType = driveType + state.readAt = r.now() + } + } + + info := volumeInfo{} + + driveType := state.driveType + if state.hasStatic { + info.diskIDs = state.diskIDs + } else { + driveType = windows.GetDriveType(rootPathPtr) + } + + info.volumeType = getDriveType(driveType) + + // Removable and CD-ROM drives may have no media or no recognized file system. + mediaOptional := isMediaOptional(driveType) + + var serialNumber, fsFlags uint32 + + // The buffers are reused, so a call that doesn't write them must not return the previous volume's values. + r.label[0], r.fsName[0] = 0, 0 + + err = windows.GetVolumeInformation( + rootPathPtr, + &r.label[0], uint32(len(r.label)), + &serialNumber, nil, &fsFlags, + &r.fsName[0], uint32(len(r.fsName)), + ) + if err != nil { + if !mediaOptional { + errs = append(errs, fmt.Errorf("could not get volume information for %s: %w", rootPath, err)) + } + + return info, errors.Join(errs...) + } + + info.label = windows.UTF16ToString(r.label) + info.filesystem = windows.UTF16ToString(r.fsName) + info.serialNumber = strings.ToUpper(strconv.FormatUint(uint64(serialNumber), 16)) + info.readonly = utils.BoolToFloat(fsFlags&windows.FILE_READ_ONLY_VOLUME != 0) + info.hasReadonly = true + + if withSpace { + err = readSpace(rootPathPtr, &info) + if err != nil && !mediaOptional { + errs = append(errs, fmt.Errorf("could not get free space for %s: %w", rootPath, err)) + } + } + + return info, errors.Join(errs...) +} + +// readSpace reads the free and total bytes of the volume with the root path rootPathPtr into info. +// Both are those of the whole volume, regardless of disk quotas of the account running windows_exporter. +func readSpace(rootPathPtr *uint16, info *volumeInfo) error { + space, err := kernel32.GetDiskSpaceInformation(rootPathPtr) + if err == nil { + unitSize := space.AllocationUnitSize() + info.freeBytes = float64(space.ActualAvailableAllocationUnits * unitSize) + info.sizeBytes = float64(space.ActualTotalAllocationUnits * unitSize) + info.hasFreeBytes = true + info.hasSizeBytes = true + + return nil + } + + if !errors.Is(err, kernel32.ErrDiskSpaceInformationUnavailable) { + return err + } + + // Windows Server 2016 lacks GetDiskSpaceInformationW. GetDiskFreeSpaceEx reports the free bytes + // of the volume, but reduces the total bytes to the quota of the caller, if any. + var callerFreeBytes, callerSizeBytes, freeBytes uint64 + + err = windows.GetDiskFreeSpaceEx(rootPathPtr, &callerFreeBytes, &callerSizeBytes, &freeBytes) + if err != nil { + return err + } + + info.freeBytes = float64(freeBytes) + info.hasFreeBytes = true + + // A quota limits the free bytes of the caller. Without one, the total bytes are those of the volume. + if callerFreeBytes == freeBytes { + info.sizeBytes = float64(callerSizeBytes) + info.hasSizeBytes = true + } + + return nil +} + +func isMediaOptional(driveType uint32) bool { + return driveType == windows.DRIVE_CDROM || driveType == windows.DRIVE_REMOVABLE +} + +// getVolumePaths returns the paths to open the volume name with CreateFile and to pass to +// GetVolumeInformation and GetDiskFreeSpaceEx, which require the root directory. +func getVolumePaths(mountedVolumes map[string]string, name string) (string, string) { + // FindFirstVolume and FindNextVolume return the volume GUID path as \\?\Volume{GUID}. + // According to https://learn.microsoft.com/en-us/windows/win32/api/ioapiset/nf-ioapiset-deviceiocontrol#remarks + // a volume is opened in the Win32 device namespace \\.\, so the \\?\ prefix is replaced. + if volumeGUID, ok := mountedVolumes[name]; ok { + volumePath := strings.TrimPrefix(volumeGUID, `\\?\`) + + return `\\.\` + volumePath, `\\?\` + volumePath + `\` + } + + // Volumes without a mount point are reported by their device name, e.g. HarddiskVolume1. + if strings.HasPrefix(name, "HarddiskVolume") { + return `\\.\GLOBALROOT\Device\` + name, `\\?\GLOBALROOT\Device\` + name + `\` + } + + return `\\.\` + name, name + `\` +} + +// readDiskIDs returns the semicolon-separated numbers of the disks the volume is located on. +func (r *volumeInfoReader) readDiskIDs(devicePath string) (string, error) { + devicePathPtr, err := windows.UTF16PtrFromString(devicePath) + if err != nil { + return "", fmt.Errorf("invalid device path %q: %w", devicePath, err) + } + + // The share mode has to allow all access, to not block other users of the volume. + // An access mode of 0 queries the volume without requiring read access. + mode := uint32(windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE | windows.FILE_SHARE_DELETE) + + volumeHandle, err := windows.CreateFile(devicePathPtr, 0, mode, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_READONLY, 0) + if err != nil { + return "", fmt.Errorf("could not open volume %s: %w", devicePath, err) + } + + defer func(fd windows.Handle) { + _ = windows.Close(fd) + }(volumeHandle) + + return queryDiskIDs(&r.extents, func(buf []byte) (uint32, error) { + var bytesReturned uint32 + + err := windows.DeviceIoControl(volumeHandle, ioctlVolumeGetVolumeDiskExtents, nil, 0, + &buf[0], uint32(len(buf)), &bytesReturned, nil) + + return bytesReturned, err + }) +} + +// queryDiskIDs returns the disk IDs of the VOLUME_DISK_EXTENTS structure that query writes into +// the buffer. It grows the buffer if query returns ERROR_MORE_DATA. +func queryDiskIDs(buf *[]byte, query func([]byte) (uint32, error)) (string, error) { + for { + bytesReturned, err := query(*buf) + if errors.Is(err, windows.ERROR_MORE_DATA) { + // The buffer holds NumberOfDiskExtents. + numDiskExtents := binary.LittleEndian.Uint32(*buf) + if numDiskExtents > maxDiskExtents { + return "", fmt.Errorf("too many disk extents: %d", numDiskExtents) + } + + required := diskExtentsHeaderSize + int(numDiskExtents)*diskExtentSize + if required <= len(*buf) { + return "", fmt.Errorf("invalid required disk extents buffer size: %d", required) + } + + *buf = make([]byte, required) + + continue + } + + if err != nil { + return "", err + } + + if int(bytesReturned) > len(*buf) { + return "", fmt.Errorf("invalid disk extents size: %d", bytesReturned) + } + + return parseDiskIDs((*buf)[:bytesReturned]) + } +} + +// parseDiskIDs returns the sorted, unique disk numbers of a VOLUME_DISK_EXTENTS structure, separated by semicolons. +func parseDiskIDs(volumeDiskExtents []byte) (string, error) { + if len(volumeDiskExtents) < diskExtentsHeaderSize { + return "", fmt.Errorf("disk extents too short: %d bytes", len(volumeDiskExtents)) + } + + numDiskExtents := int(binary.LittleEndian.Uint32(volumeDiskExtents)) + if numDiskExtents < 1 { + return "", errors.New("no disk IDs returned") + } + + if len(volumeDiskExtents) < diskExtentsHeaderSize+numDiskExtents*diskExtentSize { + return "", fmt.Errorf("disk extents truncated: %d extents in %d bytes", numDiskExtents, len(volumeDiskExtents)) + } + + diskIDs := make([]string, numDiskExtents) + for i := range diskIDs { + diskIDs[i] = strconv.FormatUint(uint64(binary.LittleEndian.Uint32(volumeDiskExtents[diskExtentsHeaderSize+i*diskExtentSize:])), 10) + } + + slices.Sort(diskIDs) + + return strings.Join(slices.Compact(diskIDs), ";"), nil +} + +func getDriveType(driveType uint32) string { + switch driveType { + case windows.DRIVE_UNKNOWN: + return "unknown" + case windows.DRIVE_NO_ROOT_DIR: + return "norootdir" + case windows.DRIVE_REMOVABLE: + return "removable" + case windows.DRIVE_FIXED: + return "fixed" + case windows.DRIVE_REMOTE: + return "remote" + case windows.DRIVE_CDROM: + return "cdrom" + case windows.DRIVE_RAMDISK: + return "ramdisk" + default: + return "unknown" + } +} diff --git a/internal/collector/logical_disk/volume_info_test.go b/internal/collector/logical_disk/volume_info_test.go new file mode 100644 index 00000000..c975a439 --- /dev/null +++ b/internal/collector/logical_disk/volume_info_test.go @@ -0,0 +1,251 @@ +// 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 logical_disk + +import ( + "encoding/binary" + "errors" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +func diskExtents(count uint32, diskIDs ...uint32) []byte { + buf := make([]byte, diskExtentsHeaderSize+len(diskIDs)*diskExtentSize) + binary.LittleEndian.PutUint32(buf, count) + + for i, diskID := range diskIDs { + binary.LittleEndian.PutUint32(buf[diskExtentsHeaderSize+i*diskExtentSize:], diskID) + } + + return buf +} + +func TestParseDiskIDs(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + buf []byte + want string + wantErr bool + }{ + {name: "single", buf: diskExtents(1, 0), want: "0"}, + {name: "spanned", buf: diskExtents(3, 2, 1, 2), want: "1;2"}, + {name: "no extents", buf: diskExtents(0), wantErr: true}, + {name: "too short", buf: []byte{1, 0, 0}, wantErr: true}, + // A count larger than the returned bytes must not read past the buffer. + {name: "truncated", buf: diskExtents(2, 1), wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := parseDiskIDs(tc.buf) + if tc.wantErr { + require.Error(t, err) + + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +func TestQueryDiskIDs(t *testing.T) { + t.Parallel() + + t.Run("grows buffer", func(t *testing.T) { + t.Parallel() + + extents := diskExtents(5, 4, 3, 2, 1, 0) + buf := make([]byte, diskExtentsHeaderSize+4*diskExtentSize) + calls := 0 + + got, err := queryDiskIDs(&buf, func(out []byte) (uint32, error) { + calls++ + + if len(out) < len(extents) { + binary.LittleEndian.PutUint32(out, 5) + + return diskExtentsHeaderSize + diskExtentSize, windows.ERROR_MORE_DATA + } + + return uint32(copy(out, extents)), nil + }) + require.NoError(t, err) + require.Equal(t, "0;1;2;3;4", got) + require.Equal(t, 2, calls) + require.Len(t, buf, len(extents)) + }) + + t.Run("bogus extent count", func(t *testing.T) { + t.Parallel() + + buf := make([]byte, diskExtentsHeaderSize+diskExtentSize) + + _, err := queryDiskIDs(&buf, func(out []byte) (uint32, error) { + binary.LittleEndian.PutUint32(out, 0xFFFFFFFF) + + return uint32(len(out)), windows.ERROR_MORE_DATA + }) + require.ErrorContains(t, err, "too many disk extents") + require.Len(t, buf, diskExtentsHeaderSize+diskExtentSize) + }) + + t.Run("more data without growth", func(t *testing.T) { + t.Parallel() + + buf := make([]byte, diskExtentsHeaderSize+diskExtentSize) + + _, err := queryDiskIDs(&buf, func(out []byte) (uint32, error) { + binary.LittleEndian.PutUint32(out, 1) + + return uint32(len(out)), windows.ERROR_MORE_DATA + }) + require.Error(t, err) + }) +} + +func TestGetVolumePaths(t *testing.T) { + t.Parallel() + + volumes := map[string]string{ + "C:": `\\?\Volume{eeb47cb6-f390-4b7b-a107-3d70152440a4}`, + `C:\MOUNT1`: `\\?\Volume{219d1d11-da09-4d2c-86eb-7ac13f10f127}`, + } + + for _, tc := range []struct { + volume string + wantDevice string + wantRoot string + }{ + {"C:", `\\.\Volume{eeb47cb6-f390-4b7b-a107-3d70152440a4}`, `\\?\Volume{eeb47cb6-f390-4b7b-a107-3d70152440a4}\`}, + {`C:\MOUNT1`, `\\.\Volume{219d1d11-da09-4d2c-86eb-7ac13f10f127}`, `\\?\Volume{219d1d11-da09-4d2c-86eb-7ac13f10f127}\`}, + {"HarddiskVolume4", `\\.\GLOBALROOT\Device\HarddiskVolume4`, `\\?\GLOBALROOT\Device\HarddiskVolume4\`}, + {"Z:", `\\.\Z:`, `Z:\`}, + } { + t.Run(tc.volume, func(t *testing.T) { + t.Parallel() + + device, root := getVolumePaths(volumes, tc.volume) + require.Equal(t, tc.wantDevice, device) + require.Equal(t, tc.wantRoot, root) + }) + } +} + +func TestVolumeInfoReaderShouldWarn(t *testing.T) { + t.Parallel() + + r := newVolumeInfoReader() + errA := errors.New("a") + + r.beginScrape() + require.True(t, r.shouldWarn("C:", errA)) + require.False(t, r.shouldWarn("C:", nil)) + r.endScrape() + + r.beginScrape() + require.True(t, r.shouldWarn("C:", errA), "an error after a success must warn again") + require.False(t, r.shouldWarn("C:", errA), "a repeated error must not warn") + require.True(t, r.shouldWarn("C:", errors.New("b")), "a different error must warn") + r.endScrape() + + // Volumes not seen in a scrape are dropped. + r.beginScrape() + r.endScrape() + require.Empty(t, r.volumes) +} + +func TestVolumeInfoReaderSystemDrive(t *testing.T) { + t.Parallel() + + systemDrive := os.Getenv("SystemDrive") + if systemDrive == "" { + t.Skip("SystemDrive is not set") + } + + volumes, err := getAllMountedVolumes() + require.NoError(t, err) + + now := time.Now() + r := newVolumeInfoReader() + r.now = func() time.Time { return now } + + r.beginScrape() + info, err := r.read(volumes, systemDrive, true) + r.endScrape() + + require.NoError(t, err) + require.NotEmpty(t, info.diskIDs) + require.Equal(t, "fixed", info.volumeType) + require.NotEmpty(t, info.filesystem) + require.True(t, info.hasReadonly) + require.True(t, info.hasFreeBytes) + require.True(t, info.hasSizeBytes) + require.Positive(t, info.sizeBytes) + require.LessOrEqual(t, info.freeBytes, info.sizeBytes) + + // Without disk quotas, the values match GetDiskFreeSpaceEx. The free bytes may change in between. + var callerFreeBytes, sizeBytes, freeBytes uint64 + + require.NoError(t, windows.GetDiskFreeSpaceEx(windows.StringToUTF16Ptr(systemDrive+`\`), &callerFreeBytes, &sizeBytes, &freeBytes)) + require.InDelta(t, float64(sizeBytes), info.sizeBytes, 0) + require.InDelta(t, float64(freeBytes), info.freeBytes, 64*1024*1024) + + state := r.volumes[systemDrive] + require.True(t, state.hasStatic) + + // The disk IDs are reused until they expire. + state.diskIDs = "cached" + + r.beginScrape() + info, err = r.read(volumes, systemDrive, false) + r.endScrape() + + require.NoError(t, err) + require.Equal(t, "cached", info.diskIDs) + require.False(t, info.hasFreeBytes) + require.False(t, info.hasSizeBytes) + + // Another volume under the same name invalidates the cached disk IDs. + r.beginScrape() + info, err = r.read(map[string]string{systemDrive: `\\?\Volume{00000000-0000-0000-0000-000000000000}`}, systemDrive, false) + r.endScrape() + + require.Error(t, err) + require.Empty(t, info.diskIDs) + require.False(t, r.volumes[systemDrive].hasStatic) + + state.hasStatic = true + state.diskIDs = "cached" + + now = now.Add(staticVolumeInfoMaxAge) + + r.beginScrape() + info, err = r.read(volumes, systemDrive, false) + r.endScrape() + + require.NoError(t, err) + require.NotEqual(t, "cached", info.diskIDs) +} diff --git a/internal/headers/kernel32/disk_space.go b/internal/headers/kernel32/disk_space.go new file mode 100644 index 00000000..31456691 --- /dev/null +++ b/internal/headers/kernel32/disk_space.go @@ -0,0 +1,81 @@ +// 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 kernel32 + +import ( + "errors" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +//nolint:gochecknoglobals +var procGetDiskSpaceInformationW = modkernel32.NewProc("GetDiskSpaceInformationW") + +// ErrDiskSpaceInformationUnavailable is returned by GetDiskSpaceInformation on Windows versions +// before Windows 10 1809 and Windows Server 2019, which lack GetDiskSpaceInformationW. +var ErrDiskSpaceInformationUnavailable = errors.New("GetDiskSpaceInformationW is not available") + +// DiskSpaceInformation contains disk space information for a volume. +// 📑 https://learn.microsoft.com/en-us/windows/win32/api/fileapi/ns-fileapi-disk_space_information +type DiskSpaceInformation struct { + ActualTotalAllocationUnits uint64 + ActualAvailableAllocationUnits uint64 + ActualPoolUnavailableAllocationUnits uint64 + CallerTotalAllocationUnits uint64 + CallerAvailableAllocationUnits uint64 + CallerPoolUnavailableAllocationUnits uint64 + UsedAllocationUnits uint64 + TotalReservedAllocationUnits uint64 + VolumeStorageReserveAllocationUnits uint64 + AvailableCommittedAllocationUnits uint64 + PoolAvailableAllocationUnits uint64 + SectorsPerAllocationUnit uint32 + BytesPerSector uint32 +} + +// AllocationUnitSize returns the size of an allocation unit in bytes. +func (d DiskSpaceInformation) AllocationUnitSize() uint64 { + return uint64(d.SectorsPerAllocationUnit) * uint64(d.BytesPerSector) +} + +// GetDiskSpaceInformation returns the disk space information of the volume with the root path rootPath. +// Unlike GetDiskFreeSpaceEx, it reports the size of the volume regardless of disk quotas. +// 📑 https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getdiskspaceinformationw +func GetDiskSpaceInformation(rootPath *uint16) (DiskSpaceInformation, error) { + var info DiskSpaceInformation + + if err := procGetDiskSpaceInformationW.Find(); err != nil { + return info, fmt.Errorf("%w: %w", ErrDiskSpaceInformationUnavailable, err) + } + + hr, _, _ := procGetDiskSpaceInformationW.Call( + uintptr(unsafe.Pointer(rootPath)), + uintptr(unsafe.Pointer(&info)), + ) + if int32(hr) < 0 { + if uint32(hr)&0xFFFF0000 == 0x80070000 { + return info, fmt.Errorf("GetDiskSpaceInformationW failed: %w", windows.Errno(uint32(hr)&0xFFFF)) + } + + return info, fmt.Errorf("GetDiskSpaceInformationW failed: HRESULT 0x%08X", uint32(hr)) + } + + return info, nil +}