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
+14
View File
@@ -609,6 +609,20 @@ jobs:
throw "Expected Hyper-V VM to be running, got $($vm.State)"
}
# A second VM, never started, for the virtual_hard_disk sub-collector:
# a dynamic disk with a checkpoint (the VM then runs on a differencing
# .avhdx) and a fixed disk attached after the checkpoint.
$vhdDir = Join-Path $env:RUNNER_TEMP "hyperv-ci-vhd"
New-Item -ItemType Directory -Force -Path $vhdDir | Out-Null
New-VM -Name "CIVirtualHardDisk" -Generation 2 -MemoryStartupBytes 64MB -NoVHD -Path $vhdDir | Out-Null
Set-VM -Name "CIVirtualHardDisk" -CheckpointType Standard
New-VHD -Path (Join-Path $vhdDir "dynamic.vhdx") -Dynamic -SizeBytes 64MB | Out-Null
Add-VMHardDiskDrive -VMName "CIVirtualHardDisk" -Path (Join-Path $vhdDir "dynamic.vhdx")
Checkpoint-VM -Name "CIVirtualHardDisk" -SnapshotName "ci"
New-VHD -Path (Join-Path $vhdDir "fixed.vhdx") -Fixed -SizeBytes 8MB | Out-Null
Add-VMHardDiskDrive -VMName "CIVirtualHardDisk" -Path (Join-Path $vhdDir "fixed.vhdx")
Get-VMHardDiskDrive -VMName "CIVirtualHardDisk" | Get-VHD | Format-Table Path, VhdType, Size, FileSize -AutoSize
- name: Download container prerequisites
id: download-container-prereqs
background: true
+4 -4
View File
@@ -282,10 +282,10 @@ When the configuration of a service can't be read, `windows_service_start_mode`
## New default sub-collectors
| Collector | New sub-collectors enabled by default |
|-------------|-------------------------------------------------|
| `hyperv` | `host`, `replica_vm`, `wmi_health` |
| `mscluster` | `shared_volumes` |
| Collector | New sub-collectors enabled by default |
|-------------|---------------------------------------------------------|
| `hyperv` | `host`, `replica_vm`, `virtual_hard_disk`, `wmi_health` |
| `mscluster` | `shared_volumes` |
These add new metrics. To keep the 0.31 set, list the sub-collectors explicitly with `--collector.hyperv.enabled` or `--collector.mscluster.enabled`.
+6
View File
@@ -24,6 +24,12 @@ online Generic Service that depends on an online IP Address, plus an offline IP
Address. The `CIFailedResources` group contains a Generic Service whose binary is
missing, so it stays failed. Native and WMI parity tests require these resources.
Hyper-V runs the `GitHubActions` VM with a 1 GB dynamic VHDX and a legacy
network adapter. A second VM, `CIVirtualHardDisk`, is never started: it has a
dynamic disk with a checkpoint, so it runs on a differencing `.avhdx`, and a
fixed disk attached after the checkpoint. The `virtual_hard_disk` tests require
each of its disks to be reported exactly once.
`WINDOWS_EXPORTER_TEST_COLLECTORS` lists required collectors. Their tests fail
when setup is missing or collection fails instead of skipping. Fixture assertions
check known instances, including the active RDP user's session and SQL database.
+71 -6
View File
@@ -2,17 +2,21 @@
The hyperv collector exposes metrics about the Hyper-V hypervisor
| | |
|---------------------|----------------------|
| Metric name prefix | `hyperv` |
| Source | Performance counters |
| Enabled by default? | No |
| | |
|---------------------|------------------------------------------------------------------------------|
| Metric name prefix | `hyperv` |
| Source | Performance counters, Hyper-V WMI (`root/virtualization/v2`), `virtdisk.dll` |
| Enabled by default? | No |
## Flags
### `--collector.hyperv.enabled`
Comma-separated list of collectors to use, for example:
`--collector.hyperv.enabled=datastore,dynamic_memory_balancer,dynamic_memory_vm,host,hypervisor_logical_processor,hypervisor_root_partition,hypervisor_root_virtual_processor,hypervisor_virtual_processor,legacy_network_adapter,replica_vm,virtual_machine_health_summary,virtual_machine_vid_partition,virtual_network_adapter,virtual_network_adapter_drop_reasons,virtual_smb,virtual_storage_device,virtual_switch,wmi_health`
`--collector.hyperv.enabled=datastore,dynamic_memory_balancer,dynamic_memory_vm,host,hypervisor_logical_processor,hypervisor_root_partition,hypervisor_root_virtual_processor,hypervisor_virtual_processor,legacy_network_adapter,replica_vm,virtual_hard_disk,virtual_machine_health_summary,virtual_machine_vid_partition,virtual_network_adapter,virtual_network_adapter_drop_reasons,virtual_smb,virtual_storage_device,virtual_switch,wmi_health`
### `--collector.hyperv.virtual-hard-disk.refresh-interval`
Interval at which the `virtual_hard_disk` sub-collector reads the disks in the background, for example `10m`.
Scrapes publish the values of the last refresh. Default: `5m`.
## Metrics
@@ -403,6 +407,67 @@ Example alert:
```
### Hyper-V Virtual Hard Disk
Sub-collector `virtual_hard_disk`, enabled by default. It reports the size and provisioning type of every
virtual hard disk (VHD, VHDX, checkpoint AVHDX) attached to a virtual machine on this host.
The disks are listed with two WQL queries against the `root/virtualization/v2` namespace
(`Msvm_ComputerSystem` and `Msvm_StorageAllocationSettingData`); the sizes are read with `virtdisk.dll`,
the API behind `Get-VHD`. On a failover cluster, each node reports the virtual machines it currently owns.
The disks are read in the background every `--collector.hyperv.virtual-hard-disk.refresh-interval` (default `5m`),
so a scrape only publishes the last values and a slow or unreachable disk doesn't block the `hyperv` collector.
The values can be up to one interval old. A disk that can't be read is logged and counted in
`windows_hyperv_virtual_hard_disk_read_errors`; only a failed disk listing fails the collector.
| Name | Description | Type | Labels |
|-------------------------------------------------------------------|----------------------------------------------------------------------------------|-------|-------------------------------|
| `windows_hyperv_virtual_hard_disk_size_bytes` | Maximum size of the virtual hard disk, as seen by the VM | gauge | vm, vm_id, path, type, device |
| `windows_hyperv_virtual_hard_disk_file_size_bytes` | Size of the virtual hard disk file on storage | gauge | vm, vm_id, path, type, device |
| `windows_hyperv_virtual_hard_disk_read_errors` | Number of attached virtual hard disks that could not be read in the last refresh | gauge | None |
| `windows_hyperv_virtual_hard_disk_last_refresh_timestamp_seconds` | Time of the last successful refresh | gauge | None |
`type` is `fixed` (thick provisioned), `dynamic` (thin provisioned), `differencing` (checkpoint or child disk)
or `unknown`.
For a VM with checkpoints, `path` is the current `.avhdx` and `file_size_bytes` only counts that file.
The parent disks aren't reported, so summing `file_size_bytes` per VM undercounts its storage.
`device` is the instance name of the disk in the `Hyper-V Virtual Storage Device` counter set, the same value as the
`device` label of `windows_hyperv_virtual_storage_device_*`. It joins the I/O metrics with the virtual machine.
The right-hand side is turned into `1` first, so the join only adds the `vm` label and keeps the values:
```promql
rate(windows_hyperv_virtual_storage_device_io_latency_seconds_total[5m])
* on (instance, device) group_left (vm)
(0 * windows_hyperv_virtual_hard_disk_size_bytes{device!=""} + 1)
```
The instance names are read from the counter set on every refresh and matched to `path` (Hyper-V replaces `\` with
`-` and `(…)` with `[…]`). `device` is empty when the disk has no instance, which is the case for virtual machines that
aren't running. The instance of a VHD-format checkpoint (`.avhd`) is named after the base disk of its chain; `device` is
then that name. VHDX checkpoints (`.avhdx`) have their own instance.
Space promised to thin provisioned disks but not yet allocated:
```promql
sum by (instance) (
windows_hyperv_virtual_hard_disk_size_bytes{type="dynamic"}
- windows_hyperv_virtual_hard_disk_file_size_bytes{type="dynamic"}
)
```
Disks that could not be read, and stale data:
```yaml
- alert: HyperVVirtualHardDiskReadErrors
expr: windows_hyperv_virtual_hard_disk_read_errors > 0
for: 30m
- alert: HyperVVirtualHardDiskStale
expr: time() - windows_hyperv_virtual_hard_disk_last_refresh_timestamp_seconds > 3 * 300
```
### Hyper-V Virtual Machine Health Summary
| Name | Description | Type | Labels |
+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)
})
}
}
+260
View File
@@ -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(&params)),
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
}
+221
View File
@@ -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(&params)),
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))
})
}
}
+40
View File
@@ -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())
}
+6
View File
@@ -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)
}