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_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
+78 -158
View File
@@ -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), `\`)
@@ -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
}