Functions/Public/Invoke-HvStorageDRS.ps1

function Invoke-HvStorageDRS {
    <#
    .SYNOPSIS
        Hyper-V Storage DRS — balances Cluster Shared Volume utilization by
        live-migrating VM storage between CSVs.

    .DESCRIPTION
        Scores each CSV on a 0–100 Happiness scale (space-based, optionally
        I/O-latency-weighted) and identifies VMs whose storage should move to a
        less-loaded CSV. Uses the same aggression-level model as Invoke-HvDRS.

        Migrations are executed via Move-VMStorage (storage live migration), which
        moves all VHDs and configuration files while the VM remains running.

        Use -WhatIf to preview recommendations without moving any data.

    .PARAMETER ClusterName
        Target Failover Cluster. Defaults to the local cluster if omitted.

    .PARAMETER AggressionLevel
        Controls sensitivity (1–5, default 3). Higher values trigger migrations
        for smaller happiness deficits. Uses the same thresholds as Invoke-HvDRS.

        Level CSV threshold Min improvement
        ----- ------------ ---------------
          1 < 30 > 40
          2 < 40 > 30
          3 < 50 > 20 (default)
          4 < 60 > 15
          5 < 70 > 10

    .PARAMETER SampleCount
        Number of I/O counter samples to average per CSV (default: 3).
        Set to 0 to skip I/O collection entirely (space-only scoring).

    .PARAMETER SampleIntervalSeconds
        Seconds between I/O counter samples (default: 5).

    .PARAMETER SpaceWeight
        Relative weight of space happiness in the combined CSV score (default: 0.7).

    .PARAMETER IoWeight
        Relative weight of I/O (latency) happiness in the combined score (default: 0.3).
        Automatically dropped to 0 for any CSV where latency counters are unavailable.

    .PARAMETER MinFreeGBReserve
        Minimum free space (GB) that must remain on the destination CSV after the
        VM's VHDs land (default: 50 GB).

    .PARAMETER RulesPath
        Path to the JSON affinity rule store. Only storage rule types (VmVmCsvAffinity,
        VmVmCsvAntiAffinity, VmCsvAffinity, VmCsvAntiAffinity) are applied; compute-only
        rule types are ignored. If the file does not exist, rule checking is skipped.
        Default: $env:ProgramData\HvDRS\rules.json.
        Manage rules with Add-HvDRSAffinityRule / Get-HvDRSAffinityRule / etc.

    .PARAMETER SoftRuleViolationPenalty
        Score penalty (0–100) applied to a candidate destination's projected score when
        a proposed storage migration would break a soft storage affinity rule (default: 25).

    .PARAMETER RuleComplianceBonus
        Score bonus (0–100) added to a candidate's projected score when a storage
        migration would fix an existing soft rule violation (default: 25).

    .PARAMETER RecommendOnly
        Print the migration plan but never call Move-VMStorage.

    .PARAMETER MaintenanceLockFile
        Shares the same lock file as Invoke-HvDRS so a single maintenance window
        suppresses both compute and storage migrations.
        Default: $env:ProgramData\HvDRS\maintenance.lock

    .PARAMETER AutomationOverridesPath
        Path to the JSON per-VM automation-level override store (see
        Set-HvDRSVMAutomationLevel) — shared with Invoke-HvDRS, since automation
        level is a per-VM setting, not a compute-vs-storage one. A VM pinned to
        Manual still gets a storage recommendation but is never actually moved by
        this function. Default: $env:ProgramData\HvDRS\automation-overrides.json.

    .PARAMETER WebhookUrl
        If specified, POSTs a JSON summary of the pass (cluster, mode, recommendation/
        execution counts, violation count) to this URL when the pass completes. A
        failed POST is logged with Write-Warning and never fails the pass itself.

    .PARAMETER WriteEventLog
        If specified, writes the same JSON summary to the Windows Application event
        log under source 'HVDRS' (creating the source if needed) when the pass completes.

    .PARAMETER PassThru
        Emit the storage migration recommendations to the pipeline as structured
        objects, in addition to the normal console output. Each object carries
        ClusterName, GeneratedAt, VMName, VMId, HostNode, SourceCSVName,
        DestinationCSVName, TotalVhdGB, SourceScoreBefore/After, DestScoreBefore/After,
        Improvement, and ComplianceReason — the same shape Invoke-HvDRS's -PassThru
        uses for compute recommendations, adapted for storage. Has no effect on
        migration execution behavior — combine with -RecommendOnly for a read-only,
        object-returning pass. This is what the storage ProPack probe consumes
        (see ProPack/Scripts/Invoke-HvDrsStorageProTipProbe.ps1).

    .EXAMPLE
        # Dry run — print recommendations without moving data
        Invoke-HvStorageDRS -ClusterName 'PROD-CLUSTER' -WhatIf

    .EXAMPLE
        # Space-only scoring (skip I/O counter collection)
        Invoke-HvStorageDRS -ClusterName 'PROD-CLUSTER' -SampleCount 0

    .EXAMPLE
        # Aggressive rebalancing with larger headroom requirement
        Invoke-HvStorageDRS -ClusterName 'PROD-CLUSTER' -AggressionLevel 5 -MinFreeGBReserve 100
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [string] $ClusterName,

        [ValidateRange(1, 5)]
        [int]   $AggressionLevel       = 3,

        [int]   $SampleCount           = 3,
        [int]   $SampleIntervalSeconds = 5,

        [ValidateRange(0.0, 1.0)]
        [float] $SpaceWeight           = 0.7,

        [ValidateRange(0.0, 1.0)]
        [float] $IoWeight              = 0.3,

        [int]   $MinFreeGBReserve      = 50,

        [string] $RulesPath = (Join-Path (Get-HvDRSDataRoot) 'HvDRS\rules.json'),

        [ValidateRange(0.0, 100.0)]
        [float] $SoftRuleViolationPenalty = 25.0,

        [ValidateRange(0.0, 100.0)]
        [float] $RuleComplianceBonus      = 25.0,

        [switch] $RecommendOnly,

        [string] $MaintenanceLockFile  = (Join-Path (Get-HvDRSDataRoot) 'HvDRS\maintenance.lock'),

        [string] $AutomationOverridesPath = (Join-Path (Get-HvDRSDataRoot) 'HvDRS\automation-overrides.json'),

        [string] $WebhookUrl,

        [switch] $WriteEventLog,

        [switch] $PassThru
    )

    # ── Resolve cluster ────────────────────────────────────────────────────────
    if (-not $ClusterName) {
        try { $ClusterName = (Get-Cluster -ErrorAction Stop).Name }
        catch { throw "No -ClusterName specified and no local cluster detected. $_" }
    }

    $ts = { "[{0}]" -f [DateTime]::Now.ToString('HH:mm:ss') }

    # ── Execution mode ─────────────────────────────────────────────────────────
    $maintenanceActive = Test-Path -LiteralPath $MaintenanceLockFile
    $willMigrate       = -not $RecommendOnly -and -not $maintenanceActive

    $modeLabel = if ($maintenanceActive) {
        $lockContent = Get-Content -LiteralPath $MaintenanceLockFile -ErrorAction SilentlyContinue
        "MAINTENANCE ($lockContent)"
    } elseif ($RecommendOnly) { 'RECOMMEND-ONLY' } else { 'AUTO-MIGRATE' }

    # ── Load storage affinity rules ────────────────────────────────────────────
    # Capture into a variable before filtering rather than piping the function
    # call directly into Where-Object: Get-AffinityRuleSet's ",@()" empty-result
    # protection (see that function's comments) only survives a direct variable
    # assignment. Piped straight into another command, PowerShell enumerates the
    # comma-wrapper array and streams its single inner (empty) array through as
    # one pipeline object instead of zero — so $_.Type below would throw under
    # Set-StrictMode when no rules are configured at all.
    $storageRuleTypes = @('VmVmCsvAffinity','VmVmCsvAntiAffinity','VmCsvAffinity','VmCsvAntiAffinity')
    $allRules  = Get-AffinityRuleSet -Path $RulesPath -ClusterName $ClusterName
    $ruleSet   = @($allRules | Where-Object { $_.Type -in $storageRuleTypes })
    $ruleLabel = if ($ruleSet.Count -gt 0) { "$($ruleSet.Count) rule(s)" } else { 'none' }

    # ── Load per-VM automation-level overrides ─────────────────────────────────
    $manualVMs = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($override in (Get-HvDRSAutomationOverrideSet -Path $AutomationOverridesPath -ClusterName $ClusterName)) {
        if ($override.AutomationLevel -eq 'Manual') { [void]$manualVMs.Add($override.VMName) }
    }

    Write-Host ("{0} HvStorageDRS starting — Cluster: {1} Aggression: {2} Weights: Space={3} IO={4} Reserve: {5} GB Rules: {6} Mode: {7}" -f
        (& $ts), $ClusterName, $AggressionLevel, $SpaceWeight, $IoWeight, $MinFreeGBReserve, $ruleLabel, $modeLabel)

    if ($maintenanceActive) {
        $lockContent = Get-Content -LiteralPath $MaintenanceLockFile -ErrorAction SilentlyContinue
        Write-Host ("{0} Maintenance lock active ({1}). No storage migrations will run." -f
            (& $ts), $lockContent) -ForegroundColor Yellow
    }

    # ── Phase 1: Collect storage snapshot ─────────────────────────────────────
    $ioLabel = if ($SampleCount -gt 0) { "samples=$SampleCount, interval=${SampleIntervalSeconds}s" } else { 'disabled' }
    Write-Host ("{0} Collecting storage metrics (I/O: {1})..." -f (& $ts), $ioLabel)

    $snapshot = Get-StorageSnapshot -ClusterName $ClusterName `
                                    -SampleCount $SampleCount `
                                    -SampleIntervalSeconds $SampleIntervalSeconds `
                                    -Verbose:($VerbosePreference -ne 'SilentlyContinue')

    if ($snapshot.CSVs.Count -eq 0) {
        Write-Host ("{0} No Cluster Shared Volumes found. Nothing to balance." -f (& $ts))
        return
    }

    Write-Host ("{0} Snapshot: {1} CSV(s), {2} VM(s) with CSV storage." -f
        (& $ts), $snapshot.CSVs.Count, $snapshot.VMs.Count)

    # ── Phase 1.5: Check storage affinity rule compliance ─────────────────────
    $violationCount = 0
    if ($ruleSet.Count -gt 0) {
        $storageViolations = @(Test-StorageAffinityCompliance -Snapshot $snapshot -RuleSet $ruleSet)
        $violationCount    = $storageViolations.Count

        if ($storageViolations.Count -gt 0) {
            $hardCount = @($storageViolations | Where-Object { $_.Enforced }).Count
            $softCount = $storageViolations.Count - $hardCount

            Write-Host ('── {0} Storage Rule Violation(s) Detected — {1} hard, {2} soft ───────────────' -f
                $storageViolations.Count, $hardCount, $softCount)

            $storageViolations | Format-Table -AutoSize -Wrap -Property `
                @{ N='Rule';     E={ $_.RuleName } },
                @{ N='Type';     E={ $_.Type } },
                @{ N='Hard';     E={ $_.Enforced } },
                @{ N='VMs';      E={ $_.VMs -join ', ' } },
                @{ N='Detail';   E={ $_.Description } } | Out-Host
        } else {
            Write-Host ("{0} All {1} storage affinity rule(s) satisfied." -f (& $ts), $ruleSet.Count)
        }
    }

    $notify = {
        param($RecommendationCount, $ExecutedCount, $FailedCount)
        if (-not $WebhookUrl -and -not $WriteEventLog) { return }
        $payload = [PSCustomObject]@{
            ClusterName         = $ClusterName
            GeneratedAt         = [DateTime]::Now
            Mode                = $modeLabel
            RecommendationCount = $RecommendationCount
            ExecutedCount       = $ExecutedCount
            FailedCount         = $FailedCount
            ViolationCount      = $violationCount
        }
        Send-HvDRSNotification -Payload $payload -WebhookUrl $WebhookUrl -WriteEventLog:$WriteEventLog
    }

    # ── Phase 2: CSV space + I/O summary ──────────────────────────────────────
    Write-Host ''
    Write-Host '── CSV Summary ───────────────────────────────────────────────────────────────'
    $snapshot.CSVs | Sort-Object SpaceUsedPct -Descending | Format-Table -AutoSize -Property `
        @{ N='CSV';         E={ $_.Name } },
        @{ N='Owner';       E={ $_.OwnerNode } },
        @{ N='Total GB';    E={ '{0:N1}' -f $_.TotalGB } },
        @{ N='Used GB';     E={ '{0:N1}' -f $_.UsedGB } },
        @{ N='Free GB';     E={ '{0:N1}' -f $_.FreeGB } },
        @{ N='Used %';      E={ '{0:N1}' -f $_.SpaceUsedPct } },
        @{ N='Read IOPS';   E={ if ($null -ne $_.ReadIOPS)  { '{0:N0}' -f $_.ReadIOPS  } else { 'N/A' } } },
        @{ N='Write IOPS';  E={ if ($null -ne $_.WriteIOPS) { '{0:N0}' -f $_.WriteIOPS } else { 'N/A' } } },
        @{ N='Latency ms';  E={ if ($null -ne $_.LatencyMs) { '{0:N2}' -f $_.LatencyMs } else { 'N/A' } } } | Out-Host

    # ── Phase 3: Score all CSVs ────────────────────────────────────────────────
    $csvScores = foreach ($csv in $snapshot.CSVs) {
        Measure-CsvHappiness -CsvMetrics $csv -SpaceWeight $SpaceWeight -IoWeight $IoWeight
    }

    Write-Host '── CSV Happiness Scores ──────────────────────────────────────────────────────'
    $csvScores | Sort-Object HappinessScore | Format-Table -AutoSize -Property `
        @{ N='CSV';         E={ $_.CsvName } },
        @{ N='Space Happy'; E={ '{0:N1}' -f $_.SpaceHappiness } },
        @{ N='IO Happy';    E={ if ($null -ne $_.IoHappiness) { '{0:N1}' -f $_.IoHappiness } else { 'N/A (space-only)' } } },
        @{ N='Score';       E={ '{0:N1}' -f $_.HappinessScore } },
        @{ N='Status';      E={
            if    ($_.HappinessScore -ge 80) { 'Healthy' }
            elseif ($_.HappinessScore -ge 50) { 'Pressured' }
            else                             { 'CRITICAL' }
        }} | Out-Host

    # ── Phase 4: Find storage migration candidates ─────────────────────────────
    $migrations = Find-StorageMigrationCandidates `
                      -Snapshot $snapshot `
                      -AggressionLevel $AggressionLevel `
                      -SpaceWeight $SpaceWeight `
                      -IoWeight $IoWeight `
                      -MinFreeGBReserve $MinFreeGBReserve `
                      -RuleSet $ruleSet `
                      -SoftRuleViolationPenalty $SoftRuleViolationPenalty `
                      -RuleComplianceBonus $RuleComplianceBonus `
                      -Verbose:($VerbosePreference -ne 'SilentlyContinue')

    $generatedAt     = [DateTime]::Now
    $passThruResults = foreach ($m in $migrations) {
        [PSCustomObject]@{
            ClusterName        = $ClusterName
            GeneratedAt        = $generatedAt
            VMName             = $m.VMName
            VMId               = $m.VMId
            HostNode           = $m.HostNode
            SourceCSVName      = $m.SourceCSVName
            DestinationCSVName = $m.DestinationCSVName
            TotalVhdGB         = $m.TotalVhdGB
            SourceScoreBefore  = $m.SourceScoreBefore
            SourceScoreAfter   = $m.SourceScoreAfter
            DestScoreBefore    = $m.DestScoreBefore
            DestScoreAfter     = $m.DestScoreAfter
            Improvement        = $m.Improvement
            ComplianceReason   = $m.ComplianceReason
        }
    }

    if (-not $migrations -or $migrations.Count -eq 0) {
        Write-Host ("{0} Storage is balanced at aggression level {1}. No migrations needed." -f
            (& $ts), $AggressionLevel)
        & $notify 0 0 0
        if ($PassThru) { Write-Output $passThruResults }
        return
    }

    Write-Host ''
    Write-Host ('── {0} Storage Migration Recommendation(s) ────────────────────────────────────' -f $migrations.Count)
    $migrations | Format-Table -AutoSize -Property `
        @{ N='VM';           E={ $_.VMName } },
        @{ N='Host';         E={ $_.HostNode } },
        @{ N='From CSV';     E={ $_.SourceCSVName } },
        @{ N='To CSV';       E={ $_.DestinationCSVName } },
        @{ N='Data GB';      E={ '{0:N1}' -f $_.TotalVhdGB } },
        @{ N='Src Score';    E={ '{0} → {1}' -f $_.SourceScoreBefore, $_.SourceScoreAfter } },
        @{ N='Dst Score';    E={ '{0} → {1}' -f $_.DestScoreBefore,   $_.DestScoreAfter } },
        @{ N='Src Free GB';  E={ '{0:N0} → {1:N0}' -f $_.SourceFreeGBBefore, $_.SourceFreeGBAfter } },
        @{ N='Delta';        E={ '+{0}' -f $_.Improvement } },
        @{ N='Trigger';      E={
            if ($_.ComplianceReason) {
                'Compliance: ' + ($_.ComplianceReason.Substring(0, [Math]::Min(40, $_.ComplianceReason.Length)))
            } else { 'Happiness' }
        }} | Out-Host

    # ── Phase 5: Execute or preview ────────────────────────────────────────────
    if (-not $willMigrate) {
        $reason = if ($maintenanceActive) { 'maintenance lock is active' } else { '-RecommendOnly was specified' }
        Write-Host ("{0} Skipping storage migration execution — {1}." -f (& $ts), $reason)
        Write-Host ''
        Write-Host ("{0} HvStorageDRS pass complete — {1} recommendation(s), no migrations executed." -f
            (& $ts), $migrations.Count)
        & $notify $migrations.Count 0 0
        if ($PassThru) { Write-Output $passThruResults }
        return
    }

    $succeeded = 0
    $failed    = 0
    $pinned    = 0

    foreach ($migration in $migrations) {
        if ($manualVMs.Contains($migration.VMName)) {
            Write-Host ("{0} Skipping '{1}' — pinned to Manual automation (see Set-HvDRSVMAutomationLevel). Recommendation stands; not executed." -f
                (& $ts), $migration.VMName) -ForegroundColor Yellow
            $pinned++
            continue
        }

        $action = "Storage live-migrate '{0}' ({1:N1} GB VHDs) from '{2}' to '{3}'" -f
                  $migration.VMName, $migration.TotalVhdGB,
                  $migration.SourceCSVName, $migration.DestinationCSVName

        if (-not $PSCmdlet.ShouldProcess($migration.VMName, $action)) { continue }

        Write-Host ("{0} Moving '{1}' ({2:N1} GB): '{3}' → '{4}' ..." -f
            (& $ts), $migration.VMName, $migration.TotalVhdGB,
            $migration.SourceCSVName, $migration.DestinationCSVName)

        try {
            Move-VMStorage -ComputerName $migration.HostNode `
                           -VMName       $migration.VMName `
                           -DestinationStoragePath $migration.DestinationCSV `
                           -ErrorAction  Stop

            Write-Host ("{0} Done. Source CSV score {1} → {2} (+{3})" -f
                (& $ts), $migration.SourceScoreBefore, $migration.SourceScoreAfter, $migration.Improvement)
            $succeeded++
        } catch {
            Write-Warning ("Storage migration of '{0}' failed: {1}" -f $migration.VMName, $_)
            $failed++
        }
    }

    if ($PSCmdlet.ShouldProcess('summary', 'Report')) {
        Write-Host ''
        Write-Host ("{0} HvStorageDRS pass complete — {1} migrated, {2} failed, {3} pinned to Manual (not executed)." -f
            (& $ts), $succeeded, $failed, $pinned)
    }

    & $notify $migrations.Count $succeeded $failed

    if ($PassThru) { Write-Output $passThruResults }
}