Modules/Private/Get-S2DMaintenanceReserveAssessment.ps1
|
# Get-S2DMaintenanceReserveAssessment — N+1/N+2 COMPUTE resiliency context note. # # IMPORTANT: No #Requires, Set-StrictMode, or $ErrorActionPreference at file scope. # Private helpers are dot-sourced into the module scope during Import-Module. # Setting StrictMode or $ErrorActionPreference here bleeds into the module scope # and breaks empty-data tests that deliberately pass null/empty inputs. # # ASSESSMENT ONLY — this function NEVER modifies cluster state. # # FRAMING (WAF-grounded, verified against Microsoft Azure Local baseline): # N+1/N+2 maintenance reserve is a COMPUTE resiliency concept. # Microsoft WAF defines it as: reserve N+1 (or N+2) physical machines worth of # CPU + RAM capacity so that a node can be DRAINED for updates (its VMs migrate # to remaining nodes) or lost without dropping VMs. This is listed SEPARATELY # from storage in the baseline and labeled "compute resiliency." # # Cartographer audits STORAGE; it has no VM compute-allocation data. It cannot # perform a real compute drain assessment. This function provides INFORMATIONAL # CONTEXT ONLY: it surfaces the raw capacity of the largest node so an # administrator can reason about compute headroom. It does NOT produce a # storage pass/fail verdict. # # The firm STORAGE reserves documented by Microsoft are: # (a) Per-drive rebuild reserve: min(NodeCount, 4) × largest capacity drive. # (b) Keep 5–10% of pool unallocated (leave volume footprints within pool). # These are handled by Check 1 (ReserveAdequacy) in Get-S2DHealthStatus. # # MATH (unchanged — kept for context value): # nodeRawBytes = capacity-disk SizeBytes for the LARGEST node # (pool-member Role='Capacity' disks, grouped by NodeName, # take the node whose total is highest) # contextBytes = targetMultiplier × nodeRawBytes # # Inputs are accepted as plain scalars (bytes) for maximum testability without # requiring live CIM or a real waterfall object. function Get-S2DMaintenanceReserveAssessment { <# .SYNOPSIS Returns informational compute-resiliency context for N+1/N+2 node headroom. .DESCRIPTION Computes the raw capacity of the largest node as context for the operator. N+1/N+2 is a COMPUTE resiliency target (reserve one/two nodes' worth of CPU + RAM so a node can be drained for updates or survive a node loss without dropping VMs). It is NOT a storage-pool capacity requirement. Microsoft WAF scopes N+1/N+2 to compute resiliency and lists it separately from storage. The storage-pool reserves are the per-drive rebuild reserve (min(NodeCount,4) x largest drive) and keeping volume footprints within the pool — both handled by the ReserveAdequacy health check. Because Cartographer audits storage and has no VM compute-allocation data, this function surfaces the largest-node raw capacity as an INFORMATIONAL reference only. Status is always 'Info' (not a storage pass/fail). Two-node clusters receive an informational note: two-way mirror keeps a full copy on each node, so a drained node does not lose data availability. This is an ASSESSMENT only — it reports against measured live state and does not modify cluster configuration in any way. .PARAMETER PoolFreeBytes Pool RemainingSize in bytes (from S2DStoragePool.RemainingSize.Bytes). Retained for API compatibility; not used in the compliance verdict. .PARAMETER RebuildReserveRecommendedBytes The recommended rebuild reserve in bytes (from S2DCapacityWaterfall.ReserveRecommended.Bytes). Retained for API compatibility; not used in the compliance verdict. .PARAMETER PhysicalDisks Array of physical disk objects. Must include NodeName, Role, and SizeBytes properties. Only pool-member capacity disks (Role='Capacity', IsPoolMember -ne $false) are used to compute the largest-node context value. .PARAMETER NodeCount Number of nodes in the cluster (used for the two-node note). .PARAMETER Target Compute resiliency target: 'N+1' (default), 'N+2', or 'None' (assessment skipped). .OUTPUTS PSCustomObject with: Target, LargestNodeCapacity [S2DCapacity], ContextCapacity [S2DCapacity], Status [string] ('Info' or 'Unknown'), Note [string] Returns $null-safe 'Unknown' result if inputs are missing or invalid. The legacy Meets and AvailableHeadroom/RequiredCapacity fields are retained for JSON schema continuity; Meets is always $null and AvailableHeadroom is always $null in the informational framing. #> [CmdletBinding()] param( [Parameter()] [int64] $PoolFreeBytes = [int64]0, [Parameter()] [int64] $RebuildReserveRecommendedBytes = [int64]0, [Parameter()] [AllowNull()] [AllowEmptyCollection()] [object[]] $PhysicalDisks = @(), [Parameter()] [int] $NodeCount = 0, [Parameter()] [ValidateSet('None', 'N+1', 'N+2')] [string] $Target = 'N+1' ) # ── Helper: build an unknown/graceful result ────────────────────────────── function local:New-UnknownResult { param([string]$Reason) [PSCustomObject]@{ Target = $Target LargestNodeCapacity = $null ContextCapacity = $null # Legacy fields retained for JSON schema compatibility RequiredCapacity = $null AvailableHeadroom = $null Meets = $null Status = 'Unknown' Note = $Reason } } # ── None target: skip assessment ───────────────────────────────────────── if ($Target -eq 'None') { return [PSCustomObject]@{ Target = 'None' LargestNodeCapacity = [S2DCapacity]::new([int64]0) ContextCapacity = [S2DCapacity]::new([int64]0) RequiredCapacity = [S2DCapacity]::new([int64]0) AvailableHeadroom = $null Meets = $null Status = 'Info' Note = 'Compute maintenance reserve assessment is disabled (Target=None).' } } # ── Guard: empty disk list ──────────────────────────────────────────────── if (-not $PhysicalDisks -or $PhysicalDisks.Count -eq 0) { return New-UnknownResult 'No physical disk data available — cannot determine largest-node capacity.' } # ── Determine largest-node raw bytes ───────────────────────────────────── # Scope: pool-member capacity disks only (Role='Capacity', IsPoolMember -ne $false). # Pre-1.2.0 fixtures may lack IsPoolMember — treat absence as pool member. $capacityDisks = @($PhysicalDisks | Where-Object { $_.Role -eq 'Capacity' -and $_.IsPoolMember -ne $false }) if ($capacityDisks.Count -eq 0) { return New-UnknownResult 'No pool-member capacity disks found — cannot determine largest-node capacity.' } $byNode = @($capacityDisks | Group-Object NodeName) if ($byNode.Count -eq 0) { return New-UnknownResult 'Could not group capacity disks by node — NodeName property may be missing.' } # Sum SizeBytes per node, then take the maximum. $nodeRawBytes = [int64]0 foreach ($nodeGroup in $byNode) { $nodeTotalRaw = [int64]( ($nodeGroup.Group | ForEach-Object { if ($null -ne $_.SizeBytes) { [int64]$_.SizeBytes } else { [int64]0 } } | Measure-Object -Sum).Sum ) if ($nodeTotalRaw -gt $nodeRawBytes) { $nodeRawBytes = $nodeTotalRaw } } if ($nodeRawBytes -le 0) { return New-UnknownResult 'Largest-node raw capacity resolved to zero — disk SizeBytes may be missing.' } # ── Compute context values ──────────────────────────────────────────────── $multiplier = switch ($Target) { 'N+1' { [int64]1 } 'N+2' { [int64]2 } default { [int64]0 } } $contextBytes = $multiplier * $nodeRawBytes # ── Informational note (compute framing) ────────────────────────────────── $twoNodeNote = if ($NodeCount -eq 2) { " Two-node cluster: two-way mirror keeps a full copy on each node, so a drained node does not lose data availability." } else { '' } $note = "N+1/N+2 is a COMPUTE resiliency target (reserve CPU + RAM so a node can be drained for updates or lost without dropping VMs) — not a storage-pool reserve. The largest node holds approximately $([math]::Round($nodeRawBytes / 1TB, 2)) TB raw storage capacity. The firm storage reserves are the per-drive rebuild reserve and keeping volume footprints within the pool.$twoNodeNote" [PSCustomObject]@{ Target = $Target LargestNodeCapacity = [S2DCapacity]::new($nodeRawBytes) ContextCapacity = [S2DCapacity]::new($contextBytes) # Legacy fields retained for JSON schema compatibility — not a storage verdict RequiredCapacity = [S2DCapacity]::new($contextBytes) AvailableHeadroom = $null Meets = $null Status = 'Info' Note = $note } } |