mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 01:59:04 +02:00
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:
co-authored by
Claude
parent
e88c8a712c
commit
ae58ced3b4
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user