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)" 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 - name: Download container prerequisites
id: download-container-prereqs id: download-container-prereqs
background: true 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 ## New default sub-collectors
| Collector | New sub-collectors enabled by default | | Collector | New sub-collectors enabled by default |
|-------------|-------------------------------------------------| |-------------|---------------------------------------------------------|
| `hyperv` | `host`, `replica_vm`, `wmi_health` | | `hyperv` | `host`, `replica_vm`, `virtual_hard_disk`, `wmi_health` |
| `mscluster` | `shared_volumes` | | `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`. 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 Address. The `CIFailedResources` group contains a Generic Service whose binary is
missing, so it stays failed. Native and WMI parity tests require these resources. 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 `WINDOWS_EXPORTER_TEST_COLLECTORS` lists required collectors. Their tests fail
when setup is missing or collection fails instead of skipping. Fixture assertions 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. 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 The hyperv collector exposes metrics about the Hyper-V hypervisor
| | | | | |
|---------------------|----------------------| |---------------------|------------------------------------------------------------------------------|
| Metric name prefix | `hyperv` | | Metric name prefix | `hyperv` |
| Source | Performance counters | | Source | Performance counters, Hyper-V WMI (`root/virtualization/v2`), `virtdisk.dll` |
| Enabled by default? | No | | Enabled by default? | No |
## Flags ## Flags
### `--collector.hyperv.enabled` ### `--collector.hyperv.enabled`
Comma-separated list of collectors to use, for example: 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 ## 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 ### Hyper-V Virtual Machine Health Summary
| Name | Description | Type | Labels | | Name | Description | Type | Labels |
+28 -1
View File
@@ -47,6 +47,7 @@ const (
subCollectorHypervisorVirtualProcessor = "hypervisor_virtual_processor" subCollectorHypervisorVirtualProcessor = "hypervisor_virtual_processor"
subCollectorLegacyNetworkAdapter = "legacy_network_adapter" subCollectorLegacyNetworkAdapter = "legacy_network_adapter"
subCollectorReplicaVM = "replica_vm" subCollectorReplicaVM = "replica_vm"
subCollectorVirtualHardDisk = "virtual_hard_disk"
subCollectorVirtualMachineHealthSummary = "virtual_machine_health_summary" subCollectorVirtualMachineHealthSummary = "virtual_machine_health_summary"
subCollectorVirtualMachineVidPartition = "virtual_machine_vid_partition" subCollectorVirtualMachineVidPartition = "virtual_machine_vid_partition"
subCollectorVirtualNetworkAdapter = "virtual_network_adapter" subCollectorVirtualNetworkAdapter = "virtual_network_adapter"
@@ -58,7 +59,14 @@ const (
) )
type Config struct { 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 //nolint:gochecknoglobals
@@ -74,6 +82,7 @@ var ConfigDefaults = Config{
subCollectorHypervisorVirtualProcessor, subCollectorHypervisorVirtualProcessor,
subCollectorLegacyNetworkAdapter, subCollectorLegacyNetworkAdapter,
subCollectorReplicaVM, subCollectorReplicaVM,
subCollectorVirtualHardDisk,
subCollectorVirtualMachineHealthSummary, subCollectorVirtualMachineHealthSummary,
subCollectorVirtualMachineVidPartition, subCollectorVirtualMachineVidPartition,
subCollectorVirtualNetworkAdapter, subCollectorVirtualNetworkAdapter,
@@ -83,6 +92,9 @@ var ConfigDefaults = Config{
subCollectorVirtualSwitch, subCollectorVirtualSwitch,
subCollectorWMIHealth, subCollectorWMIHealth,
}, },
VirtualHardDisk: VirtualHardDiskConfig{
RefreshInterval: 5 * time.Minute,
},
} }
// Collector is a Prometheus Collector for hyper-v. // Collector is a Prometheus Collector for hyper-v.
@@ -97,6 +109,7 @@ type Collector struct {
collectorHypervisorVirtualProcessor collectorHypervisorVirtualProcessor
collectorLegacyNetworkAdapter collectorLegacyNetworkAdapter
collectorReplicaVM collectorReplicaVM
collectorVirtualHardDisk
collectorVirtualMachineHealthSummary collectorVirtualMachineHealthSummary
collectorVirtualMachineVidPartition collectorVirtualMachineVidPartition
collectorVirtualNetworkAdapter collectorVirtualNetworkAdapter
@@ -129,6 +142,10 @@ func New(config *Config) *Collector {
config.CollectorsEnabled = ConfigDefaults.CollectorsEnabled config.CollectorsEnabled = ConfigDefaults.CollectorsEnabled
} }
if config.VirtualHardDisk.RefreshInterval == 0 {
config.VirtualHardDisk.RefreshInterval = ConfigDefaults.VirtualHardDisk.RefreshInterval
}
c := &Collector{ c := &Collector{
config: *config, config: *config,
} }
@@ -149,6 +166,11 @@ func NewWithFlags(app *kingpin.Application) *Collector {
"Comma-separated list of collectors to use.", "Comma-separated list of collectors to use.",
).Default(strings.Join(ConfigDefaults.CollectorsEnabled, ",")).StringVar(&collectorsEnabled) ).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 { app.Action(func(*kingpin.ParseContext) error {
c.config.CollectorsEnabled = strings.Split(collectorsEnabled, ",") 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 the collector created by build, not the nil one bound at this point.
close: func() { c.perfDataCollectorReplicaVM.Close() }, close: func() { c.perfDataCollectorReplicaVM.Close() },
}, },
subCollectorVirtualHardDisk: {
build: c.buildVirtualHardDisk,
collect: c.collectVirtualHardDisk,
close: c.closeVirtualHardDisk,
},
subCollectorVirtualMachineHealthSummary: { subCollectorVirtualMachineHealthSummary: {
build: c.buildVirtualMachineHealthSummary, build: c.buildVirtualMachineHealthSummary,
collect: c.collectVirtualMachineHealthSummary, collect: c.collectVirtualMachineHealthSummary,
+103
View File
@@ -19,6 +19,7 @@ package hyperv_test
import ( import (
"log/slog" "log/slog"
"strings"
"testing" "testing"
"time" "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. // TestCloseReleasesQuery ensures Close releases the PDH queries of the sub-collectors.
func TestCloseReleasesQuery(t *testing.T) { func TestCloseReleasesQuery(t *testing.T) {
t.Parallel() 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"` 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) { func Test_MI_Application_Initialize(t *testing.T) {
application, err := mi.ApplicationInitialize() application, err := mi.ApplicationInitialize()
require.NoError(t, err) require.NoError(t, err)
@@ -1164,3 +1176,31 @@ func Test_MI_GetElementByName(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, []string{"System"}, names) 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())) 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: default:
return fmt.Errorf("unsupported value type: %d", element.valueType) return fmt.Errorf("unsupported value type: %d", element.valueType)
} }