Functions/Private/Get-StorageSnapshot.ps1
|
function Get-StorageSnapshot { <# .SYNOPSIS Collects a point-in-time storage snapshot from a Failover Cluster: space and I/O metrics for every Cluster Shared Volume, plus VHD placement for every running VM. .DESCRIPTION Space metrics (TotalGB, FreeGB, UsedGB, SpaceUsedPct) are always collected from Get-ClusterSharedVolume. I/O metrics (ReadIOPS, WriteIOPS, LatencyMs) are collected on a best-effort basis using performance counters: • IOPS via \Cluster Disk Counters(*)\Disk Reads/sec and Disk Writes/sec queried from the first available cluster node. • Latency via \LogicalDisk(*)\Avg. Disk sec/Transfer queried on each CSV's owner node. CSVs may not appear as LogicalDisk instances on all Windows versions; failures are logged as verbose and the fields stay null. When I/O metrics are unavailable, Measure-CsvHappiness automatically normalises to a space-only score. .OUTPUTS PSCustomObject: ClusterName — string Timestamp — DateTime CSVs — array of CSV metric objects (Name, Path, OwnerNode, TotalGB, FreeGB, UsedGB, SpaceUsedPct, ReadIOPS, WriteIOPS, LatencyMs) VMs — array of VM storage objects (VMName, VMId, HostNode, PrimaryCSV [path], TotalVhdGB, VHDs) #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $ClusterName, [int] $SampleCount = 3, [int] $SampleIntervalSeconds = 5 ) # ── Cluster Shared Volumes ───────────────────────────────────────────────── Write-Verbose "[StorageSnapshot] Enumerating CSVs on '$ClusterName'..." $clusterCsvs = Get-ClusterSharedVolume -Cluster $ClusterName -ErrorAction Stop $upNodes = @(Get-ClusterNode -Cluster $ClusterName | Where-Object { $_.State -eq 'Up' } | Select-Object -ExpandProperty Name) if (-not $clusterCsvs -or $clusterCsvs.Count -eq 0) { Write-Warning "No Cluster Shared Volumes found on '$ClusterName'." return [PSCustomObject]@{ ClusterName=$ClusterName; Timestamp=Get-Date; CSVs=@(); VMs=@() } } # Build CSV list with space metrics (always available) $csvList = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($csv in $clusterCsvs) { $part = $csv.SharedVolumeInfo.Partition $totalGB = [Math]::Round($part.Size / 1GB, 2) $freeGB = [Math]::Round($part.FreeSpace / 1GB, 2) $usedGB = [Math]::Round($totalGB - $freeGB, 2) $csvList.Add([PSCustomObject]@{ Name = $csv.Name Path = $csv.SharedVolumeInfo.FriendlyVolumeName OwnerNode = if ($csv.OwnerNode) { $csv.OwnerNode.Name } else { $null } TotalGB = $totalGB FreeGB = $freeGB UsedGB = $usedGB SpaceUsedPct = if ($totalGB -gt 0) { [Math]::Round(($usedGB / $totalGB) * 100.0, 1) } else { 0.0 } ReadIOPS = $null WriteIOPS = $null LatencyMs = $null }) } # ── I/O counters — IOPS via Cluster Disk Counters ───────────────────────── if ($SampleCount -gt 0 -and $upNodes.Count -gt 0) { $coordinator = $upNodes[0] Write-Verbose "[StorageSnapshot] Collecting IOPS counters via '$coordinator'..." try { $instances = (Get-Counter -ComputerName $coordinator ` -ListSet 'Cluster Disk Counters' ` -ErrorAction Stop).PathsWithInstances | Where-Object { $_ -match 'Disk Reads/sec' } foreach ($csv in $csvList) { $match = $instances | Where-Object { $_ -like "*($($csv.Name))*" } | Select-Object -First 1 if (-not $match) { continue } $inst = [regex]::Match($match, '\((.+?)\)').Groups[1].Value $samples = Get-Counter -ComputerName $coordinator -Counter @( "\Cluster Disk Counters($inst)\Disk Reads/sec" "\Cluster Disk Counters($inst)\Disk Writes/sec" ) -SampleInterval $SampleIntervalSeconds -MaxSamples $SampleCount -ErrorAction Stop $all = $samples.CounterSamples $csv.ReadIOPS = [Math]::Round(($all | Where-Object { $_.Path -match 'Reads' } | Measure-Object CookedValue -Average).Average, 1) $csv.WriteIOPS = [Math]::Round(($all | Where-Object { $_.Path -match 'Writes' } | Measure-Object CookedValue -Average).Average, 1) Write-Verbose "[StorageSnapshot] $($csv.Name): $($csv.ReadIOPS) read IOPS, $($csv.WriteIOPS) write IOPS" } } catch { Write-Verbose "[StorageSnapshot] Cluster Disk Counters unavailable: $_" } # Latency via LogicalDisk on each CSV's owner node (best-effort) foreach ($csv in $csvList) { if (-not $csv.OwnerNode) { continue } Write-Verbose "[StorageSnapshot] Collecting latency for '$($csv.Name)' on '$($csv.OwnerNode)'..." try { $latMs = Invoke-Command -ComputerName $csv.OwnerNode -ErrorAction Stop -ScriptBlock { param($csvPath, $cnt, $interval) $paths = (Get-Counter -ListSet 'LogicalDisk').PathsWithInstances | Where-Object { $_ -match 'Avg\. Disk sec/Transfer' } $leafName = Split-Path -Leaf $csvPath $line = $paths | Where-Object { $_ -like "*$leafName*" -or $_ -like "*$csvPath*" } | Select-Object -First 1 if (-not $line) { return $null } $inst = [regex]::Match($line, '\((.+?)\)').Groups[1].Value $raw = Get-Counter "\LogicalDisk($inst)\Avg. Disk sec/Transfer" ` -SampleInterval $interval -MaxSamples $cnt -ErrorAction Stop [Math]::Round(($raw.CounterSamples | Measure-Object CookedValue -Average).Average * 1000, 2) } -ArgumentList $csv.Path, $SampleCount, $SampleIntervalSeconds if ($null -ne $latMs) { $csv.LatencyMs = $latMs Write-Verbose "[StorageSnapshot] $($csv.Name): $($csv.LatencyMs) ms latency" } } catch { Write-Verbose "[StorageSnapshot] LogicalDisk latency unavailable for '$($csv.Name)': $_" } } } # ── VM storage placement ─────────────────────────────────────────────────── Write-Verbose "[StorageSnapshot] Enumerating running VM storage across $($upNodes.Count) node(s)..." $vmStorage = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($node in $upNodes) { $vms = try { Get-VM -ComputerName $node -ErrorAction Stop | Where-Object { $_.State -eq 'Running' } } catch { Write-Warning "[StorageSnapshot] Could not enumerate VMs on '$node': $_" continue } foreach ($vm in $vms) { try { $drives = Get-VMHardDiskDrive -VMName $vm.Name -ComputerName $node -ErrorAction Stop $vhdDetails = [System.Collections.Generic.List[PSCustomObject]]::new() $totalVhdGB = 0.0 foreach ($drive in $drives) { $sizeGB = try { [Math]::Round((Get-VHD -Path $drive.Path -ComputerName $node -ErrorAction Stop).Size / 1GB, 2) } catch { 0.0 } $csvOwner = $csvList | Where-Object { $drive.Path -like "$($_.Path)*" } | Select-Object -First 1 $vhdDetails.Add([PSCustomObject]@{ Path = $drive.Path SizeGB = $sizeGB CSV = $csvOwner.Path }) $totalVhdGB += $sizeGB } # Primary CSV = the CSV hosting the most VHD data for this VM $primaryCSV = $vhdDetails | Where-Object { $_.CSV } | Group-Object CSV | Sort-Object { ($_.Group | Measure-Object SizeGB -Sum).Sum } -Descending | Select-Object -First 1 -ExpandProperty Name if (-not $primaryCSV) { continue } # no VHDs on any CSV (pass-through, physical) $vmStorage.Add([PSCustomObject]@{ VMName = $vm.Name VMId = $vm.Id.ToString() HostNode = $node PrimaryCSV = $primaryCSV TotalVhdGB = [Math]::Round($totalVhdGB, 2) VHDs = $vhdDetails.ToArray() }) } catch { Write-Warning "[StorageSnapshot] Could not collect storage info for '$($vm.Name)': $_" } } } Write-Verbose "[StorageSnapshot] Done: $($csvList.Count) CSV(s), $($vmStorage.Count) VM(s) with CSV storage." [PSCustomObject]@{ ClusterName = $ClusterName Timestamp = Get-Date CSVs = $csvList.ToArray() VMs = $vmStorage.ToArray() } } |