mirror of
https://github.com/prometheus-community/windows_exporter.git
synced 2026-10-17 10:09:05 +02:00
hyperv: add virtual_hard_disk sub-collector (#2693)
Signed-off-by: EisenbergD <dominik.eisenberg@beiersdorf.com> Co-authored-by: EisenbergD <dominik.eisenberg@beiersdorf.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
EisenbergD
Claude Opus 5.5
parent
89c5df9d8b
commit
ec7dcdc259
@@ -47,6 +47,7 @@ const (
|
||||
subCollectorHypervisorVirtualProcessor = "hypervisor_virtual_processor"
|
||||
subCollectorLegacyNetworkAdapter = "legacy_network_adapter"
|
||||
subCollectorReplicaVM = "replica_vm"
|
||||
subCollectorVirtualHardDisk = "virtual_hard_disk"
|
||||
subCollectorVirtualMachineHealthSummary = "virtual_machine_health_summary"
|
||||
subCollectorVirtualMachineVidPartition = "virtual_machine_vid_partition"
|
||||
subCollectorVirtualNetworkAdapter = "virtual_network_adapter"
|
||||
@@ -58,7 +59,14 @@ const (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
CollectorsEnabled []string `yaml:"enabled"`
|
||||
CollectorsEnabled []string `yaml:"enabled"`
|
||||
VirtualHardDisk VirtualHardDiskConfig `yaml:"virtual-hard-disk"`
|
||||
}
|
||||
|
||||
// VirtualHardDiskConfig configures the virtual_hard_disk sub-collector.
|
||||
type VirtualHardDiskConfig struct {
|
||||
// RefreshInterval is the interval at which the disks are read in the background.
|
||||
RefreshInterval time.Duration `yaml:"refresh-interval"`
|
||||
}
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
@@ -74,6 +82,7 @@ var ConfigDefaults = Config{
|
||||
subCollectorHypervisorVirtualProcessor,
|
||||
subCollectorLegacyNetworkAdapter,
|
||||
subCollectorReplicaVM,
|
||||
subCollectorVirtualHardDisk,
|
||||
subCollectorVirtualMachineHealthSummary,
|
||||
subCollectorVirtualMachineVidPartition,
|
||||
subCollectorVirtualNetworkAdapter,
|
||||
@@ -83,6 +92,9 @@ var ConfigDefaults = Config{
|
||||
subCollectorVirtualSwitch,
|
||||
subCollectorWMIHealth,
|
||||
},
|
||||
VirtualHardDisk: VirtualHardDiskConfig{
|
||||
RefreshInterval: 5 * time.Minute,
|
||||
},
|
||||
}
|
||||
|
||||
// Collector is a Prometheus Collector for hyper-v.
|
||||
@@ -97,6 +109,7 @@ type Collector struct {
|
||||
collectorHypervisorVirtualProcessor
|
||||
collectorLegacyNetworkAdapter
|
||||
collectorReplicaVM
|
||||
collectorVirtualHardDisk
|
||||
collectorVirtualMachineHealthSummary
|
||||
collectorVirtualMachineVidPartition
|
||||
collectorVirtualNetworkAdapter
|
||||
@@ -129,6 +142,10 @@ func New(config *Config) *Collector {
|
||||
config.CollectorsEnabled = ConfigDefaults.CollectorsEnabled
|
||||
}
|
||||
|
||||
if config.VirtualHardDisk.RefreshInterval == 0 {
|
||||
config.VirtualHardDisk.RefreshInterval = ConfigDefaults.VirtualHardDisk.RefreshInterval
|
||||
}
|
||||
|
||||
c := &Collector{
|
||||
config: *config,
|
||||
}
|
||||
@@ -149,6 +166,11 @@ func NewWithFlags(app *kingpin.Application) *Collector {
|
||||
"Comma-separated list of collectors to use.",
|
||||
).Default(strings.Join(ConfigDefaults.CollectorsEnabled, ",")).StringVar(&collectorsEnabled)
|
||||
|
||||
app.Flag(
|
||||
"collector.hyperv.virtual-hard-disk.refresh-interval",
|
||||
"Interval at which the virtual_hard_disk sub-collector reads the disks in the background. Scrapes publish the values of the last refresh.",
|
||||
).Default(ConfigDefaults.VirtualHardDisk.RefreshInterval.String()).DurationVar(&c.config.VirtualHardDisk.RefreshInterval)
|
||||
|
||||
app.Action(func(*kingpin.ParseContext) error {
|
||||
c.config.CollectorsEnabled = strings.Split(collectorsEnabled, ",")
|
||||
|
||||
@@ -245,6 +267,11 @@ func (c *Collector) Build(logger *slog.Logger, miSession *mi.Session) error {
|
||||
// Close the collector created by build, not the nil one bound at this point.
|
||||
close: func() { c.perfDataCollectorReplicaVM.Close() },
|
||||
},
|
||||
subCollectorVirtualHardDisk: {
|
||||
build: c.buildVirtualHardDisk,
|
||||
collect: c.collectVirtualHardDisk,
|
||||
close: c.closeVirtualHardDisk,
|
||||
},
|
||||
subCollectorVirtualMachineHealthSummary: {
|
||||
build: c.buildVirtualMachineHealthSummary,
|
||||
collect: c.collectVirtualMachineHealthSummary,
|
||||
|
||||
@@ -19,6 +19,7 @@ package hyperv_test
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -98,6 +99,108 @@ func TestCollectorLegacyNetworkAdapter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectorVirtualHardDisk checks the disk of the CI fixture VM, which is
|
||||
// created with New-VM -NewVHDSizeBytes 1GB (a dynamic VHDX).
|
||||
func TestCollectorVirtualHardDisk(t *testing.T) {
|
||||
metrics := testutils.TestCollector(t, hyperv.New, &hyperv.Config{CollectorsEnabled: []string{"virtual_hard_disk", "virtual_storage_device"}})
|
||||
|
||||
labels := prometheus.Labels{"vm": "GitHubActions", "type": "dynamic"}
|
||||
|
||||
// The device label joins the disk with its I/O metrics: the running VM's
|
||||
// disk has exactly one "Hyper-V Virtual Storage Device" instance.
|
||||
if metric := testutils.RequireFixtureMetric(t, metrics, hyperv.Name, "windows_hyperv_virtual_hard_disk_size_bytes", labels); metric != nil {
|
||||
device := labelValue(metric, "device")
|
||||
require.NotEmpty(t, device, "the running VM's disk has no device")
|
||||
require.Contains(t, testutils.MetricValuesByLabel(metrics, "windows_hyperv_virtual_storage_device_queue_length", "device"), device,
|
||||
"device %q is not a virtual_storage_device instance", device)
|
||||
}
|
||||
|
||||
if metric := testutils.RequireFixtureMetric(t, metrics, hyperv.Name, "windows_hyperv_virtual_hard_disk_size_bytes", labels); metric != nil {
|
||||
require.InDelta(t, 1<<30, metric.GetGauge().GetValue(), 0)
|
||||
}
|
||||
|
||||
if metric := testutils.RequireFixtureMetric(t, metrics, hyperv.Name, "windows_hyperv_virtual_hard_disk_file_size_bytes", labels); metric != nil {
|
||||
require.Positive(t, metric.GetGauge().GetValue())
|
||||
require.Less(t, metric.GetGauge().GetValue(), float64(1<<30))
|
||||
}
|
||||
// The first refresh runs during Build, so the first scrape has data.
|
||||
if metric := testutils.RequireFixtureMetric(t, metrics, hyperv.Name, "windows_hyperv_virtual_hard_disk_last_refresh_timestamp_seconds", nil); metric != nil {
|
||||
require.InDelta(t, float64(time.Now().Unix()), metric.GetGauge().GetValue(), 300)
|
||||
}
|
||||
|
||||
if metric := testutils.RequireFixtureMetric(t, metrics, hyperv.Name, "windows_hyperv_virtual_hard_disk_read_errors", nil); metric != nil {
|
||||
require.InDelta(t, 0, metric.GetGauge().GetValue(), 0)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVirtualHardDiskRefreshIntervalMustBePositive checks that an invalid
|
||||
// refresh interval stops the build instead of starting a busy loop.
|
||||
func TestVirtualHardDiskRefreshIntervalMustBePositive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
c := hyperv.New(&hyperv.Config{
|
||||
CollectorsEnabled: []string{"virtual_hard_disk"},
|
||||
VirtualHardDisk: hyperv.VirtualHardDiskConfig{RefreshInterval: -time.Second},
|
||||
})
|
||||
|
||||
require.ErrorContains(t, c.Build(slog.New(slog.DiscardHandler), nil), "refresh-interval must be positive")
|
||||
require.NoError(t, c.Close())
|
||||
}
|
||||
|
||||
// TestCollectorVirtualHardDiskCheckpoint checks the CI fixture VM
|
||||
// CIVirtualHardDisk: a 64 MiB dynamic disk with a checkpoint (so the VM runs
|
||||
// on a differencing .avhdx) and an 8 MiB fixed disk attached after the
|
||||
// checkpoint. Each attached disk must be reported once, the checkpoint's own
|
||||
// settings and the parent disk must not be reported.
|
||||
func TestCollectorVirtualHardDiskCheckpoint(t *testing.T) {
|
||||
metrics := testutils.TestCollector(t, hyperv.New, &hyperv.Config{CollectorsEnabled: []string{"virtual_hard_disk"}})
|
||||
|
||||
differencing := testutils.RequireFixtureMetric(t, metrics, hyperv.Name, "windows_hyperv_virtual_hard_disk_size_bytes", prometheus.Labels{"vm": "CIVirtualHardDisk", "type": "differencing"})
|
||||
fixed := testutils.RequireFixtureMetric(t, metrics, hyperv.Name, "windows_hyperv_virtual_hard_disk_size_bytes", prometheus.Labels{"vm": "CIVirtualHardDisk", "type": "fixed"})
|
||||
|
||||
if differencing == nil || fixed == nil {
|
||||
return
|
||||
}
|
||||
|
||||
require.InDelta(t, 64<<20, differencing.GetGauge().GetValue(), 0)
|
||||
require.InDelta(t, 8<<20, fixed.GetGauge().GetValue(), 0)
|
||||
|
||||
// The VM never runs, so its disks have no I/O counter instance.
|
||||
require.Empty(t, labelValue(differencing, "device"))
|
||||
require.Empty(t, labelValue(fixed, "device"))
|
||||
|
||||
for _, name := range []string{"windows_hyperv_virtual_hard_disk_size_bytes", "windows_hyperv_virtual_hard_disk_file_size_bytes"} {
|
||||
var paths []string
|
||||
|
||||
for _, metric := range metrics[name].GetMetric() {
|
||||
labels := make(map[string]string)
|
||||
for _, label := range metric.GetLabel() {
|
||||
labels[label.GetName()] = label.GetValue()
|
||||
}
|
||||
|
||||
if labels["vm"] == "CIVirtualHardDisk" {
|
||||
paths = append(paths, labels["path"])
|
||||
}
|
||||
}
|
||||
|
||||
require.Len(t, paths, 2, "%s: expected the differencing and the fixed disk, got %v", name, paths)
|
||||
|
||||
for _, path := range paths {
|
||||
require.True(t, strings.HasSuffix(strings.ToLower(path), ".avhdx") || strings.HasSuffix(strings.ToLower(path), "fixed.vhdx"), "%s: unexpected disk %s", name, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func labelValue(metric *dto.Metric, name string) string {
|
||||
for _, label := range metric.GetLabel() {
|
||||
if label.GetName() == name {
|
||||
return label.GetValue()
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// TestCloseReleasesQuery ensures Close releases the PDH queries of the sub-collectors.
|
||||
func TestCloseReleasesQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// 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 hyperv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus-community/windows_exporter/internal/headers/virtdisk"
|
||||
"github.com/prometheus-community/windows_exporter/internal/labelcache"
|
||||
"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/recovery"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// virtualHardDiskQueryTimeout bounds each WMI query of a refresh. It also
|
||||
// bounds Close, which waits for a running query.
|
||||
const virtualHardDiskQueryTimeout = 10 * time.Second
|
||||
|
||||
// virtualHardDiskFirstRefreshTimeout bounds how long Build waits for the first
|
||||
// refresh, so the first scrape after a start already has data.
|
||||
const virtualHardDiskFirstRefreshTimeout = 10 * time.Second
|
||||
|
||||
// virtualHardDiskWorkers bounds how many disks are opened at once. Opening a
|
||||
// disk on a CSV takes a few milliseconds, so hosts with many disks read them
|
||||
// in parallel.
|
||||
const virtualHardDiskWorkers = 8
|
||||
|
||||
// virtualHardDiskResourceSubType marks virtual hard disks. ISO and floppy
|
||||
// images share ResourceType 31 and differ only in ResourceSubType.
|
||||
const virtualHardDiskResourceSubType = "Microsoft:Hyper-V:Virtual Hard Disk"
|
||||
|
||||
// collectorVirtualHardDisk Hyper-V virtual hard disk size metrics
|
||||
type collectorVirtualHardDisk struct {
|
||||
virtualHardDiskVMQuery mi.Query
|
||||
virtualHardDiskSettingQuery mi.Query
|
||||
|
||||
virtualHardDiskRefresher *virtualHardDiskRefresher
|
||||
// virtualHardDiskDevices reads the instance names of the "Hyper-V Virtual
|
||||
// Storage Device" counter set, to set the device label. Nil if the counter
|
||||
// set is not available.
|
||||
virtualHardDiskDevices *pdh.Collector[perfDataVirtualHardDiskDevice]
|
||||
|
||||
virtualHardDiskSizeBytes *prometheus.Desc
|
||||
virtualHardDiskFileSizeBytes *prometheus.Desc
|
||||
virtualHardDiskReadErrors *prometheus.Desc
|
||||
virtualHardDiskLastRefreshTimestampSeconds *prometheus.Desc
|
||||
}
|
||||
|
||||
// perfDataVirtualHardDiskDevice reads only the instance names of the
|
||||
// "Hyper-V Virtual Storage Device" counter set; one counter is required.
|
||||
type perfDataVirtualHardDiskDevice struct {
|
||||
Name string
|
||||
|
||||
QueueLength float64 `perfdata:"Queue Length"`
|
||||
}
|
||||
|
||||
// msvmVirtualMachine is the part of Msvm_ComputerSystem needed to name a VM.
|
||||
// The host itself is also an Msvm_ComputerSystem; its Name is the host name,
|
||||
// which never matches a disk's InstanceID.
|
||||
type msvmVirtualMachine struct {
|
||||
Name string `mi:"Name"`
|
||||
ElementName string `mi:"ElementName"`
|
||||
}
|
||||
|
||||
// msvmStorageAllocationSettingData is the part of
|
||||
// Msvm_StorageAllocationSettingData needed to find attached virtual hard disks.
|
||||
type msvmStorageAllocationSettingData struct {
|
||||
InstanceID string `mi:"InstanceID"`
|
||||
ResourceSubType string `mi:"ResourceSubType"`
|
||||
HostResource []string `mi:"HostResource"`
|
||||
}
|
||||
|
||||
func (c *Collector) buildVirtualHardDisk() error {
|
||||
if c.config.VirtualHardDisk.RefreshInterval <= 0 {
|
||||
return fmt.Errorf("collector.hyperv.virtual-hard-disk.refresh-interval must be positive, got %s", c.config.VirtualHardDisk.RefreshInterval)
|
||||
}
|
||||
|
||||
if c.miSession == nil {
|
||||
return mi.ErrNotInitialized
|
||||
}
|
||||
|
||||
if err := virtdisk.Available(); err != nil {
|
||||
return fmt.Errorf("%w: virtdisk.dll: %w", errors.ErrUnsupported, err)
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
c.virtualHardDiskVMQuery, err = mi.NewQuery("SELECT Name, ElementName FROM Msvm_ComputerSystem")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WMI query: %w", err)
|
||||
}
|
||||
|
||||
// ResourceType 31 is a logical disk: virtual hard disks, ISO and floppy
|
||||
// images. attachedVirtualHardDisks keeps the virtual hard disks.
|
||||
c.virtualHardDiskSettingQuery, err = mi.NewQuery("SELECT InstanceID, ResourceSubType, HostResource FROM Msvm_StorageAllocationSettingData WHERE ResourceType = 31")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create WMI query: %w", err)
|
||||
}
|
||||
|
||||
labels := []string{"vm", "vm_id", "path", "type", "device"}
|
||||
|
||||
c.virtualHardDiskSizeBytes = prometheus.NewDesc(
|
||||
prometheus.BuildFQName(types.Namespace, Name, "virtual_hard_disk_size_bytes"),
|
||||
"Maximum size of the virtual hard disk, as seen by the virtual machine",
|
||||
labels,
|
||||
nil,
|
||||
)
|
||||
c.virtualHardDiskFileSizeBytes = prometheus.NewDesc(
|
||||
prometheus.BuildFQName(types.Namespace, Name, "virtual_hard_disk_file_size_bytes"),
|
||||
"Size of the virtual hard disk file on storage",
|
||||
labels,
|
||||
nil,
|
||||
)
|
||||
c.virtualHardDiskReadErrors = prometheus.NewDesc(
|
||||
prometheus.BuildFQName(types.Namespace, Name, "virtual_hard_disk_read_errors"),
|
||||
"Number of attached virtual hard disks that could not be read in the last refresh",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
c.virtualHardDiskLastRefreshTimestampSeconds = prometheus.NewDesc(
|
||||
prometheus.BuildFQName(types.Namespace, Name, "virtual_hard_disk_last_refresh_timestamp_seconds"),
|
||||
"Time of the last successful refresh of the virtual hard disk metrics",
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
c.virtualHardDiskDevices, err = pdh.NewCollector[perfDataVirtualHardDiskDevice](c.logger, pdh.CounterTypeRaw, "Hyper-V Virtual Storage Device", pdh.InstancesAll)
|
||||
if err != nil {
|
||||
c.logger.Warn("Hyper-V Virtual Storage Device counters are not available, virtual hard disks have no device label",
|
||||
slog.Any("err", err),
|
||||
)
|
||||
|
||||
c.virtualHardDiskDevices.Close()
|
||||
c.virtualHardDiskDevices = nil
|
||||
}
|
||||
|
||||
c.virtualHardDiskRefresher = newVirtualHardDiskRefresher(
|
||||
c.logger,
|
||||
c.config.VirtualHardDisk.RefreshInterval,
|
||||
c.listVirtualHardDisks,
|
||||
c.listVirtualHardDiskDevices,
|
||||
virtualHardDiskReader{getInfo: virtdisk.GetInfo, parentLocations: virtdisk.ParentLocations},
|
||||
)
|
||||
c.virtualHardDiskRefresher.start(virtualHardDiskFirstRefreshTimeout)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Collector) closeVirtualHardDisk() {
|
||||
// Stop the refresher first: it reads the counters until it stops.
|
||||
if c.virtualHardDiskRefresher != nil {
|
||||
c.virtualHardDiskRefresher.stop()
|
||||
c.virtualHardDiskRefresher = nil
|
||||
}
|
||||
|
||||
if c.virtualHardDiskDevices != nil {
|
||||
c.virtualHardDiskDevices.Close()
|
||||
c.virtualHardDiskDevices = nil
|
||||
}
|
||||
}
|
||||
|
||||
// listVirtualHardDiskDevices returns the "Hyper-V Virtual Storage Device"
|
||||
// instances. Only running virtual machines have instances.
|
||||
func (c *Collector) listVirtualHardDiskDevices() ([]string, error) {
|
||||
if c.virtualHardDiskDevices == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rows []perfDataVirtualHardDiskDevice
|
||||
|
||||
err := c.virtualHardDiskDevices.Collect(&rows)
|
||||
if errors.Is(err, pdh.ErrNoData) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to collect Hyper-V Virtual Storage Device instances: %w", err)
|
||||
}
|
||||
|
||||
instances := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
instances = append(instances, row.Name)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
func (c *Collector) listVirtualHardDisks() ([]attachedVirtualHardDisk, error) {
|
||||
var vms []msvmVirtualMachine
|
||||
if err := c.miSession.Query(&vms, mi.NamespaceRootVirtualizationV2, c.virtualHardDiskVMQuery, virtualHardDiskQueryTimeout); err != nil {
|
||||
return nil, fmt.Errorf("failed to query Msvm_ComputerSystem: %w", err)
|
||||
}
|
||||
|
||||
var settings []msvmStorageAllocationSettingData
|
||||
if err := c.miSession.Query(&settings, mi.NamespaceRootVirtualizationV2, c.virtualHardDiskSettingQuery, virtualHardDiskQueryTimeout); err != nil {
|
||||
return nil, fmt.Errorf("failed to query Msvm_StorageAllocationSettingData: %w", err)
|
||||
}
|
||||
|
||||
return attachedVirtualHardDisks(vms, settings), nil
|
||||
}
|
||||
|
||||
// collectVirtualHardDisk publishes the result of the last background refresh.
|
||||
// Disks that can't be read are logged and counted, only a failed disk listing
|
||||
// is returned as an error.
|
||||
func (c *Collector) collectVirtualHardDisk(ch chan<- prometheus.Metric, scrape *labelcache.Scrape) error {
|
||||
snapshot := c.virtualHardDiskRefresher.get()
|
||||
|
||||
for _, result := range snapshot.results {
|
||||
disk := result.disk
|
||||
diskType := virtualHardDiskType(result.info.ProviderSubtype)
|
||||
|
||||
ch <- scrape.MustNewConstMetric(c.virtualHardDiskSizeBytes, prometheus.GaugeValue, float64(result.info.VirtualSize), disk.VMName, disk.VMID, disk.Path, diskType, result.device)
|
||||
|
||||
ch <- scrape.MustNewConstMetric(c.virtualHardDiskFileSizeBytes, prometheus.GaugeValue, float64(result.info.PhysicalSize), disk.VMName, disk.VMID, disk.Path, diskType, result.device)
|
||||
}
|
||||
|
||||
if !snapshot.refreshed.IsZero() {
|
||||
ch <- scrape.MustNewConstMetric(c.virtualHardDiskReadErrors, prometheus.GaugeValue, float64(snapshot.readErrors))
|
||||
|
||||
ch <- scrape.MustNewConstMetric(c.virtualHardDiskLastRefreshTimestampSeconds, prometheus.GaugeValue, float64(snapshot.refreshed.UnixNano())/1e9)
|
||||
}
|
||||
|
||||
if snapshot.err != nil {
|
||||
return fmt.Errorf("last virtual hard disk refresh failed: %w", snapshot.err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type virtualHardDiskResult struct {
|
||||
disk attachedVirtualHardDisk
|
||||
info virtdisk.Info
|
||||
// chain is the disk and its parents, see virtualHardDiskChain.
|
||||
chain []string
|
||||
// device is the "Hyper-V Virtual Storage Device" counter instance of the
|
||||
// disk, "" if it has none.
|
||||
device string
|
||||
}
|
||||
|
||||
// virtualHardDiskReader reads a disk and the paths of its parents.
|
||||
type virtualHardDiskReader struct {
|
||||
getInfo func(path string) (virtdisk.Info, error)
|
||||
parentLocations func(path string) ([]string, error)
|
||||
}
|
||||
|
||||
// readVirtualHardDisks reads the disks with at most workers disks at once. A
|
||||
// disk that fails is left out and its error, or a recovered panic, is
|
||||
// returned joined with the others; the order of the disks is kept.
|
||||
func readVirtualHardDisks(disks []attachedVirtualHardDisk, workers int, reader virtualHardDiskReader) ([]virtualHardDiskResult, error) {
|
||||
results := make([]virtualHardDiskResult, len(disks))
|
||||
read := make([]bool, len(disks))
|
||||
slots := make(chan struct{}, workers)
|
||||
|
||||
var group recovery.Group
|
||||
|
||||
for i, disk := range disks {
|
||||
slots <- struct{}{}
|
||||
|
||||
group.Go(func() error {
|
||||
defer func() { <-slots }()
|
||||
|
||||
info, err := reader.getInfo(disk.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
results[i] = virtualHardDiskResult{
|
||||
disk: disk,
|
||||
info: info,
|
||||
chain: virtualHardDiskChain(disk.Path, info.ProviderSubtype, reader.getInfo, reader.parentLocations),
|
||||
}
|
||||
read[i] = true
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err := group.Wait()
|
||||
|
||||
out := make([]virtualHardDiskResult, 0, len(disks))
|
||||
|
||||
for i := range disks {
|
||||
if read[i] {
|
||||
out = append(out, results[i])
|
||||
}
|
||||
}
|
||||
|
||||
return out, err
|
||||
}
|
||||
|
||||
type attachedVirtualHardDisk struct {
|
||||
VMID string
|
||||
VMName string
|
||||
Path string
|
||||
}
|
||||
|
||||
// attachedVirtualHardDisks returns the disks of the VMs' current configuration.
|
||||
// The InstanceID of a setting is "Microsoft:<owner GUID>\...". The owner is the
|
||||
// VM for its current configuration and the checkpoint for checkpoint settings,
|
||||
// so matching the VM GUID drops checkpoint settings.
|
||||
func attachedVirtualHardDisks(vms []msvmVirtualMachine, settings []msvmStorageAllocationSettingData) []attachedVirtualHardDisk {
|
||||
names := make(map[string]msvmVirtualMachine, len(vms))
|
||||
for _, vm := range vms {
|
||||
names[strings.ToUpper(vm.Name)] = vm
|
||||
}
|
||||
|
||||
var disks []attachedVirtualHardDisk
|
||||
|
||||
for _, setting := range settings {
|
||||
if setting.ResourceSubType != virtualHardDiskResourceSubType {
|
||||
continue
|
||||
}
|
||||
|
||||
owner, ok := strings.CutPrefix(setting.InstanceID, "Microsoft:")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
owner, _, _ = strings.Cut(owner, `\`)
|
||||
|
||||
vm, ok := names[strings.ToUpper(owner)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, path := range setting.HostResource {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
disks = append(disks, attachedVirtualHardDisk{VMID: vm.Name, VMName: vm.ElementName, Path: path})
|
||||
}
|
||||
}
|
||||
|
||||
return disks
|
||||
}
|
||||
|
||||
func virtualHardDiskType(subtype uint32) string {
|
||||
switch subtype {
|
||||
case virtdisk.ProviderSubtypeFixed:
|
||||
return "fixed"
|
||||
case virtdisk.ProviderSubtypeDynamic:
|
||||
return "dynamic"
|
||||
case virtdisk.ProviderSubtypeDifferencing:
|
||||
return "differencing"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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 hyperv
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/prometheus-community/windows_exporter/internal/headers/virtdisk"
|
||||
)
|
||||
|
||||
// virtualHardDiskMaxChainDepth bounds how many parents of a differencing disk
|
||||
// are followed to find its "Hyper-V Virtual Storage Device" counter instance.
|
||||
const virtualHardDiskMaxChainDepth = 16
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var virtualStorageDeviceNameReplacer = strings.NewReplacer(`\`, "-", "(", "[", ")", "]")
|
||||
|
||||
// virtualStorageDeviceName returns the name that the "Hyper-V Virtual Storage
|
||||
// Device" counter set uses for the disk at path: counter paths reserve "\"
|
||||
// and "(...)", so Hyper-V replaces them with "-" and "[...]". A "\\?\" prefix
|
||||
// becomes "--?-".
|
||||
func virtualStorageDeviceName(path string) string {
|
||||
return virtualStorageDeviceNameReplacer.Replace(path)
|
||||
}
|
||||
|
||||
// virtualStorageDeviceMatcher finds the counter instance of a disk among the
|
||||
// instances that exist. Only running virtual machines have instances.
|
||||
type virtualStorageDeviceMatcher struct {
|
||||
instances map[string]string // instance name and lower case instance name -> instance name
|
||||
}
|
||||
|
||||
func newVirtualStorageDeviceMatcher(instances []string) virtualStorageDeviceMatcher {
|
||||
matcher := virtualStorageDeviceMatcher{instances: make(map[string]string, 2*len(instances))}
|
||||
|
||||
for _, instance := range instances {
|
||||
matcher.instances[instance] = instance
|
||||
}
|
||||
|
||||
// Exact names win over names that only match without case.
|
||||
for _, instance := range instances {
|
||||
if _, ok := matcher.instances[strings.ToLower(instance)]; !ok {
|
||||
matcher.instances[strings.ToLower(instance)] = instance
|
||||
}
|
||||
}
|
||||
|
||||
return matcher
|
||||
}
|
||||
|
||||
// match returns the instance of the first candidate path that has one, or ""
|
||||
// if none has. The candidates are a disk and then its parents: the instance of
|
||||
// a VHD (not VHDX) checkpoint .avhd is named after the base disk of its chain.
|
||||
func (m virtualStorageDeviceMatcher) match(candidates []string) string {
|
||||
for _, candidate := range candidates {
|
||||
name := virtualStorageDeviceName(candidate)
|
||||
|
||||
if instance, ok := m.instances[name]; ok {
|
||||
return instance
|
||||
}
|
||||
|
||||
if instance, ok := m.instances[strings.ToLower(name)]; ok {
|
||||
return instance
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// virtualHardDiskChain returns path followed by its parents, the base disk
|
||||
// last. Only differencing disks have parents. Relative parent locations are
|
||||
// resolved against the directory of the child.
|
||||
func virtualHardDiskChain(
|
||||
path string,
|
||||
subtype uint32,
|
||||
getInfo func(path string) (virtdisk.Info, error),
|
||||
parentLocations func(path string) ([]string, error),
|
||||
) []string {
|
||||
chain := []string{path}
|
||||
seen := map[string]bool{strings.ToLower(path): true}
|
||||
|
||||
for current := path; subtype == virtdisk.ProviderSubtypeDifferencing && len(chain) < virtualHardDiskMaxChainDepth; {
|
||||
locations, err := parentLocations(current)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
next := ""
|
||||
|
||||
// Absolute locations first: they match the counter instance names,
|
||||
// relative ones may contain "..".
|
||||
locations = slices.Clone(locations)
|
||||
slices.SortStableFunc(locations, func(a, b string) int {
|
||||
switch {
|
||||
case filepath.IsAbs(a) == filepath.IsAbs(b):
|
||||
return 0
|
||||
case filepath.IsAbs(a):
|
||||
return -1
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
})
|
||||
|
||||
for _, location := range locations {
|
||||
if !filepath.IsAbs(location) {
|
||||
location = filepath.Join(filepath.Dir(current), location)
|
||||
}
|
||||
|
||||
if seen[strings.ToLower(location)] {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := getInfo(location)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
next, subtype = location, info.ProviderSubtype
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if next == "" {
|
||||
break
|
||||
}
|
||||
|
||||
chain = append(chain, next)
|
||||
seen[strings.ToLower(next)] = true
|
||||
current = next
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// 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 hyperv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus-community/windows_exporter/internal/headers/virtdisk"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAttachedVirtualHardDisks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
vmID = "8E3A2C14-5B7D-4F11-9C2E-0A1B2C3D4E5F"
|
||||
snapshotID = "11111111-2222-3333-4444-555555555555"
|
||||
)
|
||||
|
||||
vms := []msvmVirtualMachine{
|
||||
{Name: "HOSTNAME", ElementName: "HOSTNAME"}, // the host itself
|
||||
{Name: vmID, ElementName: "VM01"},
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
vms []msvmVirtualMachine
|
||||
settings []msvmStorageAllocationSettingData
|
||||
want []attachedVirtualHardDisk
|
||||
}{
|
||||
"current disk": {
|
||||
vms: vms,
|
||||
settings: []msvmStorageAllocationSettingData{{ResourceSubType: virtualHardDiskResourceSubType, InstanceID: `Microsoft:` + vmID + `\83F8638B-8DCA-4152-9EDA-2CA8B33039B4\0\0\L`, HostResource: []string{`C:\ClusterStorage\Volume1\VM01\disk.vhdx`}}},
|
||||
want: []attachedVirtualHardDisk{{VMID: vmID, VMName: "VM01", Path: `C:\ClusterStorage\Volume1\VM01\disk.vhdx`}},
|
||||
},
|
||||
"checkpoint settings are dropped": {
|
||||
vms: vms,
|
||||
settings: []msvmStorageAllocationSettingData{
|
||||
{ResourceSubType: virtualHardDiskResourceSubType, InstanceID: `Microsoft:` + snapshotID + `\83F8638B-8DCA-4152-9EDA-2CA8B33039B4\0\0\L`, HostResource: []string{`C:\VM01\disk.vhdx`}},
|
||||
{ResourceSubType: virtualHardDiskResourceSubType, InstanceID: `Microsoft:` + vmID + `\83F8638B-8DCA-4152-9EDA-2CA8B33039B4\0\0\L`, HostResource: []string{`C:\VM01\disk_ABC.avhdx`}},
|
||||
},
|
||||
want: []attachedVirtualHardDisk{{VMID: vmID, VMName: "VM01", Path: `C:\VM01\disk_ABC.avhdx`}},
|
||||
},
|
||||
"guid case differs": {
|
||||
vms: vms,
|
||||
settings: []msvmStorageAllocationSettingData{{ResourceSubType: virtualHardDiskResourceSubType, InstanceID: `Microsoft:8e3a2c14-5b7d-4f11-9c2e-0a1b2c3d4e5f\X\0\0\L`, HostResource: []string{`C:\VM01\disk.vhdx`}}},
|
||||
want: []attachedVirtualHardDisk{{VMID: vmID, VMName: "VM01", Path: `C:\VM01\disk.vhdx`}},
|
||||
},
|
||||
"empty host resource": {
|
||||
vms: vms,
|
||||
settings: []msvmStorageAllocationSettingData{
|
||||
{ResourceSubType: virtualHardDiskResourceSubType, InstanceID: `Microsoft:` + vmID + `\X\0\0\L`, HostResource: nil},
|
||||
{ResourceSubType: virtualHardDiskResourceSubType, InstanceID: `Microsoft:` + vmID + `\Y\0\0\L`, HostResource: []string{""}},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
"unknown instance id format": {
|
||||
vms: vms,
|
||||
settings: []msvmStorageAllocationSettingData{{ResourceSubType: virtualHardDiskResourceSubType, InstanceID: `garbage`, HostResource: []string{`C:\x.vhdx`}}},
|
||||
want: nil,
|
||||
},
|
||||
"iso and floppy images are dropped": {
|
||||
vms: vms,
|
||||
settings: []msvmStorageAllocationSettingData{
|
||||
{ResourceSubType: "Microsoft:Hyper-V:Virtual CD/DVD Disk", InstanceID: `Microsoft:` + vmID + `\X\0\0\D`, HostResource: []string{`C:\ISO\install.iso`}},
|
||||
{ResourceSubType: "Microsoft:Hyper-V:Virtual Floppy Disk", InstanceID: `Microsoft:` + vmID + `\Y\0\0\F`, HostResource: []string{`C:\VM01\boot.vfd`}},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
"no vms": {
|
||||
vms: nil,
|
||||
settings: nil,
|
||||
want: nil,
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tc.want, attachedVirtualHardDisks(tc.vms, tc.settings))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualHardDiskType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, "fixed", virtualHardDiskType(virtdisk.ProviderSubtypeFixed))
|
||||
require.Equal(t, "dynamic", virtualHardDiskType(virtdisk.ProviderSubtypeDynamic))
|
||||
require.Equal(t, "differencing", virtualHardDiskType(virtdisk.ProviderSubtypeDifferencing))
|
||||
require.Equal(t, "unknown", virtualHardDiskType(0))
|
||||
require.Equal(t, "unknown", virtualHardDiskType(99))
|
||||
}
|
||||
|
||||
func TestReadVirtualHardDisks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
disks := make([]attachedVirtualHardDisk, 20)
|
||||
for i := range disks {
|
||||
disks[i] = attachedVirtualHardDisk{VMID: "id", VMName: "vm", Path: fmt.Sprintf(`C:\disk%d.vhdx`, i)}
|
||||
}
|
||||
|
||||
errBroken := errors.New("broken")
|
||||
|
||||
var running, maxRunning atomic.Int32
|
||||
|
||||
getInfo := func(path string) (virtdisk.Info, error) {
|
||||
current := running.Add(1)
|
||||
defer running.Add(-1)
|
||||
|
||||
for {
|
||||
highest := maxRunning.Load()
|
||||
if current <= highest || maxRunning.CompareAndSwap(highest, current) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
if path == `C:\disk7.vhdx` {
|
||||
return virtdisk.Info{}, fmt.Errorf("%s: %w", path, errBroken)
|
||||
}
|
||||
|
||||
if path == `C:\disk9.vhdx` {
|
||||
panic("boom")
|
||||
}
|
||||
|
||||
return virtdisk.Info{VirtualSize: uint64(len(path)), ProviderSubtype: virtdisk.ProviderSubtypeDynamic}, nil
|
||||
}
|
||||
|
||||
results, err := readVirtualHardDisks(disks, 4, virtualHardDiskReader{getInfo: getInfo, parentLocations: func(string) ([]string, error) { return nil, nil }})
|
||||
|
||||
// One disk fails, one panics: both are reported, the others are read.
|
||||
require.ErrorIs(t, err, errBroken)
|
||||
require.ErrorContains(t, err, `C:\disk7.vhdx`)
|
||||
require.ErrorContains(t, err, "boom")
|
||||
require.Len(t, results, len(disks)-2)
|
||||
|
||||
for _, result := range results {
|
||||
require.NotEqual(t, `C:\disk7.vhdx`, result.disk.Path)
|
||||
require.NotEqual(t, `C:\disk9.vhdx`, result.disk.Path)
|
||||
require.Equal(t, uint64(len(result.disk.Path)), result.info.VirtualSize)
|
||||
}
|
||||
|
||||
require.LessOrEqual(t, maxRunning.Load(), int32(4), "more disks were opened at once than allowed")
|
||||
require.Greater(t, maxRunning.Load(), int32(1), "disks were not opened in parallel")
|
||||
}
|
||||
|
||||
func TestReadVirtualHardDisksEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
results, err := readVirtualHardDisks(nil, 4, virtualHardDiskReader{
|
||||
getInfo: func(string) (virtdisk.Info, error) {
|
||||
t.Fatal("no disk to read")
|
||||
|
||||
return virtdisk.Info{}, nil
|
||||
},
|
||||
parentLocations: func(string) ([]string, error) { return nil, nil },
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, results)
|
||||
}
|
||||
|
||||
func TestVirtualStorageDeviceName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for path, want := range map[string]string{
|
||||
`C:\ClusterStorage\CSV01\VM01\disk.vhdx`: `C:-ClusterStorage-CSV01-VM01-disk.vhdx`,
|
||||
`C:\ClusterStorage\CSV04\VM02\Disk(1).vhdx`: `C:-ClusterStorage-CSV04-VM02-Disk[1].vhdx`,
|
||||
`\\?\C:\ClusterStorage\CSV01\VM01\Virtual Machines\x.vmgs`: `--?-C:-ClusterStorage-CSV01-VM01-Virtual Machines-x.vmgs`,
|
||||
`C:\ClusterStorage\CSV05\VM03\VM03_8C9A696D-1374-4DD6-B15C-B74E2CA47C29.avhdx`: `C:-ClusterStorage-CSV05-VM03-VM03_8C9A696D-1374-4DD6-B15C-B74E2CA47C29.avhdx`,
|
||||
`D:\VMs\vm.example.net\vra.vhd`: `D:-VMs-vm.example.net-vra.vhd`,
|
||||
} {
|
||||
require.Equal(t, want, virtualStorageDeviceName(path), path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVirtualStorageDeviceMatcher(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
matcher := newVirtualStorageDeviceMatcher([]string{
|
||||
`C:-CSV01-VM01-disk.vhdx`,
|
||||
`C:-CSV01-VM01-Disk[1].vhdx`,
|
||||
`C:-CSV06-VM02-vm02_os.vhd`,
|
||||
`--?-C:-CSV01-VM01-Virtual Machines-x.vmgs`,
|
||||
})
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
candidates []string
|
||||
want string
|
||||
}{
|
||||
"exact": {[]string{`C:\CSV01\VM01\disk.vhdx`}, `C:-CSV01-VM01-disk.vhdx`},
|
||||
"parentheses": {[]string{`C:\CSV01\VM01\Disk(1).vhdx`}, `C:-CSV01-VM01-Disk[1].vhdx`},
|
||||
"case differs": {[]string{`c:\csv01\vm01\DISK.VHDX`}, `C:-CSV01-VM01-disk.vhdx`},
|
||||
"vhd checkpoint uses parent": {[]string{`C:\CSV06\VM02\vm02_os_8C9A696D-1374-4DD6-B15C-B74E2CA47C29.avhd`, `C:\CSV06\VM02\vm02_os.vhd`}, `C:-CSV06-VM02-vm02_os.vhd`},
|
||||
"own name wins over parent": {[]string{`C:\CSV01\VM01\Disk(1).vhdx`, `C:\CSV01\VM01\disk.vhdx`}, `C:-CSV01-VM01-Disk[1].vhdx`},
|
||||
"vm not running": {[]string{`C:\CSV09\VM09\off.vhdx`}, ""},
|
||||
"no candidates": {nil, ""},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tc.want, matcher.match(tc.candidates))
|
||||
})
|
||||
}
|
||||
|
||||
require.Empty(t, newVirtualStorageDeviceMatcher(nil).match([]string{`C:\CSV01\VM01\disk.vhdx`}))
|
||||
}
|
||||
|
||||
func TestVirtualHardDiskChain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// vm_os_B.avhd -> vm_os_A.avhd -> vm_os.vhd, the second parent recorded relative.
|
||||
parents := map[string][]string{
|
||||
`C:\VM\vm_os_B.avhd`: {`C:\VM\vm_os_A.avhd`},
|
||||
`C:\VM\vm_os_A.avhd`: {`vm_os.vhd`, `C:\VM\vm_os.vhd`},
|
||||
}
|
||||
subtypes := map[string]uint32{
|
||||
`C:\VM\vm_os_B.avhd`: virtdisk.ProviderSubtypeDifferencing,
|
||||
`C:\VM\vm_os_A.avhd`: virtdisk.ProviderSubtypeDifferencing,
|
||||
`C:\VM\vm_os.vhd`: virtdisk.ProviderSubtypeDynamic,
|
||||
}
|
||||
|
||||
parentLocations := func(path string) ([]string, error) { return parents[path], nil }
|
||||
getInfo := func(path string) (virtdisk.Info, error) {
|
||||
subtype, ok := subtypes[path]
|
||||
if !ok {
|
||||
return virtdisk.Info{}, errors.New("not found")
|
||||
}
|
||||
|
||||
return virtdisk.Info{ProviderSubtype: subtype}, nil
|
||||
}
|
||||
|
||||
require.Equal(t,
|
||||
[]string{`C:\VM\vm_os_B.avhd`, `C:\VM\vm_os_A.avhd`, `C:\VM\vm_os.vhd`},
|
||||
virtualHardDiskChain(`C:\VM\vm_os_B.avhd`, virtdisk.ProviderSubtypeDifferencing, getInfo, parentLocations),
|
||||
)
|
||||
|
||||
// Disks that aren't differencing have no parents to read.
|
||||
require.Equal(t, []string{`C:\VM\vm_os.vhd`}, virtualHardDiskChain(`C:\VM\vm_os.vhd`, virtdisk.ProviderSubtypeDynamic, getInfo, func(string) ([]string, error) {
|
||||
t.Fatal("parent locations of a dynamic disk")
|
||||
|
||||
return nil, nil
|
||||
}))
|
||||
|
||||
// A loop in broken metadata ends.
|
||||
loop := func(path string) ([]string, error) { return []string{path}, nil }
|
||||
require.Equal(t, []string{`C:\VM\vm_os_B.avhd`}, virtualHardDiskChain(`C:\VM\vm_os_B.avhd`, virtdisk.ProviderSubtypeDifferencing, getInfo, loop))
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// 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 hyperv
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus-community/windows_exporter/internal/utils/recovery"
|
||||
)
|
||||
|
||||
// virtualHardDiskSnapshot is the result of the last refresh.
|
||||
type virtualHardDiskSnapshot struct {
|
||||
results []virtualHardDiskResult
|
||||
// readErrors is the number of disks of the last refresh that could not be read.
|
||||
readErrors int
|
||||
// refreshed is the time of the last successful refresh, zero before the first one.
|
||||
refreshed time.Time
|
||||
// err is the error of the last disk listing. The results of the last
|
||||
// successful refresh are kept.
|
||||
err error
|
||||
}
|
||||
|
||||
// virtualHardDiskRefresher reads the virtual hard disks in the background, so
|
||||
// a scrape only publishes the last values and a slow or hanging disk can't
|
||||
// block the hyperv collector.
|
||||
type virtualHardDiskRefresher struct {
|
||||
logger *slog.Logger
|
||||
interval time.Duration
|
||||
list func() ([]attachedVirtualHardDisk, error)
|
||||
// instances returns the "Hyper-V Virtual Storage Device" counter instances.
|
||||
instances func() ([]string, error)
|
||||
reader virtualHardDiskReader
|
||||
now func() time.Time
|
||||
|
||||
// listMu is held while list and instances run. stop waits for it, because
|
||||
// they use the MI session and the PDH query, which are released after Close.
|
||||
listMu sync.Mutex
|
||||
|
||||
mu sync.RWMutex
|
||||
snapshot virtualHardDiskSnapshot
|
||||
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func newVirtualHardDiskRefresher(
|
||||
logger *slog.Logger,
|
||||
interval time.Duration,
|
||||
list func() ([]attachedVirtualHardDisk, error),
|
||||
instances func() ([]string, error),
|
||||
reader virtualHardDiskReader,
|
||||
) *virtualHardDiskRefresher {
|
||||
return &virtualHardDiskRefresher{
|
||||
logger: logger,
|
||||
interval: interval,
|
||||
list: list,
|
||||
instances: instances,
|
||||
reader: reader,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// start refreshes in the background and waits up to firstRefreshTimeout for
|
||||
// the first refresh, so the first scrape after a start already has data.
|
||||
func (r *virtualHardDiskRefresher) start(firstRefreshTimeout time.Duration) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r.cancel = cancel
|
||||
|
||||
firstRefresh := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for first := true; ; first = false {
|
||||
if err := recovery.Run(func() error {
|
||||
r.refresh(ctx)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
r.logger.Error("virtual hard disk refresh failed", slog.Any("err", err))
|
||||
}
|
||||
|
||||
if first {
|
||||
close(firstRefresh)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(r.interval):
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-firstRefresh:
|
||||
case <-time.After(firstRefreshTimeout):
|
||||
r.logger.Warn("first virtual hard disk refresh did not finish in time, continuing in the background",
|
||||
slog.Duration("timeout", firstRefreshTimeout),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// stop ends the background refresh. It waits for a running disk listing, but
|
||||
// not for disk reads, which don't use shared resources.
|
||||
func (r *virtualHardDiskRefresher) stop() {
|
||||
if r.cancel != nil {
|
||||
r.cancel()
|
||||
}
|
||||
|
||||
r.listMu.Lock()
|
||||
defer r.listMu.Unlock()
|
||||
}
|
||||
|
||||
func (r *virtualHardDiskRefresher) get() virtualHardDiskSnapshot {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
return r.snapshot
|
||||
}
|
||||
|
||||
func (r *virtualHardDiskRefresher) refresh(ctx context.Context) {
|
||||
disks, instances, err := r.listDisks(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
r.logger.WarnContext(ctx, "failed to list virtual hard disks", slog.Any("err", err))
|
||||
|
||||
r.mu.Lock()
|
||||
r.snapshot.err = err
|
||||
r.mu.Unlock()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
results, err := readVirtualHardDisks(disks, virtualHardDiskWorkers, r.reader)
|
||||
if err != nil {
|
||||
r.logger.WarnContext(ctx, "failed to read virtual hard disks", slog.Any("err", err))
|
||||
}
|
||||
|
||||
matcher := newVirtualStorageDeviceMatcher(instances)
|
||||
for i := range results {
|
||||
results[i].device = matcher.match(results[i].chain)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.snapshot = virtualHardDiskSnapshot{
|
||||
results: results,
|
||||
readErrors: len(disks) - len(results),
|
||||
refreshed: r.now(),
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// listDisks returns the attached disks and the counter instances. A failure to
|
||||
// read the instances is logged; the disks are then published without device.
|
||||
func (r *virtualHardDiskRefresher) listDisks(ctx context.Context) ([]attachedVirtualHardDisk, []string, error) {
|
||||
r.listMu.Lock()
|
||||
defer r.listMu.Unlock()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return nil, nil, ctx.Err()
|
||||
}
|
||||
|
||||
disks, err := r.list()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
instances, err := r.instances()
|
||||
if err != nil {
|
||||
r.logger.WarnContext(ctx, "failed to read Hyper-V Virtual Storage Device instances", slog.Any("err", err))
|
||||
}
|
||||
|
||||
return disks, instances, nil
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// 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 hyperv
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus-community/windows_exporter/internal/headers/virtdisk"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func noVirtualStorageDevices() ([]string, error) { return nil, nil }
|
||||
|
||||
func noParents(string) ([]string, error) { return nil, nil }
|
||||
|
||||
func testDisks(paths ...string) []attachedVirtualHardDisk {
|
||||
disks := make([]attachedVirtualHardDisk, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
disks = append(disks, attachedVirtualHardDisk{VMID: "id", VMName: "vm", Path: path})
|
||||
}
|
||||
|
||||
return disks
|
||||
}
|
||||
|
||||
func TestVirtualHardDiskRefresherFirstRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errUnreadable := errors.New("unreadable")
|
||||
refreshed := time.Date(2026, 10, 12, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
refresher := newVirtualHardDiskRefresher(slog.New(slog.DiscardHandler), time.Hour,
|
||||
func() ([]attachedVirtualHardDisk, error) { return testDisks(`C:\a.vhdx`, `C:\b.vhdx`), nil },
|
||||
noVirtualStorageDevices,
|
||||
virtualHardDiskReader{getInfo: func(path string) (virtdisk.Info, error) {
|
||||
if path == `C:\b.vhdx` {
|
||||
return virtdisk.Info{}, errUnreadable
|
||||
}
|
||||
|
||||
return virtdisk.Info{VirtualSize: 10, PhysicalSize: 5, ProviderSubtype: virtdisk.ProviderSubtypeDynamic}, nil
|
||||
}, parentLocations: noParents},
|
||||
)
|
||||
refresher.now = func() time.Time { return refreshed }
|
||||
|
||||
require.Equal(t, virtualHardDiskSnapshot{}, refresher.get(), "no data before the first refresh")
|
||||
|
||||
refresher.start(time.Second)
|
||||
t.Cleanup(refresher.stop)
|
||||
|
||||
snapshot := refresher.get()
|
||||
require.NoError(t, snapshot.err, "per-disk errors are logged, not returned")
|
||||
require.Equal(t, refreshed, snapshot.refreshed)
|
||||
require.Equal(t, 1, snapshot.readErrors)
|
||||
require.Len(t, snapshot.results, 1)
|
||||
require.Equal(t, `C:\a.vhdx`, snapshot.results[0].disk.Path)
|
||||
require.Equal(t, uint64(10), snapshot.results[0].info.VirtualSize)
|
||||
}
|
||||
|
||||
func TestVirtualHardDiskRefresherListErrorKeepsResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
errWMI := errors.New("wmi is broken")
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
refreshes := make(chan struct{}, 10)
|
||||
done := make(chan struct{})
|
||||
|
||||
refresher := newVirtualHardDiskRefresher(slog.New(slog.DiscardHandler), 10*time.Millisecond,
|
||||
func() ([]attachedVirtualHardDisk, error) {
|
||||
defer func() { refreshes <- struct{}{} }()
|
||||
|
||||
switch calls.Add(1) {
|
||||
case 1:
|
||||
return testDisks(`C:\a.vhdx`), nil
|
||||
case 2:
|
||||
return nil, errWMI
|
||||
default:
|
||||
// Block further refreshes, so the test sees the state after the error.
|
||||
<-done
|
||||
|
||||
return nil, errWMI
|
||||
}
|
||||
},
|
||||
noVirtualStorageDevices,
|
||||
virtualHardDiskReader{getInfo: func(string) (virtdisk.Info, error) { return virtdisk.Info{VirtualSize: 10}, nil }, parentLocations: noParents},
|
||||
)
|
||||
|
||||
refresher.start(time.Second)
|
||||
t.Cleanup(func() {
|
||||
close(done)
|
||||
refresher.stop()
|
||||
})
|
||||
|
||||
<-refreshes
|
||||
|
||||
first := refresher.get()
|
||||
|
||||
<-refreshes
|
||||
require.Eventually(t, func() bool { return refresher.get().err != nil }, time.Second, time.Millisecond)
|
||||
|
||||
snapshot := refresher.get()
|
||||
require.ErrorIs(t, snapshot.err, errWMI)
|
||||
require.Len(t, snapshot.results, 1, "the last good disks stay published")
|
||||
require.Equal(t, first.refreshed, snapshot.refreshed, "the refresh time stays at the last good refresh")
|
||||
}
|
||||
|
||||
func TestVirtualHardDiskRefresherStopWaitsForList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listing := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
|
||||
var calls atomic.Int32
|
||||
|
||||
refresher := newVirtualHardDiskRefresher(slog.New(slog.DiscardHandler), time.Millisecond,
|
||||
func() ([]attachedVirtualHardDisk, error) {
|
||||
if calls.Add(1) == 2 {
|
||||
close(listing)
|
||||
<-release
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
},
|
||||
noVirtualStorageDevices,
|
||||
virtualHardDiskReader{getInfo: func(string) (virtdisk.Info, error) { return virtdisk.Info{}, nil }, parentLocations: noParents},
|
||||
)
|
||||
|
||||
refresher.start(time.Second)
|
||||
<-listing
|
||||
|
||||
var stopped atomic.Bool
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
refresher.stop()
|
||||
stopped.Store(true)
|
||||
})
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
require.False(t, stopped.Load(), "stop returned while the WMI query was still running")
|
||||
|
||||
close(release)
|
||||
wg.Wait()
|
||||
|
||||
callsAtStop := calls.Load()
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
require.Equal(t, callsAtStop, calls.Load(), "the refresher kept running after stop")
|
||||
}
|
||||
|
||||
func TestVirtualHardDiskRefresherStartIsBounded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
release := make(chan struct{})
|
||||
|
||||
refresher := newVirtualHardDiskRefresher(slog.New(slog.DiscardHandler), time.Hour,
|
||||
func() ([]attachedVirtualHardDisk, error) {
|
||||
<-release
|
||||
|
||||
return nil, nil
|
||||
},
|
||||
noVirtualStorageDevices,
|
||||
virtualHardDiskReader{getInfo: func(string) (virtdisk.Info, error) { return virtdisk.Info{}, nil }, parentLocations: noParents},
|
||||
)
|
||||
|
||||
start := time.Now()
|
||||
refresher.start(20 * time.Millisecond)
|
||||
require.Less(t, time.Since(start), time.Second, "start waited for a hanging first refresh")
|
||||
|
||||
close(release)
|
||||
refresher.stop()
|
||||
}
|
||||
|
||||
func TestVirtualHardDiskRefresherDevice(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
vhdx = `C:\CSV01\VM01\Disk(1).vhdx`
|
||||
avhd = `C:\CSV06\VM02\vm02_os_8C9A696D-1374-4DD6-B15C-B74E2CA47C29.avhd`
|
||||
avhdParent = `C:\CSV06\VM02\vm02_os.vhd`
|
||||
off = `C:\CSV09\VM09\off.vhdx`
|
||||
)
|
||||
|
||||
reader := virtualHardDiskReader{
|
||||
getInfo: func(path string) (virtdisk.Info, error) {
|
||||
if path == avhd {
|
||||
return virtdisk.Info{ProviderSubtype: virtdisk.ProviderSubtypeDifferencing}, nil
|
||||
}
|
||||
|
||||
return virtdisk.Info{ProviderSubtype: virtdisk.ProviderSubtypeDynamic}, nil
|
||||
},
|
||||
parentLocations: func(path string) ([]string, error) {
|
||||
if path == avhd {
|
||||
return []string{avhdParent}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
instances func() ([]string, error)
|
||||
want map[string]string
|
||||
}{
|
||||
"matched": {
|
||||
instances: func() ([]string, error) {
|
||||
return []string{`C:-CSV01-VM01-Disk[1].vhdx`, `C:-CSV06-VM02-vm02_os.vhd`, `--?-C:-CSV01-VM01-Virtual Machines-x.vmgs`}, nil
|
||||
},
|
||||
want: map[string]string{vhdx: `C:-CSV01-VM01-Disk[1].vhdx`, avhd: `C:-CSV06-VM02-vm02_os.vhd`, off: ""},
|
||||
},
|
||||
"counters fail": {
|
||||
instances: func() ([]string, error) { return nil, errors.New("pdh failed") },
|
||||
want: map[string]string{vhdx: "", avhd: "", off: ""},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
refresher := newVirtualHardDiskRefresher(slog.New(slog.DiscardHandler), time.Hour,
|
||||
func() ([]attachedVirtualHardDisk, error) { return testDisks(vhdx, avhd, off), nil },
|
||||
tc.instances,
|
||||
reader,
|
||||
)
|
||||
|
||||
refresher.start(time.Second)
|
||||
t.Cleanup(refresher.stop)
|
||||
|
||||
snapshot := refresher.get()
|
||||
require.NoError(t, snapshot.err, "a failed counter read must not fail the refresh")
|
||||
|
||||
got := make(map[string]string)
|
||||
for _, result := range snapshot.results {
|
||||
got[result.disk.Path] = result.device
|
||||
}
|
||||
|
||||
require.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// 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 virtdisk reads the size and type of virtual hard disks (VHD, VHDX,
|
||||
// VHD Set) through virtdisk.dll, the API behind Get-VHD.
|
||||
// https://learn.microsoft.com/en-us/windows/win32/api/virtdisk/
|
||||
package virtdisk
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var (
|
||||
modVirtdisk = windows.NewLazySystemDLL("virtdisk.dll")
|
||||
procOpenVirtualDisk = modVirtdisk.NewProc("OpenVirtualDisk")
|
||||
procGetVirtualDiskInformation = modVirtdisk.NewProc("GetVirtualDiskInformation")
|
||||
)
|
||||
|
||||
// Provider subtypes of GET_VIRTUAL_DISK_INFO_PROVIDER_SUBTYPE.
|
||||
const (
|
||||
ProviderSubtypeFixed uint32 = 2
|
||||
ProviderSubtypeDynamic uint32 = 3
|
||||
ProviderSubtypeDifferencing uint32 = 4
|
||||
)
|
||||
|
||||
const (
|
||||
virtualDiskAccessNone = 0x00000000 // VIRTUAL_DISK_ACCESS_NONE, required with version 2 parameters
|
||||
openVirtualDiskFlagNoParents = 0x00000001 // OPEN_VIRTUAL_DISK_FLAG_NO_PARENTS
|
||||
openVirtualDiskVersion2 = 2 // OPEN_VIRTUAL_DISK_VERSION_2
|
||||
|
||||
getVirtualDiskInfoSize = 1 // GET_VIRTUAL_DISK_INFO_SIZE
|
||||
getVirtualDiskInfoParentLocation = 3 // GET_VIRTUAL_DISK_INFO_PARENT_LOCATION
|
||||
getVirtualDiskInfoProviderSubtype = 7 // GET_VIRTUAL_DISK_INFO_PROVIDER_SUBTYPE
|
||||
)
|
||||
|
||||
// virtualStorageType is VIRTUAL_STORAGE_TYPE. The zero value is
|
||||
// VIRTUAL_STORAGE_TYPE_DEVICE_UNKNOWN with VIRTUAL_STORAGE_TYPE_VENDOR_UNKNOWN,
|
||||
// which lets OpenVirtualDisk detect VHD, VHDX and VHD Set files.
|
||||
type virtualStorageType struct {
|
||||
DeviceID uint32
|
||||
VendorID windows.GUID
|
||||
}
|
||||
|
||||
// openVirtualDiskParametersV2 is OPEN_VIRTUAL_DISK_PARAMETERS with
|
||||
// OPEN_VIRTUAL_DISK_VERSION_2. The union is 4-byte aligned.
|
||||
type openVirtualDiskParametersV2 struct {
|
||||
Version uint32
|
||||
GetInfoOnly int32 // BOOL
|
||||
ReadOnly int32 // BOOL
|
||||
ResiliencyGUID windows.GUID
|
||||
}
|
||||
|
||||
// getVirtualDiskInfo is GET_VIRTUAL_DISK_INFO. The union is 8-byte aligned
|
||||
// and its largest member is the 24-byte Size struct.
|
||||
type getVirtualDiskInfo struct {
|
||||
Version uint32
|
||||
_ uint32
|
||||
Union [24]byte
|
||||
}
|
||||
|
||||
// Info is the size and type of a virtual hard disk.
|
||||
type Info struct {
|
||||
// VirtualSize is the maximum size the virtual machine sees, in bytes.
|
||||
VirtualSize uint64
|
||||
// PhysicalSize is the size of the file on storage, in bytes.
|
||||
PhysicalSize uint64
|
||||
// ProviderSubtype is ProviderSubtypeFixed, ProviderSubtypeDynamic or ProviderSubtypeDifferencing.
|
||||
ProviderSubtype uint32
|
||||
}
|
||||
|
||||
// Available reports whether virtdisk.dll and the functions used by GetInfo
|
||||
// can be loaded. LazyProc.Call panics if they can't, so check this first.
|
||||
func Available() error {
|
||||
if err := procOpenVirtualDisk.Find(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return procGetVirtualDiskInformation.Find()
|
||||
}
|
||||
|
||||
// GetInfo opens the virtual hard disk at path read-only, without its parents,
|
||||
// and returns its size and provider subtype. It works on disks attached to
|
||||
// running virtual machines.
|
||||
func GetInfo(path string) (Info, error) {
|
||||
handle, err := open(path)
|
||||
if err != nil {
|
||||
return Info{}, err
|
||||
}
|
||||
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
size, err := getInformation(handle, getVirtualDiskInfoSize)
|
||||
if err != nil {
|
||||
return Info{}, fmt.Errorf("GetVirtualDiskInformation size %s: %w", path, err)
|
||||
}
|
||||
|
||||
subtype, err := getInformation(handle, getVirtualDiskInfoProviderSubtype)
|
||||
if err != nil {
|
||||
return Info{}, fmt.Errorf("GetVirtualDiskInformation provider subtype %s: %w", path, err)
|
||||
}
|
||||
|
||||
return Info{
|
||||
VirtualSize: binary.LittleEndian.Uint64(size.Union[0:8]),
|
||||
PhysicalSize: binary.LittleEndian.Uint64(size.Union[8:16]),
|
||||
ProviderSubtype: binary.LittleEndian.Uint32(subtype.Union[0:4]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParentLocations returns the paths at which the parent of the differencing
|
||||
// disk at path is stored, as recorded in the disk. Disks without a parent
|
||||
// return none.
|
||||
func ParentLocations(path string) ([]string, error) {
|
||||
handle, err := open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() { _ = windows.CloseHandle(handle) }()
|
||||
|
||||
subtype, err := getInformation(handle, getVirtualDiskInfoProviderSubtype)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetVirtualDiskInformation provider subtype %s: %w", path, err)
|
||||
}
|
||||
|
||||
if binary.LittleEndian.Uint32(subtype.Union[0:4]) != ProviderSubtypeDifferencing {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// GET_VIRTUAL_DISK_INFO with ParentLocation: the 8-byte aligned union starts
|
||||
// at offset 8 with the BOOL ParentResolved, followed by the MULTI_SZ
|
||||
// ParentLocationBuffer. A uint64 buffer keeps the alignment.
|
||||
buffer := make([]uint64, 512)
|
||||
|
||||
for range 4 {
|
||||
bytes := unsafe.Slice((*byte)(unsafe.Pointer(&buffer[0])), len(buffer)*8)
|
||||
binary.LittleEndian.PutUint32(bytes, getVirtualDiskInfoParentLocation)
|
||||
|
||||
infoSize := uint32(len(bytes))
|
||||
|
||||
ret, _, _ := procGetVirtualDiskInformation.Call(
|
||||
uintptr(handle),
|
||||
uintptr(unsafe.Pointer(&infoSize)),
|
||||
uintptr(unsafe.Pointer(&buffer[0])),
|
||||
0, // SizeUsed is optional
|
||||
)
|
||||
|
||||
switch windows.Errno(ret) {
|
||||
case 0:
|
||||
if infoSize < 12 || int(infoSize) > len(bytes) {
|
||||
return nil, fmt.Errorf("GetVirtualDiskInformation parent location %s: invalid size %d", path, infoSize)
|
||||
}
|
||||
|
||||
chars := unsafe.Slice((*uint16)(unsafe.Pointer(&bytes[12])), (int(infoSize)-12)/2)
|
||||
|
||||
return decodeMultiString(chars), nil
|
||||
case windows.ERROR_INSUFFICIENT_BUFFER, windows.ERROR_MORE_DATA:
|
||||
if int(infoSize) <= len(bytes) {
|
||||
return nil, fmt.Errorf("GetVirtualDiskInformation parent location %s: %w", path, windows.Errno(ret))
|
||||
}
|
||||
|
||||
buffer = make([]uint64, (int(infoSize)+7)/8)
|
||||
default:
|
||||
return nil, fmt.Errorf("GetVirtualDiskInformation parent location %s: %w", path, windows.Errno(ret))
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("GetVirtualDiskInformation parent location %s: buffer did not stabilize", path)
|
||||
}
|
||||
|
||||
// decodeMultiString splits a MULTI_SZ into its strings.
|
||||
func decodeMultiString(chars []uint16) []string {
|
||||
var out []string
|
||||
|
||||
for len(chars) > 0 {
|
||||
end := 0
|
||||
for end < len(chars) && chars[end] != 0 {
|
||||
end++
|
||||
}
|
||||
|
||||
if end == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
out = append(out, windows.UTF16ToString(chars[:end]))
|
||||
|
||||
if end == len(chars) {
|
||||
break
|
||||
}
|
||||
|
||||
chars = chars[end+1:]
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// open opens the virtual hard disk at path for reading its information only.
|
||||
// GetInfoOnly also opens a disk that is in use by a running virtual machine.
|
||||
func open(path string) (windows.Handle, error) {
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("virtual disk %s: %w", path, err)
|
||||
}
|
||||
|
||||
var (
|
||||
storageType virtualStorageType
|
||||
handle windows.Handle
|
||||
)
|
||||
|
||||
params := openVirtualDiskParametersV2{Version: openVirtualDiskVersion2, GetInfoOnly: 1}
|
||||
|
||||
ret, _, _ := procOpenVirtualDisk.Call(
|
||||
uintptr(unsafe.Pointer(&storageType)),
|
||||
uintptr(unsafe.Pointer(pathPtr)),
|
||||
virtualDiskAccessNone,
|
||||
openVirtualDiskFlagNoParents,
|
||||
uintptr(unsafe.Pointer(¶ms)),
|
||||
uintptr(unsafe.Pointer(&handle)),
|
||||
)
|
||||
if ret != 0 {
|
||||
return 0, fmt.Errorf("OpenVirtualDisk %s: %w", path, windows.Errno(ret))
|
||||
}
|
||||
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func getInformation(handle windows.Handle, version uint32) (getVirtualDiskInfo, error) {
|
||||
info := getVirtualDiskInfo{Version: version}
|
||||
infoSize := uint32(unsafe.Sizeof(info))
|
||||
|
||||
ret, _, _ := procGetVirtualDiskInformation.Call(
|
||||
uintptr(handle),
|
||||
uintptr(unsafe.Pointer(&infoSize)),
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
0, // SizeUsed is optional
|
||||
)
|
||||
if ret != 0 {
|
||||
return getVirtualDiskInfo{}, windows.Errno(ret)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// 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 virtdisk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals
|
||||
var (
|
||||
procCreateVirtualDisk = modVirtdisk.NewProc("CreateVirtualDisk")
|
||||
|
||||
virtualStorageTypeVendorMicrosoft = windows.GUID{
|
||||
Data1: 0xEC984AEC, Data2: 0xA0F9, Data3: 0x47E9,
|
||||
Data4: [8]byte{0x90, 0x1F, 0x71, 0x41, 0x5A, 0x66, 0x34, 0x5B},
|
||||
}
|
||||
)
|
||||
|
||||
// createVirtualDiskParametersV2 is CREATE_VIRTUAL_DISK_PARAMETERS with
|
||||
// CREATE_VIRTUAL_DISK_VERSION_2, which VHDX requires. The union starts at offset 8.
|
||||
type createVirtualDiskParametersV2 struct {
|
||||
Version uint32
|
||||
_ uint32
|
||||
UniqueID windows.GUID
|
||||
MaximumSize uint64
|
||||
BlockSizeInBytes uint32
|
||||
SectorSizeInBytes uint32
|
||||
PhysicalSectorSizeInBytes uint32
|
||||
_ uint32
|
||||
ParentPath *uint16
|
||||
SourcePath *uint16
|
||||
OpenFlags uint32
|
||||
ParentVirtualStorageType virtualStorageType
|
||||
SourceVirtualStorageType virtualStorageType
|
||||
ResiliencyGUID windows.GUID
|
||||
}
|
||||
|
||||
const (
|
||||
createVirtualDiskVersion2 = 2
|
||||
createVirtualDiskFlagFullPhysicalAlloc = 0x1
|
||||
virtualStorageTypeDeviceVHDX = 3
|
||||
)
|
||||
|
||||
// createVHDX creates a VHDX for the test and closes it. Creating a virtual
|
||||
// disk needs administrator rights; the test is skipped without them.
|
||||
func createVHDX(t *testing.T, path string, size uint64, fixed bool) {
|
||||
t.Helper()
|
||||
|
||||
createVirtualDisk(t, path, createVirtualDiskParametersV2{Version: createVirtualDiskVersion2, MaximumSize: size}, fixed)
|
||||
}
|
||||
|
||||
// createDifferencingVHDX creates a differencing VHDX with parent as its parent.
|
||||
func createDifferencingVHDX(t *testing.T, path, parent string) {
|
||||
t.Helper()
|
||||
|
||||
parentPtr, err := windows.UTF16PtrFromString(parent)
|
||||
require.NoError(t, err)
|
||||
|
||||
createVirtualDisk(t, path, createVirtualDiskParametersV2{Version: createVirtualDiskVersion2, ParentPath: parentPtr}, false)
|
||||
}
|
||||
|
||||
func createVirtualDisk(t *testing.T, path string, params createVirtualDiskParametersV2, fixed bool) {
|
||||
t.Helper()
|
||||
|
||||
pathPtr, err := windows.UTF16PtrFromString(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
storageType := virtualStorageType{DeviceID: virtualStorageTypeDeviceVHDX, VendorID: virtualStorageTypeVendorMicrosoft}
|
||||
|
||||
var flags uintptr
|
||||
if fixed {
|
||||
flags = createVirtualDiskFlagFullPhysicalAlloc
|
||||
}
|
||||
|
||||
var handle windows.Handle
|
||||
|
||||
ret, _, _ := procCreateVirtualDisk.Call(
|
||||
uintptr(unsafe.Pointer(&storageType)),
|
||||
uintptr(unsafe.Pointer(pathPtr)),
|
||||
virtualDiskAccessNone,
|
||||
0, // security descriptor
|
||||
flags,
|
||||
0, // provider specific flags
|
||||
uintptr(unsafe.Pointer(¶ms)),
|
||||
0, // overlapped
|
||||
uintptr(unsafe.Pointer(&handle)),
|
||||
)
|
||||
if errors.Is(windows.Errno(ret), windows.ERROR_ACCESS_DENIED) || errors.Is(windows.Errno(ret), windows.ERROR_PRIVILEGE_NOT_HELD) {
|
||||
t.Skipf("creating a virtual disk needs administrator rights: %v", windows.Errno(ret))
|
||||
}
|
||||
|
||||
require.Zero(t, ret, "CreateVirtualDisk: %v", windows.Errno(ret))
|
||||
require.NoError(t, windows.CloseHandle(handle))
|
||||
}
|
||||
|
||||
func TestAvailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.NoError(t, Available())
|
||||
}
|
||||
|
||||
func TestGetInfo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const size = 64 << 20 // 64 MiB
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
fixed bool
|
||||
subtype uint32
|
||||
}{
|
||||
"fixed": {true, ProviderSubtypeFixed},
|
||||
"dynamic": {false, ProviderSubtypeDynamic},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), name+".vhdx")
|
||||
createVHDX(t, path, size, tc.fixed)
|
||||
|
||||
info, err := GetInfo(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(size), info.VirtualSize)
|
||||
require.Equal(t, tc.subtype, info.ProviderSubtype)
|
||||
|
||||
if tc.fixed {
|
||||
require.GreaterOrEqual(t, info.PhysicalSize, uint64(size))
|
||||
} else {
|
||||
require.Less(t, info.PhysicalSize, uint64(size))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInfoMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "missing.vhdx")
|
||||
|
||||
_, err := GetInfo(path)
|
||||
require.ErrorContains(t, err, path)
|
||||
}
|
||||
|
||||
func TestParentLocations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
parent := filepath.Join(dir, "parent.vhdx")
|
||||
child := filepath.Join(dir, "child.avhdx")
|
||||
|
||||
createVHDX(t, parent, 64<<20, false)
|
||||
createDifferencingVHDX(t, child, parent)
|
||||
|
||||
info, err := GetInfo(child)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ProviderSubtypeDifferencing, info.ProviderSubtype)
|
||||
|
||||
locations, err := ParentLocations(child)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compare files, not strings: the temp dir can be a short 8.3 path
|
||||
// (RUNNER~1) while the disk records the long one.
|
||||
parentInfo, err := os.Stat(parent)
|
||||
require.NoError(t, err)
|
||||
require.Condition(t, func() bool {
|
||||
for _, location := range locations {
|
||||
if info, err := os.Stat(location); err == nil && os.SameFile(info, parentInfo) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}, "parent %s not in %v", parent, locations)
|
||||
|
||||
// A disk without a parent has no parent locations.
|
||||
locations, err = ParentLocations(parent)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, locations)
|
||||
}
|
||||
|
||||
func TestDecodeMultiString(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
in []uint16
|
||||
want []string
|
||||
}{
|
||||
"two": {append(append(windows.StringToUTF16(`..\parent.vhdx`), windows.StringToUTF16(`C:\vm\parent.vhdx`)...), 0), []string{`..\parent.vhdx`, `C:\vm\parent.vhdx`}},
|
||||
"one": {windows.StringToUTF16(`C:\vm\parent.vhd`), []string{`C:\vm\parent.vhd`}},
|
||||
"empty": {[]uint16{0, 0}, nil},
|
||||
"unterminated": {[]uint16{'a', 'b'}, []string{"ab"}},
|
||||
"nil": {nil, nil},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tc.want, decodeMultiString(tc.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,18 @@ type win32DiskDriveWrongType struct {
|
||||
Capabilities []string `mi:"Capabilities"`
|
||||
}
|
||||
|
||||
// win32OperatingSystem is used to exercise STRINGA (string[]) unmarshalling.
|
||||
// Win32_OperatingSystem.MUILanguages is populated on every host.
|
||||
type win32OperatingSystem struct {
|
||||
MUILanguages []string `mi:"MUILanguages"`
|
||||
}
|
||||
|
||||
// win32OperatingSystemWrongType maps the string[] MUILanguages property onto
|
||||
// an incompatible Go type to exercise the STRINGA type guard.
|
||||
type win32OperatingSystemWrongType struct {
|
||||
MUILanguages []uint16 `mi:"MUILanguages"`
|
||||
}
|
||||
|
||||
func Test_MI_Application_Initialize(t *testing.T) {
|
||||
application, err := mi.ApplicationInitialize()
|
||||
require.NoError(t, err)
|
||||
@@ -1164,3 +1176,31 @@ func Test_MI_GetElementByName(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"System"}, names)
|
||||
}
|
||||
|
||||
// Test_MI_QueryUnmarshal_StringArray unmarshals a STRINGA property into a
|
||||
// []string struct field.
|
||||
func Test_MI_QueryUnmarshal_StringArray(t *testing.T) {
|
||||
application, err := mi.ApplicationInitialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
session, err := application.NewSession(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
query, err := mi.NewQuery("SELECT MUILanguages FROM Win32_OperatingSystem")
|
||||
require.NoError(t, err)
|
||||
|
||||
var systems []win32OperatingSystem
|
||||
|
||||
require.NoError(t, session.Query(&systems, mi.NamespaceRootCIMv2, query, -1))
|
||||
require.Len(t, systems, 1)
|
||||
require.NotEmpty(t, systems[0].MUILanguages)
|
||||
require.NotEmpty(t, systems[0].MUILanguages[0])
|
||||
|
||||
var wrong []win32OperatingSystemWrongType
|
||||
|
||||
// Unmarshalling a string[] into a []uint16 field must error, not panic.
|
||||
require.Error(t, session.Query(&wrong, mi.NamespaceRootCIMv2, query, -1))
|
||||
|
||||
require.NoError(t, session.Close())
|
||||
require.NoError(t, application.Close())
|
||||
}
|
||||
|
||||
@@ -303,6 +303,12 @@ func setField(miTag string, field reflect.Value, element *Element) error {
|
||||
}
|
||||
|
||||
field.Set(reflect.ValueOf(element.getUint16Array()))
|
||||
case ValueTypeSTRINGA:
|
||||
if field.Type() != reflect.TypeFor[[]string]() {
|
||||
return fmt.Errorf("cannot unmarshal STRINGA into field of type %s, expected []string", field.Type())
|
||||
}
|
||||
|
||||
field.Set(reflect.ValueOf(element.getStringArray()))
|
||||
default:
|
||||
return fmt.Errorf("unsupported value type: %d", element.valueType)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user