From ec7dcdc2593a0a7726e020eed8b2efa476a6cd95 Mon Sep 17 00:00:00 2001 From: Dominik Eisenberg <64131471+Dominik-esb@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:23:46 +0200 Subject: [PATCH] hyperv: add virtual_hard_disk sub-collector (#2693) Signed-off-by: EisenbergD Co-authored-by: EisenbergD Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 14 + UPGRADE.md | 8 +- docs/ci-testing.md | 6 + docs/collector.hyperv.md | 77 +++- internal/collector/hyperv/hyperv.go | 29 +- internal/collector/hyperv/hyperv_test.go | 103 +++++ .../hyperv/hyperv_virtual_hard_disk.go | 370 ++++++++++++++++++ .../hyperv/hyperv_virtual_hard_disk_device.go | 148 +++++++ .../hyperv_virtual_hard_disk_internal_test.go | 266 +++++++++++++ .../hyperv_virtual_hard_disk_refresh.go | 193 +++++++++ ...virtual_hard_disk_refresh_internal_test.go | 260 ++++++++++++ internal/headers/virtdisk/virtdisk.go | 260 ++++++++++++ internal/headers/virtdisk/virtdisk_test.go | 221 +++++++++++ internal/mi/mi_test.go | 40 ++ internal/mi/session.go | 6 + 15 files changed, 1990 insertions(+), 11 deletions(-) create mode 100644 internal/collector/hyperv/hyperv_virtual_hard_disk.go create mode 100644 internal/collector/hyperv/hyperv_virtual_hard_disk_device.go create mode 100644 internal/collector/hyperv/hyperv_virtual_hard_disk_internal_test.go create mode 100644 internal/collector/hyperv/hyperv_virtual_hard_disk_refresh.go create mode 100644 internal/collector/hyperv/hyperv_virtual_hard_disk_refresh_internal_test.go create mode 100644 internal/headers/virtdisk/virtdisk.go create mode 100644 internal/headers/virtdisk/virtdisk_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b197d42..b7f4e1e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/UPGRADE.md b/UPGRADE.md index a32ae37e..3163f91f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -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`. diff --git a/docs/ci-testing.md b/docs/ci-testing.md index 47849e7a..745f45b9 100644 --- a/docs/ci-testing.md +++ b/docs/ci-testing.md @@ -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. diff --git a/docs/collector.hyperv.md b/docs/collector.hyperv.md index c3698620..0e5d1ea6 100644 --- a/docs/collector.hyperv.md +++ b/docs/collector.hyperv.md @@ -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 | diff --git a/internal/collector/hyperv/hyperv.go b/internal/collector/hyperv/hyperv.go index ea71261a..114d98d2 100644 --- a/internal/collector/hyperv/hyperv.go +++ b/internal/collector/hyperv/hyperv.go @@ -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, diff --git a/internal/collector/hyperv/hyperv_test.go b/internal/collector/hyperv/hyperv_test.go index a280a4ce..254994ca 100644 --- a/internal/collector/hyperv/hyperv_test.go +++ b/internal/collector/hyperv/hyperv_test.go @@ -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() diff --git a/internal/collector/hyperv/hyperv_virtual_hard_disk.go b/internal/collector/hyperv/hyperv_virtual_hard_disk.go new file mode 100644 index 00000000..f9b6521b --- /dev/null +++ b/internal/collector/hyperv/hyperv_virtual_hard_disk.go @@ -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:\...". 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" + } +} diff --git a/internal/collector/hyperv/hyperv_virtual_hard_disk_device.go b/internal/collector/hyperv/hyperv_virtual_hard_disk_device.go new file mode 100644 index 00000000..e7fb3bfa --- /dev/null +++ b/internal/collector/hyperv/hyperv_virtual_hard_disk_device.go @@ -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 +} diff --git a/internal/collector/hyperv/hyperv_virtual_hard_disk_internal_test.go b/internal/collector/hyperv/hyperv_virtual_hard_disk_internal_test.go new file mode 100644 index 00000000..ed71e6db --- /dev/null +++ b/internal/collector/hyperv/hyperv_virtual_hard_disk_internal_test.go @@ -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)) +} diff --git a/internal/collector/hyperv/hyperv_virtual_hard_disk_refresh.go b/internal/collector/hyperv/hyperv_virtual_hard_disk_refresh.go new file mode 100644 index 00000000..74cb1cfe --- /dev/null +++ b/internal/collector/hyperv/hyperv_virtual_hard_disk_refresh.go @@ -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 +} diff --git a/internal/collector/hyperv/hyperv_virtual_hard_disk_refresh_internal_test.go b/internal/collector/hyperv/hyperv_virtual_hard_disk_refresh_internal_test.go new file mode 100644 index 00000000..45805f50 --- /dev/null +++ b/internal/collector/hyperv/hyperv_virtual_hard_disk_refresh_internal_test.go @@ -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) + }) + } +} diff --git a/internal/headers/virtdisk/virtdisk.go b/internal/headers/virtdisk/virtdisk.go new file mode 100644 index 00000000..b6f712a9 --- /dev/null +++ b/internal/headers/virtdisk/virtdisk.go @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +// Package virtdisk reads the size and type of virtual hard disks (VHD, VHDX, +// VHD Set) through virtdisk.dll, the API behind Get-VHD. +// https://learn.microsoft.com/en-us/windows/win32/api/virtdisk/ +package virtdisk + +import ( + "encoding/binary" + "fmt" + "unsafe" + + "golang.org/x/sys/windows" +) + +//nolint:gochecknoglobals +var ( + modVirtdisk = windows.NewLazySystemDLL("virtdisk.dll") + procOpenVirtualDisk = modVirtdisk.NewProc("OpenVirtualDisk") + procGetVirtualDiskInformation = modVirtdisk.NewProc("GetVirtualDiskInformation") +) + +// Provider subtypes of GET_VIRTUAL_DISK_INFO_PROVIDER_SUBTYPE. +const ( + ProviderSubtypeFixed uint32 = 2 + ProviderSubtypeDynamic uint32 = 3 + ProviderSubtypeDifferencing uint32 = 4 +) + +const ( + virtualDiskAccessNone = 0x00000000 // VIRTUAL_DISK_ACCESS_NONE, required with version 2 parameters + openVirtualDiskFlagNoParents = 0x00000001 // OPEN_VIRTUAL_DISK_FLAG_NO_PARENTS + openVirtualDiskVersion2 = 2 // OPEN_VIRTUAL_DISK_VERSION_2 + + getVirtualDiskInfoSize = 1 // GET_VIRTUAL_DISK_INFO_SIZE + getVirtualDiskInfoParentLocation = 3 // GET_VIRTUAL_DISK_INFO_PARENT_LOCATION + getVirtualDiskInfoProviderSubtype = 7 // GET_VIRTUAL_DISK_INFO_PROVIDER_SUBTYPE +) + +// virtualStorageType is VIRTUAL_STORAGE_TYPE. The zero value is +// VIRTUAL_STORAGE_TYPE_DEVICE_UNKNOWN with VIRTUAL_STORAGE_TYPE_VENDOR_UNKNOWN, +// which lets OpenVirtualDisk detect VHD, VHDX and VHD Set files. +type virtualStorageType struct { + DeviceID uint32 + VendorID windows.GUID +} + +// openVirtualDiskParametersV2 is OPEN_VIRTUAL_DISK_PARAMETERS with +// OPEN_VIRTUAL_DISK_VERSION_2. The union is 4-byte aligned. +type openVirtualDiskParametersV2 struct { + Version uint32 + GetInfoOnly int32 // BOOL + ReadOnly int32 // BOOL + ResiliencyGUID windows.GUID +} + +// getVirtualDiskInfo is GET_VIRTUAL_DISK_INFO. The union is 8-byte aligned +// and its largest member is the 24-byte Size struct. +type getVirtualDiskInfo struct { + Version uint32 + _ uint32 + Union [24]byte +} + +// Info is the size and type of a virtual hard disk. +type Info struct { + // VirtualSize is the maximum size the virtual machine sees, in bytes. + VirtualSize uint64 + // PhysicalSize is the size of the file on storage, in bytes. + PhysicalSize uint64 + // ProviderSubtype is ProviderSubtypeFixed, ProviderSubtypeDynamic or ProviderSubtypeDifferencing. + ProviderSubtype uint32 +} + +// Available reports whether virtdisk.dll and the functions used by GetInfo +// can be loaded. LazyProc.Call panics if they can't, so check this first. +func Available() error { + if err := procOpenVirtualDisk.Find(); err != nil { + return err + } + + return procGetVirtualDiskInformation.Find() +} + +// GetInfo opens the virtual hard disk at path read-only, without its parents, +// and returns its size and provider subtype. It works on disks attached to +// running virtual machines. +func GetInfo(path string) (Info, error) { + handle, err := open(path) + if err != nil { + return Info{}, err + } + + defer func() { _ = windows.CloseHandle(handle) }() + + size, err := getInformation(handle, getVirtualDiskInfoSize) + if err != nil { + return Info{}, fmt.Errorf("GetVirtualDiskInformation size %s: %w", path, err) + } + + subtype, err := getInformation(handle, getVirtualDiskInfoProviderSubtype) + if err != nil { + return Info{}, fmt.Errorf("GetVirtualDiskInformation provider subtype %s: %w", path, err) + } + + return Info{ + VirtualSize: binary.LittleEndian.Uint64(size.Union[0:8]), + PhysicalSize: binary.LittleEndian.Uint64(size.Union[8:16]), + ProviderSubtype: binary.LittleEndian.Uint32(subtype.Union[0:4]), + }, nil +} + +// ParentLocations returns the paths at which the parent of the differencing +// disk at path is stored, as recorded in the disk. Disks without a parent +// return none. +func ParentLocations(path string) ([]string, error) { + handle, err := open(path) + if err != nil { + return nil, err + } + + defer func() { _ = windows.CloseHandle(handle) }() + + subtype, err := getInformation(handle, getVirtualDiskInfoProviderSubtype) + if err != nil { + return nil, fmt.Errorf("GetVirtualDiskInformation provider subtype %s: %w", path, err) + } + + if binary.LittleEndian.Uint32(subtype.Union[0:4]) != ProviderSubtypeDifferencing { + return nil, nil + } + + // GET_VIRTUAL_DISK_INFO with ParentLocation: the 8-byte aligned union starts + // at offset 8 with the BOOL ParentResolved, followed by the MULTI_SZ + // ParentLocationBuffer. A uint64 buffer keeps the alignment. + buffer := make([]uint64, 512) + + for range 4 { + bytes := unsafe.Slice((*byte)(unsafe.Pointer(&buffer[0])), len(buffer)*8) + binary.LittleEndian.PutUint32(bytes, getVirtualDiskInfoParentLocation) + + infoSize := uint32(len(bytes)) + + ret, _, _ := procGetVirtualDiskInformation.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&infoSize)), + uintptr(unsafe.Pointer(&buffer[0])), + 0, // SizeUsed is optional + ) + + switch windows.Errno(ret) { + case 0: + if infoSize < 12 || int(infoSize) > len(bytes) { + return nil, fmt.Errorf("GetVirtualDiskInformation parent location %s: invalid size %d", path, infoSize) + } + + chars := unsafe.Slice((*uint16)(unsafe.Pointer(&bytes[12])), (int(infoSize)-12)/2) + + return decodeMultiString(chars), nil + case windows.ERROR_INSUFFICIENT_BUFFER, windows.ERROR_MORE_DATA: + if int(infoSize) <= len(bytes) { + return nil, fmt.Errorf("GetVirtualDiskInformation parent location %s: %w", path, windows.Errno(ret)) + } + + buffer = make([]uint64, (int(infoSize)+7)/8) + default: + return nil, fmt.Errorf("GetVirtualDiskInformation parent location %s: %w", path, windows.Errno(ret)) + } + } + + return nil, fmt.Errorf("GetVirtualDiskInformation parent location %s: buffer did not stabilize", path) +} + +// decodeMultiString splits a MULTI_SZ into its strings. +func decodeMultiString(chars []uint16) []string { + var out []string + + for len(chars) > 0 { + end := 0 + for end < len(chars) && chars[end] != 0 { + end++ + } + + if end == 0 { + break + } + + out = append(out, windows.UTF16ToString(chars[:end])) + + if end == len(chars) { + break + } + + chars = chars[end+1:] + } + + return out +} + +// open opens the virtual hard disk at path for reading its information only. +// GetInfoOnly also opens a disk that is in use by a running virtual machine. +func open(path string) (windows.Handle, error) { + pathPtr, err := windows.UTF16PtrFromString(path) + if err != nil { + return 0, fmt.Errorf("virtual disk %s: %w", path, err) + } + + var ( + storageType virtualStorageType + handle windows.Handle + ) + + params := openVirtualDiskParametersV2{Version: openVirtualDiskVersion2, GetInfoOnly: 1} + + ret, _, _ := procOpenVirtualDisk.Call( + uintptr(unsafe.Pointer(&storageType)), + uintptr(unsafe.Pointer(pathPtr)), + virtualDiskAccessNone, + openVirtualDiskFlagNoParents, + uintptr(unsafe.Pointer(¶ms)), + uintptr(unsafe.Pointer(&handle)), + ) + if ret != 0 { + return 0, fmt.Errorf("OpenVirtualDisk %s: %w", path, windows.Errno(ret)) + } + + return handle, nil +} + +func getInformation(handle windows.Handle, version uint32) (getVirtualDiskInfo, error) { + info := getVirtualDiskInfo{Version: version} + infoSize := uint32(unsafe.Sizeof(info)) + + ret, _, _ := procGetVirtualDiskInformation.Call( + uintptr(handle), + uintptr(unsafe.Pointer(&infoSize)), + uintptr(unsafe.Pointer(&info)), + 0, // SizeUsed is optional + ) + if ret != 0 { + return getVirtualDiskInfo{}, windows.Errno(ret) + } + + return info, nil +} diff --git a/internal/headers/virtdisk/virtdisk_test.go b/internal/headers/virtdisk/virtdisk_test.go new file mode 100644 index 00000000..0bf92fbb --- /dev/null +++ b/internal/headers/virtdisk/virtdisk_test.go @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package virtdisk + +import ( + "errors" + "os" + "path/filepath" + "testing" + "unsafe" + + "github.com/stretchr/testify/require" + "golang.org/x/sys/windows" +) + +//nolint:gochecknoglobals +var ( + procCreateVirtualDisk = modVirtdisk.NewProc("CreateVirtualDisk") + + virtualStorageTypeVendorMicrosoft = windows.GUID{ + Data1: 0xEC984AEC, Data2: 0xA0F9, Data3: 0x47E9, + Data4: [8]byte{0x90, 0x1F, 0x71, 0x41, 0x5A, 0x66, 0x34, 0x5B}, + } +) + +// createVirtualDiskParametersV2 is CREATE_VIRTUAL_DISK_PARAMETERS with +// CREATE_VIRTUAL_DISK_VERSION_2, which VHDX requires. The union starts at offset 8. +type createVirtualDiskParametersV2 struct { + Version uint32 + _ uint32 + UniqueID windows.GUID + MaximumSize uint64 + BlockSizeInBytes uint32 + SectorSizeInBytes uint32 + PhysicalSectorSizeInBytes uint32 + _ uint32 + ParentPath *uint16 + SourcePath *uint16 + OpenFlags uint32 + ParentVirtualStorageType virtualStorageType + SourceVirtualStorageType virtualStorageType + ResiliencyGUID windows.GUID +} + +const ( + createVirtualDiskVersion2 = 2 + createVirtualDiskFlagFullPhysicalAlloc = 0x1 + virtualStorageTypeDeviceVHDX = 3 +) + +// createVHDX creates a VHDX for the test and closes it. Creating a virtual +// disk needs administrator rights; the test is skipped without them. +func createVHDX(t *testing.T, path string, size uint64, fixed bool) { + t.Helper() + + createVirtualDisk(t, path, createVirtualDiskParametersV2{Version: createVirtualDiskVersion2, MaximumSize: size}, fixed) +} + +// createDifferencingVHDX creates a differencing VHDX with parent as its parent. +func createDifferencingVHDX(t *testing.T, path, parent string) { + t.Helper() + + parentPtr, err := windows.UTF16PtrFromString(parent) + require.NoError(t, err) + + createVirtualDisk(t, path, createVirtualDiskParametersV2{Version: createVirtualDiskVersion2, ParentPath: parentPtr}, false) +} + +func createVirtualDisk(t *testing.T, path string, params createVirtualDiskParametersV2, fixed bool) { + t.Helper() + + pathPtr, err := windows.UTF16PtrFromString(path) + require.NoError(t, err) + + storageType := virtualStorageType{DeviceID: virtualStorageTypeDeviceVHDX, VendorID: virtualStorageTypeVendorMicrosoft} + + var flags uintptr + if fixed { + flags = createVirtualDiskFlagFullPhysicalAlloc + } + + var handle windows.Handle + + ret, _, _ := procCreateVirtualDisk.Call( + uintptr(unsafe.Pointer(&storageType)), + uintptr(unsafe.Pointer(pathPtr)), + virtualDiskAccessNone, + 0, // security descriptor + flags, + 0, // provider specific flags + uintptr(unsafe.Pointer(¶ms)), + 0, // overlapped + uintptr(unsafe.Pointer(&handle)), + ) + if errors.Is(windows.Errno(ret), windows.ERROR_ACCESS_DENIED) || errors.Is(windows.Errno(ret), windows.ERROR_PRIVILEGE_NOT_HELD) { + t.Skipf("creating a virtual disk needs administrator rights: %v", windows.Errno(ret)) + } + + require.Zero(t, ret, "CreateVirtualDisk: %v", windows.Errno(ret)) + require.NoError(t, windows.CloseHandle(handle)) +} + +func TestAvailable(t *testing.T) { + t.Parallel() + + require.NoError(t, Available()) +} + +func TestGetInfo(t *testing.T) { + t.Parallel() + + const size = 64 << 20 // 64 MiB + + for name, tc := range map[string]struct { + fixed bool + subtype uint32 + }{ + "fixed": {true, ProviderSubtypeFixed}, + "dynamic": {false, ProviderSubtypeDynamic}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), name+".vhdx") + createVHDX(t, path, size, tc.fixed) + + info, err := GetInfo(path) + require.NoError(t, err) + require.Equal(t, uint64(size), info.VirtualSize) + require.Equal(t, tc.subtype, info.ProviderSubtype) + + if tc.fixed { + require.GreaterOrEqual(t, info.PhysicalSize, uint64(size)) + } else { + require.Less(t, info.PhysicalSize, uint64(size)) + } + }) + } +} + +func TestGetInfoMissingFile(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "missing.vhdx") + + _, err := GetInfo(path) + require.ErrorContains(t, err, path) +} + +func TestParentLocations(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + parent := filepath.Join(dir, "parent.vhdx") + child := filepath.Join(dir, "child.avhdx") + + createVHDX(t, parent, 64<<20, false) + createDifferencingVHDX(t, child, parent) + + info, err := GetInfo(child) + require.NoError(t, err) + require.Equal(t, ProviderSubtypeDifferencing, info.ProviderSubtype) + + locations, err := ParentLocations(child) + require.NoError(t, err) + + // Compare files, not strings: the temp dir can be a short 8.3 path + // (RUNNER~1) while the disk records the long one. + parentInfo, err := os.Stat(parent) + require.NoError(t, err) + require.Condition(t, func() bool { + for _, location := range locations { + if info, err := os.Stat(location); err == nil && os.SameFile(info, parentInfo) { + return true + } + } + + return false + }, "parent %s not in %v", parent, locations) + + // A disk without a parent has no parent locations. + locations, err = ParentLocations(parent) + require.NoError(t, err) + require.Empty(t, locations) +} + +func TestDecodeMultiString(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + in []uint16 + want []string + }{ + "two": {append(append(windows.StringToUTF16(`..\parent.vhdx`), windows.StringToUTF16(`C:\vm\parent.vhdx`)...), 0), []string{`..\parent.vhdx`, `C:\vm\parent.vhdx`}}, + "one": {windows.StringToUTF16(`C:\vm\parent.vhd`), []string{`C:\vm\parent.vhd`}}, + "empty": {[]uint16{0, 0}, nil}, + "unterminated": {[]uint16{'a', 'b'}, []string{"ab"}}, + "nil": {nil, nil}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.want, decodeMultiString(tc.in)) + }) + } +} diff --git a/internal/mi/mi_test.go b/internal/mi/mi_test.go index 60270c0c..a6013d3b 100644 --- a/internal/mi/mi_test.go +++ b/internal/mi/mi_test.go @@ -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()) +} diff --git a/internal/mi/session.go b/internal/mi/session.go index 5b4e11d7..0846dacc 100644 --- a/internal/mi/session.go +++ b/internal/mi/session.go @@ -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) }