mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-21 20:19:05 +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_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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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