Modules/Analyzers/10-CapacityAndEfficiency.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS v2.5.0 analyzer pass — capacity headroom (#128), idle / underutilized VM detection (#125), storage efficiency (#126), and SQL / Windows Server license inventory (#127). Runs after all collectors so it can reason over the complete manifest. #> function Invoke-RangerCapacityAnalysis { <# .SYNOPSIS v2.5.0 (#128): compute cluster capacity headroom and runway projection. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest, [double]$WarnUtilizationPct = 80, [double]$FailUtilizationPct = 90 ) $nodes = @($Manifest.domains.clusterNode.nodes) $vms = @($Manifest.domains.virtualMachines.inventory) $volumes = @($Manifest.domains.storage.volumes) $pools = @($Manifest.domains.storage.pools) $totalLogicalCores = 0 $totalMemoryGiB = 0.0 foreach ($n in $nodes) { if ($n.logicalProcessorCount) { $totalLogicalCores += [int]$n.logicalProcessorCount } if ($n.totalMemoryGiB) { $totalMemoryGiB += [double]$n.totalMemoryGiB } } $allocatedVcpu = 0 $allocatedMemMb = 0 foreach ($vm in $vms) { if ($vm.processorCount) { $allocatedVcpu += [int]$vm.processorCount } if ($vm.memoryAssignedMb) { $allocatedMemMb += [long]$vm.memoryAssignedMb } } $allocatedMemGiB = [Math]::Round($allocatedMemMb / 1024.0, 1) # vCPU oversubscription is normal; headroom tracked against logical cores for visibility only. $vcpuPct = if ($totalLogicalCores -gt 0) { [Math]::Round(($allocatedVcpu / $totalLogicalCores) * 100, 1) } else { 0 } $memPct = if ($totalMemoryGiB -gt 0) { [Math]::Round(($allocatedMemGiB / $totalMemoryGiB) * 100, 1) } else { 0 } # Storage headroom — per-volume used/free plus pool-level allocation $totalVolGiB = 0.0; $freeVolGiB = 0.0 foreach ($v in $volumes) { if ($v.sizeGB) { $totalVolGiB += [double]$v.sizeGB } if ($v.freeSpaceGB) { $freeVolGiB += [double]$v.freeSpaceGB } } $usedVolGiB = [Math]::Round($totalVolGiB - $freeVolGiB, 1) $storagePct = if ($totalVolGiB -gt 0) { [Math]::Round(($usedVolGiB / $totalVolGiB) * 100, 1) } else { 0 } $poolCapacityGiB = 0.0; $poolAllocatedGiB = 0.0 foreach ($p in $pools) { if ($p.size) { $poolCapacityGiB += [double]$p.size / 1GB } if ($p.allocatedSize) { $poolAllocatedGiB += [double]$p.allocatedSize / 1GB } } $poolPct = if ($poolCapacityGiB -gt 0) { [Math]::Round(($poolAllocatedGiB / $poolCapacityGiB) * 100, 1) } else { 0 } $statusFor = { param($pct) if ($pct -ge $FailUtilizationPct) { 'Critical' } elseif ($pct -ge $WarnUtilizationPct) { 'Warning' } else { 'Healthy' } } # Runway — if growth trend is unknown (no historical data), we surface current state only; # a `projectedRunwayMonths` field is written as $null so consumers can fill in from trend data. return [ordered]@{ summary = [ordered]@{ nodeCount = $nodes.Count vmCount = $vms.Count totalLogicalCores = $totalLogicalCores allocatedVcpu = $allocatedVcpu vcpuUtilizationPct = $vcpuPct vcpuStatus = & $statusFor $vcpuPct totalMemoryGiB = [Math]::Round($totalMemoryGiB, 1) allocatedMemoryGiB = $allocatedMemGiB memoryUtilizationPct = $memPct memoryStatus = & $statusFor $memPct totalStorageGiB = [Math]::Round($totalVolGiB, 1) usedStorageGiB = $usedVolGiB storageUtilizationPct = $storagePct storageStatus = & $statusFor $storagePct poolCapacityGiB = [Math]::Round($poolCapacityGiB, 1) poolAllocatedGiB = [Math]::Round($poolAllocatedGiB, 1) poolUtilizationPct = $poolPct poolStatus = & $statusFor $poolPct projectedRunwayMonths = $null thresholds = @{ warn = $WarnUtilizationPct; fail = $FailUtilizationPct } } perNode = @( foreach ($n in $nodes) { $nodeVms = @($vms | Where-Object { $_.hostNode -eq $n.name }) $nodeVcpu = ($nodeVms | Measure-Object -Property processorCount -Sum).Sum $nodeMemMb = ($nodeVms | Measure-Object -Property memoryAssignedMb -Sum).Sum [ordered]@{ node = $n.name logicalCores = [int]$n.logicalProcessorCount allocatedVcpu = [int]$nodeVcpu vcpuUtilizationPct = if ($n.logicalProcessorCount) { [Math]::Round(($nodeVcpu / [double]$n.logicalProcessorCount) * 100, 1) } else { 0 } memoryGiB = [double]$n.totalMemoryGiB allocatedMemoryGiB = [Math]::Round($nodeMemMb / 1024.0, 1) memoryUtilizationPct = if ($n.totalMemoryGiB) { [Math]::Round((($nodeMemMb / 1024.0) / [double]$n.totalMemoryGiB) * 100, 1) } else { 0 } vmCount = $nodeVms.Count } } ) } } function Invoke-RangerVmUtilizationAnalysis { <# .SYNOPSIS v2.5.0 (#125): detect idle / underutilized VMs and produce rightsizing hints. .DESCRIPTION Consumes `vm.utilization` sidecar data when present (average/peak CPU and memory %) and falls back to allocation-only heuristics when utilization counters are absent. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest, [double]$IdleCpuPct = 5, [double]$UnderCpuPct = 20, [double]$UnderMemPct = 30 ) $vms = @($Manifest.domains.virtualMachines.inventory) $classified = New-Object System.Collections.ArrayList $idleCount = 0; $underCount = 0; $rightsizedVcpu = 0; $rightsizedMemMb = 0 foreach ($vm in $vms) { $util = $vm.utilization $avgCpu = if ($util -and $null -ne $util.avgCpuPct) { [double]$util.avgCpuPct } else { $null } $peakCpu = if ($util -and $null -ne $util.peakCpuPct) { [double]$util.peakCpuPct } else { $null } $avgMem = if ($util -and $null -ne $util.avgMemoryPct) { [double]$util.avgMemoryPct } else { $null } $runState = [string]$vm.state $classification = 'unknown' $recommendation = $null $proposedVcpu = [int]$vm.processorCount $proposedMemMb = [int]$vm.memoryAssignedMb if ($runState -ne 'Running') { $classification = 'stopped' $recommendation = 'VM is not running — consider decommission or archive if idle > 30 days.' } elseif ($null -ne $avgCpu -and $null -ne $peakCpu) { if ($peakCpu -le $IdleCpuPct) { $classification = 'idle' $recommendation = "Idle (peak CPU ${peakCpu}%). Stop or consolidate." $idleCount++ } elseif ($avgCpu -le $UnderCpuPct -and ($null -ne $avgMem -and $avgMem -le $UnderMemPct)) { $classification = 'underutilized' $proposedVcpu = [Math]::Max(1, [int][Math]::Ceiling($vm.processorCount * 0.5)) $proposedMemMb = [int]([Math]::Ceiling(($vm.memoryAssignedMb * 0.5) / 512) * 512) $rightsizedVcpu += ($vm.processorCount - $proposedVcpu) $rightsizedMemMb += ($vm.memoryAssignedMb - $proposedMemMb) $recommendation = "Rightsize to $proposedVcpu vCPU / $([Math]::Round($proposedMemMb/1024,1)) GiB (avg CPU ${avgCpu}%, avg mem ${avgMem}%)." $underCount++ } else { $classification = 'healthy' } } else { $classification = 'no-counters' $recommendation = 'No utilization counters available — enable performance collection to classify.' } [void]$classified.Add([ordered]@{ name = $vm.name hostNode = $vm.hostNode state = $runState processorCount = [int]$vm.processorCount memoryAssignedMb = [int]$vm.memoryAssignedMb avgCpuPct = $avgCpu peakCpuPct = $peakCpu avgMemoryPct = $avgMem classification = $classification proposedVcpu = $proposedVcpu proposedMemoryMb = $proposedMemMb recommendation = $recommendation }) } return [ordered]@{ summary = [ordered]@{ vmCount = $vms.Count idleCount = $idleCount underutilizedCount = $underCount potentialVcpuFreed = $rightsizedVcpu potentialMemoryFreedGiB = [Math]::Round($rightsizedMemMb / 1024.0, 1) thresholds = @{ idleCpuPct = $IdleCpuPct; underCpuPct = $UnderCpuPct; underMemPct = $UnderMemPct } } classifications = @($classified) } } function Invoke-RangerStorageEfficiencyAnalysis { <# .SYNOPSIS v2.5.0 (#126): dedup, thin-provisioning coverage, and storage waste surface. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest ) $volumes = @($Manifest.domains.storage.volumes) $records = New-Object System.Collections.ArrayList $dedupEnabled = 0; $dedupEligible = 0; $thinEnabled = 0; $thinEligible = 0 $totalLogicalGiB = 0.0; $totalPhysicalGiB = 0.0; $totalSavedGiB = 0.0 foreach ($v in $volumes) { $label = [string]$v.fileSystemLabel if ([string]::IsNullOrWhiteSpace($label) -or $label -eq 'OS') { continue } $eff = $v.efficiency $dedup = if ($eff -and $null -ne $eff.dedupEnabled) { [bool]$eff.dedupEnabled } else { $false } $dedupMode = if ($eff) { [string]$eff.dedupMode } else { '' } $thin = if ($eff -and $null -ne $eff.thinProvisioned) { [bool]$eff.thinProvisioned } else { $false } $savedGiB = if ($eff -and $null -ne $eff.savedGiB) { [double]$eff.savedGiB } else { 0 } $ratio = if ($eff -and $null -ne $eff.dedupRatio) { [double]$eff.dedupRatio } else { $null } $sizeGiB = [double]$v.sizeGB $freeGiB = [double]$v.freeSpaceGB $usedGiB = $sizeGiB - $freeGiB $dedupEligible++; $thinEligible++ if ($dedup) { $dedupEnabled++ } if ($thin) { $thinEnabled++ } $totalLogicalGiB += $sizeGiB $totalPhysicalGiB += if ($ratio -and $ratio -gt 0) { $usedGiB / $ratio } else { $usedGiB } $totalSavedGiB += $savedGiB $waste = if (-not $thin -and $freeGiB -gt ($sizeGiB * 0.5)) { 'over-provisioned' } elseif (-not $dedup -and $label -match '(vmstore|backup|file)') { 'dedup-candidate' } else { 'none' } [void]$records.Add([ordered]@{ volume = $label sizeGiB = [Math]::Round($sizeGiB, 1) usedGiB = [Math]::Round($usedGiB, 1) freeGiB = [Math]::Round($freeGiB, 1) dedupEnabled = $dedup dedupMode = $dedupMode dedupRatio = $ratio savedGiB = $savedGiB thinProvisioned = $thin wasteClass = $waste }) } $dedupCoverage = if ($dedupEligible -gt 0) { [Math]::Round(($dedupEnabled / $dedupEligible) * 100, 1) } else { 0 } $thinCoverage = if ($thinEligible -gt 0) { [Math]::Round(($thinEnabled / $thinEligible) * 100, 1) } else { 0 } return [ordered]@{ summary = [ordered]@{ volumeCount = $records.Count dedupEnabledCount = $dedupEnabled dedupCoveragePct = $dedupCoverage thinEnabledCount = $thinEnabled thinCoveragePct = $thinCoverage totalLogicalGiB = [Math]::Round($totalLogicalGiB, 1) totalPhysicalGiB = [Math]::Round($totalPhysicalGiB, 1) totalSavedGiB = [Math]::Round($totalSavedGiB, 1) overallRatio = if ($totalPhysicalGiB -gt 0) { [Math]::Round($totalLogicalGiB / $totalPhysicalGiB, 2) } else { 1.0 } } volumes = @($records) } } function Invoke-RangerLicenseInventory { <# .SYNOPSIS v2.5.0 (#127): SQL Server + Windows Server license inventory. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest ) $vms = @($Manifest.domains.virtualMachines.inventory) $sqlInstances = New-Object System.Collections.ArrayList $wsInstances = New-Object System.Collections.ArrayList $sqlCores = 0; $wsCores = 0 foreach ($vm in $vms) { $guest = $vm.guestSoftware if (-not $guest) { continue } if ($guest.windowsServer) { $ws = $guest.windowsServer $cores = [int]$vm.processorCount $wsCores += $cores [void]$wsInstances.Add([ordered]@{ vm = $vm.name hostNode = $vm.hostNode edition = [string]$ws.edition version = [string]$ws.version coreCount = $cores licenseModel = [string]$ws.licenseModel ahbEligible = [bool]$ws.ahbEligible }) } if ($guest.sqlServer) { foreach ($sql in @($guest.sqlServer)) { $cores = if ($sql.assignedCoreCount) { [int]$sql.assignedCoreCount } else { [int]$vm.processorCount } $sqlCores += $cores [void]$sqlInstances.Add([ordered]@{ vm = $vm.name hostNode = $vm.hostNode instanceName = [string]$sql.instanceName edition = [string]$sql.edition version = [string]$sql.version coreCount = $cores licenseModel = [string]$sql.licenseModel ahbEligible = [bool]$sql.ahbEligible }) } } } return [ordered]@{ summary = [ordered]@{ sqlInstanceCount = $sqlInstances.Count sqlTotalCores = $sqlCores windowsServerCount = $wsInstances.Count windowsServerCores = $wsCores } sqlServer = @($sqlInstances) windowsServer = @($wsInstances) } } function Invoke-RangerV25Analyzers { <# .SYNOPSIS Run all v2.5.0 analyzer passes and merge the results into the manifest. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest ) if (-not $Manifest.domains) { $Manifest.domains = [ordered]@{} } $capacity = Invoke-RangerCapacityAnalysis -Manifest $Manifest $vmUtil = Invoke-RangerVmUtilizationAnalysis -Manifest $Manifest $efficiency = Invoke-RangerStorageEfficiencyAnalysis -Manifest $Manifest $licenses = Invoke-RangerLicenseInventory -Manifest $Manifest $Manifest.domains.capacityAnalysis = $capacity $Manifest.domains.vmUtilization = $vmUtil $Manifest.domains.storageEfficiency = $efficiency $Manifest.domains.licenseInventory = $licenses return $Manifest } |