logical_disk: cache volume info, read free space live, tolerate failing volumes (#2691)

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Otto Kröpke
2026-10-11 21:10:02 +02:00
committed by GitHub
co-authored by Claude
parent e88c8a712c
commit ae58ced3b4
5 changed files with 837 additions and 165 deletions
+16 -7
View File
@@ -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_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_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_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_free_bytes` | Unused space of the disk in bytes | 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_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_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_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` | | `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]) rate(windows_logical_disk_write_seconds_total[5m])
``` ```
### Warning about size metrics ### Size metrics
The `free_bytes` and `size_bytes` metrics are not updated in real time and might have a delay of 10-15min. The `free_bytes` and `size_bytes` metrics are read from the volume on every scrape. Both are the free and total bytes
This is the same behavior as the Windows performance counters. 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 ### BitLocker status
The `bitlocker_status` sub-collector reads the BitLocker status through `fveapi.dll`, the BitLocker API of Windows. 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 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="FAT32",serial_number="5C1A2B3D",type="fixed",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="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="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="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 windows_logical_disk_info{disk="1",filesystem="ReFS",serial_number="C69B59AD",type="fixed",volume="G:",volume_name="Volume"} 1
+78 -158
View File
@@ -19,13 +19,11 @@ package logical_disk
import ( import (
"context" "context"
"encoding/binary"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"regexp" "regexp"
"slices" "slices"
"strconv"
"strings" "strings"
"time" "time"
@@ -34,7 +32,6 @@ import (
"github.com/prometheus-community/windows_exporter/internal/mi" "github.com/prometheus-community/windows_exporter/internal/mi"
"github.com/prometheus-community/windows_exporter/internal/pdh" "github.com/prometheus-community/windows_exporter/internal/pdh"
"github.com/prometheus-community/windows_exporter/internal/types" "github.com/prometheus-community/windows_exporter/internal/types"
"github.com/prometheus-community/windows_exporter/internal/utils"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"golang.org/x/sys/windows" "golang.org/x/sys/windows"
) )
@@ -89,6 +86,13 @@ type Collector struct {
perfDataCollector *pdh.Collector[perfDataCounterValues] perfDataCollector *pdh.Collector[perfDataCounterValues]
perfDataObject []perfDataCounterValues perfDataObject []perfDataCounterValues
metricsEnabled bool
bitlockerEnabled bool
volumeInfo *volumeInfoReader
// lastMountErr is the last logged error of getAllMountedVolumes.
lastMountErr string
avgReadQueue *prometheus.Desc avgReadQueue *prometheus.Desc
avgWriteQueue *prometheus.Desc avgWriteQueue *prometheus.Desc
freeSpace *prometheus.Desc freeSpace *prometheus.Desc
@@ -111,15 +115,6 @@ type Collector struct {
bitlockerStatus *prometheus.Desc bitlockerStatus *prometheus.Desc
} }
type volumeInfo struct {
diskIDs string
filesystem string
serialNumber string
label string
volumeType string
readonly float64
}
func New(config *Config) *Collector { func New(config *Config) *Collector {
if config == nil { if config == nil {
config = &ConfigDefaults 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( c.information = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, Name, "info"), prometheus.BuildFQName(types.Namespace, Name, "info"),
"A metric with a constant '1' value labeled with logical disk information", "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( c.freeSpace = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, Name, "free_bytes"), prometheus.BuildFQName(types.Namespace, Name, "free_bytes"),
"Free space in bytes, updates every 10-15 min (LogicalDisk.PercentFreeSpace)", "Free space in bytes",
[]string{"volume"}, []string{"volume"},
nil, nil,
) )
c.totalSpace = prometheus.NewDesc( c.totalSpace = prometheus.NewDesc(
prometheus.BuildFQName(types.Namespace, Name, "size_bytes"), 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"}, []string{"volume"},
nil, nil,
) )
@@ -358,31 +357,36 @@ func (c *Collector) Build(logger *slog.Logger, _ *mi.Session) error {
// Collect sends the metric values for each metric // Collect sends the metric values for each metric
// to the provided prometheus Metric channel. // to the provided prometheus Metric channel.
func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error { func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error {
var info volumeInfo
err := c.perfDataCollector.Collect(&c.perfDataObject) err := c.perfDataCollector.Collect(&c.perfDataObject)
if err != nil { if err != nil {
return fmt.Errorf("failed to collect LogicalDisk metrics: %w", err) 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() volumes, err := getAllMountedVolumes()
if err != nil { c.logMountError(err)
return fmt.Errorf("failed to get volumes: %w", err)
} c.volumeInfo.beginScrape()
defer c.volumeInfo.endScrape()
for _, data := range c.perfDataObject { for _, data := range c.perfDataObject {
if c.config.VolumeExclude.MatchString(data.Name) || !c.config.VolumeInclude.MatchString(data.Name) { if c.config.VolumeExclude.MatchString(data.Name) || !c.config.VolumeInclude.MatchString(data.Name) {
continue continue
} }
info, err = getVolumeInfo(volumes, data.Name) info, err := c.volumeInfo.read(volumes, data.Name, c.metricsEnabled)
if err != nil { if c.volumeInfo.shouldWarn(data.Name, err) {
c.logger.Warn("failed to get volume information for "+data.Name, c.logger.Warn("failed to get volume information for "+data.Name,
slog.Any("err", err), 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) 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, 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( ch <- prometheus.MustNewConstMetric(
c.requestsQueued, c.requestsQueued,
prometheus.GaugeValue, prometheus.GaugeValue,
@@ -469,14 +484,14 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error
ch <- prometheus.MustNewConstMetric( ch <- prometheus.MustNewConstMetric(
c.freeSpace, c.freeSpace,
prometheus.GaugeValue, prometheus.GaugeValue,
data.FreeSpace*1024*1024, freeBytes,
data.Name, data.Name,
) )
ch <- prometheus.MustNewConstMetric( ch <- prometheus.MustNewConstMetric(
c.totalSpace, c.totalSpace,
prometheus.GaugeValue, prometheus.GaugeValue,
data.PercentFreeSpace*1024*1024, sizeBytes,
data.Name, 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) 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. // Locked BitLocker volumes have no LogicalDisk performance instance.
seen := make(map[string]struct{}, len(c.perfDataObject)) seen := make(map[string]struct{}, len(c.perfDataObject))
for _, data := range c.perfDataObject { for _, data := range c.perfDataObject {
@@ -548,6 +563,26 @@ func (c *Collector) Collect(ch chan<- prometheus.Metric, _ time.Duration) error
return nil 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, // collectBitlocker emits the BitLocker status of a volume. With lockedOnly,
// volumes in any other state are skipped. // volumes in any other state are skipped.
func (c *Collector) collectBitlocker(ch chan<- prometheus.Metric, volumes map[string]string, volume string, lockedOnly bool) { 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, // getFveVolumeName returns the name of a volume in the Win32 device namespace,
// as required by fveapi.dll. // as required by fveapi.dll.
func getFveVolumeName(volumes map[string]string, volume string) string { func getFveVolumeName(volumes map[string]string, volume string) string {
// The volume GUID path (\\?\Volume{GUID}) is known for every mounted volume, including mount points. devicePath, _ := getVolumePaths(volumes, volume)
if volumeGUID, ok := volumes[volume]; ok {
return `\\.\` + strings.TrimPrefix(volumeGUID, `\\?\`)
}
// Volumes without a mount point are reported by their device name, e.g. HarddiskVolume1. return devicePath
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
} }
// 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) { func getAllMountedVolumes() (map[string]string, error) {
guidBuf := make([]uint16, windows.MAX_PATH+1) guidBuf := make([]uint16, windows.MAX_PATH+1)
guidBufLen := uint32(len(guidBuf)) guidBufLen := uint32(len(guidBuf))
volumes := map[string]string{}
hFindVolume, err := windows.FindFirstVolume(&guidBuf[0], guidBufLen) hFindVolume, err := windows.FindFirstVolume(&guidBuf[0], guidBufLen)
if err != nil { if err != nil {
return nil, fmt.Errorf("FindFirstVolume: %w", err) return volumes, fmt.Errorf("FindFirstVolume: %w", err)
} }
defer func() { defer func() {
_ = windows.FindVolumeClose(hFindVolume) _ = windows.FindVolumeClose(hFindVolume)
}() }()
volumes := map[string]string{} var errs []error
for ; ; err = windows.FindNextVolume(hFindVolume, &guidBuf[0], guidBufLen) { for ; ; err = windows.FindNextVolume(hFindVolume, &guidBuf[0], guidBufLen) {
if err != nil { if err != nil {
switch { if !errors.Is(err, windows.ERROR_NO_MORE_FILES) {
case errors.Is(err, windows.ERROR_NO_MORE_FILES): errs = append(errs, fmt.Errorf("FindNextVolume: %w", err))
return volumes, nil
default:
return nil, fmt.Errorf("FindNextVolume: %w", err)
} }
return volumes, errors.Join(errs...)
} }
mountPoints, err := getVolumeMountPaths(&guidBuf[0], windows.GetVolumePathNamesForVolumeName) mountPoints, err := getVolumeMountPaths(&guidBuf[0], windows.GetVolumePathNamesForVolumeName)
if err != nil { 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), `\`) volumeName := strings.TrimSuffix(windows.UTF16ToString(guidBuf), `\`)
@@ -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"
}
}
@@ -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)
}
+81
View File
@@ -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
}