mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 18:19:04 +02:00
Co-authored-by: Claude <noreply@anthropic.com> Signed-off-by: Claude <noreply@anthropic.com>
412 lines
13 KiB
Go
412 lines
13 KiB
Go
// 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"
|
|
}
|
|
}
|