Functions/Private/Find-StorageMigrationCandidates.ps1
|
function Find-StorageMigrationCandidates { <# .SYNOPSIS Identifies VMs whose VHDs should be moved to a different CSV to balance storage space and I/O load across the cluster. .DESCRIPTION Two-pass algorithm, mirroring Find-MigrationCandidates: Pass 1 — Storage rule compliance (hard-rule violations in current placement) For each enforced storage affinity rule that is currently violated, selects the best (VM, destination CSV) pair that resolves the violation without introducing any new hard storage-rule violation. These migrations are added to the plan first, and the simulated CSV state is updated accordingly. Pass 2 — Happiness (space/IO load balancing) 1. Score every CSV with Measure-CsvHappiness. 2. Identify CSVs whose current (simulated) score is below the aggression-level happiness threshold, sorted most→least unhappy. 3. For each unhappy source CSV, evaluate every combination of (unscheduled VM on source, candidate destination CSV): • Candidate must have enough headroom after receiving the VM's VHDs: (FreeGB – vm.TotalVhdGB) >= MinFreeGBReserve. • Storage rule impact of the move is checked: - Hard violation → destination excluded. - Soft violation → configurable score penalty applied. - Fixes a violation → configurable score bonus applied. • Simulate source after VM departs → projectedSrcScore (rule-adjusted). • Simulate destination after VM arrives → projectedDstScore. • improvement = adjustedSrcScore − currentSimSrcScore. 4. Pick the (VM, destination) pair with the highest improvement. 5. If improvement meets the aggression-level minimum, add to the plan. 6. Update simulated CSV state (FreeGB) before moving to the next source. The simulated state ensures the greedy planner does not over-commit a single destination CSV across multiple planned moves. .PARAMETER RuleSet Array of storage affinity / anti-affinity rule objects (VmVmCsvAffinity, VmVmCsvAntiAffinity, VmCsvAffinity, VmCsvAntiAffinity) returned by Get-AffinityRuleSet. Pass an empty array or omit to disable rule checking. .PARAMETER SoftRuleViolationPenalty Points subtracted from a candidate destination's projected source-relief score when the move would break a soft (non-enforced) storage rule (default: 25). .PARAMETER RuleComplianceBonus Points added to a candidate's projected score when the move fixes an existing soft storage-rule violation (default: 25). Hard-rule compliance migrations are always recommended regardless of the happiness improvement. .PARAMETER ExcludedVMs VM names pinned to Manual automation that must never be chosen as the VM to move, in either pass — see Find-MigrationCandidates' -ExcludedVMs for why. They remain fully present in -Snapshot for scoring, compliance-violation detection, and destination-capacity accounting. .OUTPUTS List of PSCustomObjects: VMName, VMId, HostNode, SourceCSV, SourceCSVName, DestinationCSV, DestinationCSVName, VHDCount, TotalVhdGB, SourceFreeGBBefore, SourceFreeGBAfter, DestFreeGBBefore, DestFreeGBAfter, SourceScoreBefore, SourceScoreAfter, DestScoreBefore, DestScoreAfter, Improvement, ComplianceReason. #> [CmdletBinding()] param( [Parameter(Mandatory)] [PSCustomObject] $Snapshot, [ValidateRange(1, 5)] [int] $AggressionLevel = 3, [ValidateRange(0.0, 1.0)] [float] $SpaceWeight = 0.7, [ValidateRange(0.0, 1.0)] [float] $IoWeight = 0.3, [int] $MinFreeGBReserve = 50, [PSCustomObject[]] $RuleSet = @(), [float] $SoftRuleViolationPenalty = 25.0, [float] $RuleComplianceBonus = 25.0, [string[]]$ExcludedVMs = @() ) $excluded = [System.Collections.Generic.HashSet[string]]::new([string[]]$ExcludedVMs) $thresholds = @{ 1 = @{ Happiness = 30; Improvement = 40 } 2 = @{ Happiness = 40; Improvement = 30 } 3 = @{ Happiness = 50; Improvement = 20 } 4 = @{ Happiness = 60; Improvement = 15 } 5 = @{ Happiness = 70; Improvement = 10 } } $happinessThreshold = $thresholds[$AggressionLevel].Happiness $improvementThreshold = $thresholds[$AggressionLevel].Improvement # Initial scores — used for SourceScoreBefore / DestScoreBefore in output $initialScores = @{} foreach ($csv in $Snapshot.CSVs) { $s = Measure-CsvHappiness -CsvMetrics $csv -SpaceWeight $SpaceWeight -IoWeight $IoWeight $initialScores[$csv.Name] = $s.HappinessScore } # Mutable simulated CSV state keyed by CSV Name $simCsvs = @{} foreach ($csv in $Snapshot.CSVs) { $simCsvs[$csv.Name] = [PSCustomObject]@{ Name = $csv.Name Path = $csv.Path TotalGB = $csv.TotalGB FreeGB = $csv.FreeGB LatencyMs = $csv.LatencyMs ReadIOPS = $csv.ReadIOPS WriteIOPS = $csv.WriteIOPS } } # Path → Name mapping (VMs reference CSVs by path) $pathToName = @{} foreach ($csv in $Snapshot.CSVs) { $pathToName[$csv.Path] = $csv.Name } # VM → CSV-name mapping, updated as moves are planned and passed to # Get-StorageMigrationRuleImpact so each rule check sees every move already # planned in this pass. Unmapped paths are kept as-is, matching that function. $vmCsvName = @{} foreach ($vm in $Snapshot.VMs) { $vmCsvName[$vm.VMName] = if ($vm.PrimaryCSV -and $pathToName.ContainsKey($vm.PrimaryCSV)) { $pathToName[$vm.PrimaryCSV] } else { $vm.PrimaryCSV } } # Helper: score a simulated CSV object $scoreSimCsv = { param($sim) $proxy = [PSCustomObject]@{ Name = $sim.Name TotalGB = $sim.TotalGB FreeGB = $sim.FreeGB LatencyMs = $sim.LatencyMs } (Measure-CsvHappiness -CsvMetrics $proxy -SpaceWeight $SpaceWeight -IoWeight $IoWeight).HappinessScore } # Helper: how badly a single enforced storage-affinity rule is currently # violated (0 = satisfied). Mirrors Find-MigrationCandidates' $ruleSeverity # — a VmVmCsvAffinity/VmVmCsvAntiAffinity rule spanning 3+ VMs can need more # than one move to fully satisfy, so this lets Pass 1 recognize and accept a # move that only partially resolves it. $csvRuleSeverity = { param($rule, $placement) switch ($rule.Type) { 'VmVmCsvAffinity' { $csvs = @($rule.VMs | Where-Object { $placement.ContainsKey($_) } | ForEach-Object { $placement[$_] }) if ($csvs.Count -eq 0) { return 0 } return [Math]::Max(0, (@($csvs | Select-Object -Unique)).Count - 1) } 'VmVmCsvAntiAffinity' { $csvs = @($rule.VMs | Where-Object { $placement.ContainsKey($_) } | ForEach-Object { $placement[$_] }) if ($csvs.Count -eq 0) { return 0 } return [Math]::Max(0, $csvs.Count - (@($csvs | Select-Object -Unique)).Count) } 'VmCsvAffinity' { return @($rule.VMs | Where-Object { $placement.ContainsKey($_) -and ($rule.CSVs -notcontains $placement[$_]) }).Count } 'VmCsvAntiAffinity' { return @($rule.VMs | Where-Object { $placement.ContainsKey($_) -and ($rule.CSVs -contains $placement[$_]) }).Count } default { return 0 } } } $scheduledVMs = [System.Collections.Generic.HashSet[string]]::new() $migrations = [System.Collections.Generic.List[PSCustomObject]]::new() # ════════════════════════════════════════════════════════════════════════════ # PASS 1 — Storage rule compliance (fix enforced-rule violations first) # ════════════════════════════════════════════════════════════════════════════ if ($RuleSet -and $RuleSet.Count -gt 0) { $enforcedStorageRules = @($RuleSet | Where-Object { $_.Enforced -and $_.Type -in @('VmVmCsvAffinity', 'VmVmCsvAntiAffinity', 'VmCsvAffinity', 'VmCsvAntiAffinity') }) # See Find-MigrationCandidates' matching loop for why this iterates # rules (via $csvRuleSeverity) rather than a one-shot violation list, # and for why this cap is generous but non-load-bearing. $maxComplianceIterations = $Snapshot.VMs.Count + $enforcedStorageRules.Count + 1 for ($iter = 0; $iter -lt $maxComplianceIterations; $iter++) { $violatedRules = @($enforcedStorageRules | Where-Object { (& $csvRuleSeverity $_ $vmCsvName) -gt 0 }) if ($violatedRules.Count -eq 0) { break } $bestFix = $null $bestFixSeverityDrop = 0 $bestFixScore = -1 foreach ($rule in $violatedRules) { $currentSeverity = & $csvRuleSeverity $rule $vmCsvName $movable = @($rule.VMs | Where-Object { $vmCsvName.ContainsKey($_) -and -not $scheduledVMs.Contains($_) -and -not $excluded.Contains($_) }) foreach ($vmName in $movable) { $vm = $Snapshot.VMs | Where-Object { $_.VMName -eq $vmName } if (-not $vm) { continue } $srcName = $vmCsvName[$vmName] $simSrcForVm = $simCsvs[$srcName] if (-not $simSrcForVm) { continue } $candidates = $simCsvs.Values | Where-Object { $_.Name -ne $srcName -and ($_.FreeGB - $vm.TotalVhdGB) -ge $MinFreeGBReserve } foreach ($dst in $candidates) { $impact = Get-StorageMigrationRuleImpact -VMName $vmName -DestinationCsvName $dst.Name ` -Snapshot $Snapshot -RuleSet $RuleSet ` -Placement $vmCsvName # Never accept a move that breaks a DIFFERENT enforced rule if ($impact.HasHardViolation) { continue } $hypothetical = $vmCsvName.Clone() $hypothetical[$vmName] = $dst.Name $severityDrop = $currentSeverity - (& $csvRuleSeverity $rule $hypothetical) if ($severityDrop -le 0) { continue } # no progress on this rule $dstFreeAfter = $dst.FreeGB - $vm.TotalVhdGB $dstSimCopy = [PSCustomObject]@{ Name = $dst.Name; TotalGB = $dst.TotalGB FreeGB = $dstFreeAfter; LatencyMs = $dst.LatencyMs } $projectedDstScore = & $scoreSimCsv $dstSimCopy if ($severityDrop -gt $bestFixSeverityDrop -or ($severityDrop -eq $bestFixSeverityDrop -and $projectedDstScore -gt $bestFixScore)) { $bestFixSeverityDrop = $severityDrop $bestFixScore = $projectedDstScore $newSeverity = $currentSeverity - $severityDrop $srcFreeAfter = $simSrcForVm.FreeGB + $vm.TotalVhdGB $srcSimCopy = [PSCustomObject]@{ Name = $simSrcForVm.Name; TotalGB = $simSrcForVm.TotalGB FreeGB = $srcFreeAfter; LatencyMs = $simSrcForVm.LatencyMs } $projectedSrcScore = & $scoreSimCsv $srcSimCopy $bestFix = [PSCustomObject]@{ VMName = $vmName VMId = $vm.VMId HostNode = $vm.HostNode SourceCSV = $simSrcForVm.Path SourceCSVName = $simSrcForVm.Name DestinationCSV = $dst.Path DestinationCSVName = $dst.Name VHDCount = $vm.VHDs.Count TotalVhdGB = $vm.TotalVhdGB SourceFreeGBBefore = [Math]::Round($simSrcForVm.FreeGB, 1) SourceFreeGBAfter = [Math]::Round($srcFreeAfter, 1) DestFreeGBBefore = [Math]::Round($dst.FreeGB, 1) DestFreeGBAfter = [Math]::Round($dstFreeAfter, 1) SourceScoreBefore = $initialScores[$srcName] SourceScoreAfter = [Math]::Round($projectedSrcScore, 1) DestScoreBefore = $initialScores[$dst.Name] DestScoreAfter = [Math]::Round($projectedDstScore, 1) Improvement = [Math]::Round($projectedSrcScore - $initialScores[$srcName], 1) ComplianceReason = if ($newSeverity -eq 0) { "Satisfies enforced $($rule.Type) rule '$($rule.Name)'" } else { "Partially satisfies enforced $($rule.Type) rule '$($rule.Name)' ($newSeverity violation(s) remaining)" } } } } } } if (-not $bestFix) { Write-Verbose (" No move improves storage compliance for: {0}" -f (($violatedRules | ForEach-Object { $_.Name }) -join ', ')) break } $migrations.Add($bestFix) [void]$scheduledVMs.Add($bestFix.VMName) $simCsvs[$bestFix.SourceCSVName].FreeGB += $bestFix.TotalVhdGB $simCsvs[$bestFix.DestinationCSVName].FreeGB -= $bestFix.TotalVhdGB $vmCsvName[$bestFix.VMName] = $bestFix.DestinationCSVName } } # ════════════════════════════════════════════════════════════════════════════ # PASS 2 — Happiness-based migrations (space / IO load balancing) # ════════════════════════════════════════════════════════════════════════════ # Collect unhappy CSVs — re-evaluated each outer iteration via simulated state $unhappyCsvNames = $initialScores.GetEnumerator() | Where-Object { $_.Value -lt $happinessThreshold } | Sort-Object Value | Select-Object -ExpandProperty Key foreach ($srcName in $unhappyCsvNames) { # A single move rarely brings a large, badly-unhappy CSV all the way up # to the aggression threshold on its own. Keep planning moves off this # SAME source — each iteration re-scores against the simulated state # left by the previous one — until it's fixed or no VM on it can be # beneficially moved anywhere. $vmsOnSrc strictly shrinks every # iteration (a scheduled VM is never reconsidered), so this always # terminates; the explicit cap is defense-in-depth, not a load-bearing # guard. for ($guard = 0; $guard -lt $Snapshot.VMs.Count; $guard++) { $simSrc = $simCsvs[$srcName] if (-not $simSrc) { break } # Re-score against current simulated state; stop if already fixed $currentSrcScore = & $scoreSimCsv $simSrc if ($currentSrcScore -ge $happinessThreshold) { break } # VMs whose primary storage is on this CSV, not yet scheduled, and not # pinned to Manual automation $vmsOnSrc = $Snapshot.VMs | Where-Object { $pathToName[$_.PrimaryCSV] -eq $srcName -and -not $scheduledVMs.Contains($_.VMName) -and -not $excluded.Contains($_.VMName) } if (-not $vmsOnSrc) { break } $bestMigration = $null $bestImprovement = 0.0 foreach ($vm in $vmsOnSrc) { # Candidate destinations: enough headroom after receiving this VM $candidates = $simCsvs.Values | Where-Object { $_.Name -ne $srcName -and ($_.FreeGB - $vm.TotalVhdGB) -ge $MinFreeGBReserve } foreach ($dst in $candidates) { # Storage rule impact check $impact = if ($RuleSet -and $RuleSet.Count -gt 0) { Get-StorageMigrationRuleImpact -VMName $vm.VMName -DestinationCsvName $dst.Name ` -Snapshot $Snapshot -RuleSet $RuleSet ` -Placement $vmCsvName } else { [PSCustomObject]@{ HasHardViolation=$false; HasSoftViolation=$false; FixesViolation=$false } } if ($impact.HasHardViolation) { continue } # Simulate source after VM departs $srcFreeAfter = $simSrc.FreeGB + $vm.TotalVhdGB $srcSimCopy = [PSCustomObject]@{ Name = $simSrc.Name; TotalGB = $simSrc.TotalGB FreeGB = $srcFreeAfter; LatencyMs = $simSrc.LatencyMs } $projectedSrcScore = (Measure-CsvHappiness -CsvMetrics $srcSimCopy -SpaceWeight $SpaceWeight -IoWeight $IoWeight).HappinessScore # Simulate destination after VM arrives $dstFreeAfter = $dst.FreeGB - $vm.TotalVhdGB $dstSimCopy = [PSCustomObject]@{ Name = $dst.Name; TotalGB = $dst.TotalGB FreeGB = $dstFreeAfter; LatencyMs = $dst.LatencyMs } $projectedDstScore = (Measure-CsvHappiness -CsvMetrics $dstSimCopy -SpaceWeight $SpaceWeight -IoWeight $IoWeight).HappinessScore # Never plan a happiness-based move that pushes the DESTINATION # below the aggression threshold — whether it was already unhappy # (piling more onto a struggling CSV) or currently healthy (this # move alone would make it the next CSV needing relief). Pass 1's # rule-compliance fixes are exempt from this (see that pass's own # comment) since a hard-rule violation must be resolved regardless # of the happiness cost. if ($projectedDstScore -lt $happinessThreshold) { continue } # Apply rule-aware adjustment to the source-relief score used for selection $adjustedSrcScore = $projectedSrcScore if ($impact.HasSoftViolation) { $adjustedSrcScore = [Math]::Max(0, $adjustedSrcScore - $SoftRuleViolationPenalty) } if ($impact.FixesViolation) { $adjustedSrcScore = [Math]::Min(100, $adjustedSrcScore + $RuleComplianceBonus) } $improvement = $adjustedSrcScore - $currentSrcScore if ($improvement -gt $bestImprovement) { $bestImprovement = $improvement $bestMigration = [PSCustomObject]@{ VMName = $vm.VMName VMId = $vm.VMId HostNode = $vm.HostNode SourceCSV = $simSrc.Path SourceCSVName = $simSrc.Name DestinationCSV = $dst.Path DestinationCSVName = $dst.Name VHDCount = $vm.VHDs.Count TotalVhdGB = $vm.TotalVhdGB SourceFreeGBBefore = [Math]::Round($simSrc.FreeGB, 1) SourceFreeGBAfter = [Math]::Round($srcFreeAfter, 1) DestFreeGBBefore = [Math]::Round($dst.FreeGB, 1) DestFreeGBAfter = [Math]::Round($dstFreeAfter, 1) SourceScoreBefore = $initialScores[$srcName] SourceScoreAfter = [Math]::Round($projectedSrcScore, 1) DestScoreBefore = $initialScores[$dst.Name] DestScoreAfter = [Math]::Round($projectedDstScore, 1) Improvement = [Math]::Round($improvement, 1) ComplianceReason = $null } } } } if ($null -eq $bestMigration -or $bestImprovement -lt $improvementThreshold) { break } $migrations.Add($bestMigration) [void]$scheduledVMs.Add($bestMigration.VMName) # Greedy state update $simCsvs[$bestMigration.SourceCSVName].FreeGB += $bestMigration.TotalVhdGB $simCsvs[$bestMigration.DestinationCSVName].FreeGB -= $bestMigration.TotalVhdGB $vmCsvName[$bestMigration.VMName] = $bestMigration.DestinationCSVName } } return $migrations } |