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:
Dominik Eisenberg
2026-10-12 20:23:46 +02:00
committed by GitHub
co-authored by EisenbergD Claude Opus 5.5
parent 89c5df9d8b
commit ec7dcdc259
15 changed files with 1990 additions and 11 deletions
+28 -1
View File
@@ -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,
+103
View File
@@ -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)
})
}
}