Functions/Public/Maintenance.ps1

function Enable-HvDRSMaintenance {
    <#
    .SYNOPSIS
        Drops a maintenance lock file that causes HvDRS to skip all live migrations.

    .DESCRIPTION
        Creates the maintenance lock file checked by Invoke-HvDRS at the start of each pass.
        While the file exists, HvDRS will still collect metrics and score VMs but will not
        execute or propose any live migrations.

        Remove the lock with Disable-HvDRSMaintenance when the maintenance window ends.

    .PARAMETER Reason
        Optional free-text reason stored inside the lock file (shown in HvDRS output).

    .PARAMETER LockFile
        Path to the lock file. Must match the -MaintenanceLockFile path used by Invoke-HvDRS.
        Default: $env:ProgramData\HvDRS\maintenance.lock

    .EXAMPLE
        Enable-HvDRSMaintenance -Reason 'Patch Tuesday patching window'
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Reason = 'Maintenance window',
        [string]$LockFile = (Join-Path (Get-HvDRSDataRoot) 'HvDRS\maintenance.lock')
    )

    if (-not $PSCmdlet.ShouldProcess($LockFile, 'Create maintenance lock file')) { return }

    $dir = Split-Path $LockFile
    if (-not (Test-Path $dir)) {
        New-Item -Path $dir -ItemType Directory -Force | Out-Null
    }

    $content = "{0} — enabled {1}" -f $Reason, (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
    Set-Content -LiteralPath $LockFile -Value $content -Encoding UTF8

    Write-Host "HvDRS maintenance mode ENABLED."
    Write-Host " Lock file : $LockFile"
    Write-Host " Reason : $Reason"
    Write-Host " Run Disable-HvDRSMaintenance to resume automatic migrations."
}

function Disable-HvDRSMaintenance {
    <#
    .SYNOPSIS
        Removes the HvDRS maintenance lock file, re-enabling automatic live migrations.

    .PARAMETER LockFile
        Path to the lock file. Must match the -MaintenanceLockFile path used by Invoke-HvDRS.
        Default: $env:ProgramData\HvDRS\maintenance.lock

    .EXAMPLE
        Disable-HvDRSMaintenance
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$LockFile = (Join-Path (Get-HvDRSDataRoot) 'HvDRS\maintenance.lock')
    )

    if (-not (Test-Path -LiteralPath $LockFile)) {
        Write-Warning "Maintenance lock file not found at '$LockFile'. HvDRS is already active."
        return
    }

    if (-not $PSCmdlet.ShouldProcess($LockFile, 'Remove maintenance lock file')) { return }

    Remove-Item -LiteralPath $LockFile -Force
    Write-Host "HvDRS maintenance mode DISABLED. Automatic migrations will resume on the next pass."
}

function Get-HvDRSMaintenanceStatus {
    <#
    .SYNOPSIS
        Reports whether HvDRS maintenance mode is currently active.

    .PARAMETER LockFile
        Path to the lock file.
        Default: $env:ProgramData\HvDRS\maintenance.lock
    #>

    [CmdletBinding()]
    param(
        [string]$LockFile = (Join-Path (Get-HvDRSDataRoot) 'HvDRS\maintenance.lock')
    )

    if (Test-Path -LiteralPath $LockFile) {
        $content = Get-Content -LiteralPath $LockFile -ErrorAction SilentlyContinue
        [PSCustomObject]@{
            MaintenanceActive = $true
            LockFile          = $LockFile
            Reason            = $content
        }
    } else {
        [PSCustomObject]@{
            MaintenanceActive = $false
            LockFile          = $LockFile
            Reason            = $null
        }
    }
}

function Enter-HvDRSNodeMaintenance {
    <#
    .SYNOPSIS
        Evacuates every running VM off a cluster node using HVDRS's happiness-aware
        destination selection, then pauses the node so it stops receiving new
        cluster role placements — a Hyper-V-cluster analog of vSphere's "Enter
        Maintenance Mode".

    .DESCRIPTION
        Unlike Suspend-ClusterNode -Drain, which lets the Failover Cluster's own
        placement logic choose destinations, this function scores every candidate
        node with the same VM Happiness model Invoke-HvDRS uses (Network-Aware NIC
        gate, post-migration memory reserve, possible-owner constraints, and
        hard/soft affinity-rule impact — see Find-EvacuationDestination) and
        live-migrates each VM to the best-scoring valid destination via
        Move-ClusterVirtualMachineRole.

        If any VM cannot be evacuated (e.g. a hard host-affinity rule leaves no
        valid destination, or the migration itself fails), the node is NOT paused
        and the failure is reported — pausing a node that still has unmovable VMs
        on it would strand them there indefinitely.

        Use -WhatIf to preview the full evacuation + pause plan without moving
        anything or pausing the node.

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

    .PARAMETER NodeName
        The cluster node to drain and pause.

    .PARAMETER RulesPath
        Path to the JSON affinity rule store. Same default/semantics as Invoke-HvDRS.

    .PARAMETER CpuWeight
        Relative weight of CPU happiness when scoring candidate destinations (default: 0.5).

    .PARAMETER MemoryWeight
        Relative weight of memory happiness when scoring candidate destinations (default: 0.5).

    .PARAMETER MaxDestinationNetworkUtil
        Network-Aware DRS gate: destination hosts at or above this NIC utilization
        percentage are excluded (default: 70%). Same semantics as Invoke-HvDRS.

    .PARAMETER DestinationMemoryReserveMB
        Minimum free memory (MB) that must remain on the destination after the VM
        lands (default: 512 MB).

    .PARAMETER SoftRuleViolationPenalty
        Score penalty applied when a candidate destination would break a soft
        affinity rule (default: 25).

    .PARAMETER RuleComplianceBonus
        Score bonus applied when a candidate destination would fix an existing
        soft rule violation (default: 25).

    .PARAMETER SampleCount
        CPU counter samples to average per node when snapshotting (default: 5).

    .PARAMETER SampleIntervalSeconds
        Seconds between CPU counter samples (default: 2).

    .EXAMPLE
        # Preview the evacuation plan without moving anything
        Enter-HvDRSNodeMaintenance -ClusterName 'PROD-CLUSTER' -NodeName 'HV-NODE3' -WhatIf

    .EXAMPLE
        # Drain and pause the node
        Enter-HvDRSNodeMaintenance -ClusterName 'PROD-CLUSTER' -NodeName 'HV-NODE3'
    #>

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

        [Parameter(Mandatory)]
        [string]$NodeName,

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

        [ValidateRange(0.0, 1.0)]
        [float]$CpuWeight = 0.5,

        [ValidateRange(0.0, 1.0)]
        [float]$MemoryWeight = 0.5,

        [ValidateRange(0.0, 100.0)]
        [float]$MaxDestinationNetworkUtil = 70.0,

        [int]$DestinationMemoryReserveMB = 512,

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

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

        [int]$SampleCount = 5,

        [int]$SampleIntervalSeconds = 2
    )

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

    Write-Host "Collecting cluster snapshot..."
    $snapshot = Get-ClusterSnapshot -ClusterName $ClusterName -SampleCount $SampleCount -SampleIntervalSeconds $SampleIntervalSeconds
    $ruleSet  = Get-AffinityRuleSet -Path $RulesPath -ClusterName $ClusterName

    $vmsToEvacuate = @($snapshot.VMs | Where-Object { $_.HostNode -eq $NodeName })

    if ($vmsToEvacuate.Count -eq 0) {
        Write-Host "No running VMs found on '$NodeName'."
    } else {
        Write-Host "Evacuating $($vmsToEvacuate.Count) VM(s) from '$NodeName'..."
    }

    $results   = [System.Collections.Generic.List[PSCustomObject]]::new()
    $allPlaced = $true

    foreach ($vm in $vmsToEvacuate) {
        $dest = Find-EvacuationDestination -VM $vm -Snapshot $snapshot -ExcludeNode $NodeName `
                                           -RuleSet $ruleSet -CpuWeight $CpuWeight -MemoryWeight $MemoryWeight `
                                           -MaxDestinationNetworkUtil $MaxDestinationNetworkUtil `
                                           -DestinationMemoryReserveMB $DestinationMemoryReserveMB `
                                           -SoftRuleViolationPenalty $SoftRuleViolationPenalty `
                                           -RuleComplianceBonus $RuleComplianceBonus `
                                           -ClusterName $ClusterName

        if (-not $dest) {
            $allPlaced = $false
            Write-Warning "No valid destination found for '$($vm.VMName)' — it will remain on '$NodeName'."
            $results.Add([PSCustomObject]@{
                VMName          = $vm.VMName
                DestinationNode = $null
                Succeeded       = $false
                Message         = 'No valid destination found'
            })
            continue
        }

        $action = "Live-migrate '{0}' from '{1}' to '{2}' [projected score {3}]" -f
                  $vm.VMName, $NodeName, $dest.DestinationNode, $dest.ProjectedScore

        if (-not $PSCmdlet.ShouldProcess($vm.VMName, $action)) {
            $results.Add([PSCustomObject]@{
                VMName          = $vm.VMName
                DestinationNode = $dest.DestinationNode
                Succeeded       = $false
                Message         = 'Skipped (-WhatIf)'
            })
            continue
        }

        try {
            Move-ClusterVirtualMachineRole -Cluster $ClusterName -Name $vm.VMName `
                                           -Node $dest.DestinationNode -MigrationType Live -ErrorAction Stop | Out-Null
            Write-Host " Migrated '$($vm.VMName)' -> '$($dest.DestinationNode)' (score $($dest.ProjectedScore))"
            $results.Add([PSCustomObject]@{
                VMName          = $vm.VMName
                DestinationNode = $dest.DestinationNode
                Succeeded       = $true
                Message         = 'Migrated'
            })
        } catch {
            $allPlaced = $false
            Write-Warning "Migration of '$($vm.VMName)' to '$($dest.DestinationNode)' failed: $_"
            $results.Add([PSCustomObject]@{
                VMName          = $vm.VMName
                DestinationNode = $dest.DestinationNode
                Succeeded       = $false
                Message         = "Migration failed: $_"
            })
        }
    }

    $nodePaused = $false
    if (-not $allPlaced) {
        Write-Warning "Not all VMs could be evacuated from '$NodeName' — node will NOT be paused."
    } elseif ($PSCmdlet.ShouldProcess($NodeName, 'Pause cluster node (Suspend-ClusterNode)')) {
        try {
            Suspend-ClusterNode -Cluster $ClusterName -Name $NodeName -ErrorAction Stop | Out-Null
            Write-Host "Node '$NodeName' paused."
            $nodePaused = $true
        } catch {
            Write-Warning "Failed to pause node '$NodeName': $_"
        }
    }

    [PSCustomObject]@{
        ClusterName = $ClusterName
        NodeName    = $NodeName
        Evacuated   = $results.ToArray()
        AllPlaced   = $allPlaced
        NodePaused  = $nodePaused
    }
}

function Exit-HvDRSNodeMaintenance {
    <#
    .SYNOPSIS
        Resumes a cluster node previously paused by Enter-HvDRSNodeMaintenance (or
        any other Suspend-ClusterNode caller), allowing it to receive new cluster
        role placements again.

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

    .PARAMETER NodeName
        The cluster node to resume.

    .EXAMPLE
        Exit-HvDRSNodeMaintenance -ClusterName 'PROD-CLUSTER' -NodeName 'HV-NODE3'
    #>

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

        [Parameter(Mandatory)]
        [string]$NodeName
    )

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

    if (-not $PSCmdlet.ShouldProcess($NodeName, 'Resume cluster node (Resume-ClusterNode)')) { return }

    Resume-ClusterNode -Cluster $ClusterName -Name $NodeName -ErrorAction Stop | Out-Null
    Write-Host "Node '$NodeName' resumed."
}

function Get-HvDRSNodeMaintenanceStatus {
    <#
    .SYNOPSIS
        Reports the paused/up state of one or all nodes in a Failover Cluster —
        read-only, no snapshot collection or migrations.

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

    .PARAMETER NodeName
        Report only this node. Omit to report all nodes.

    .EXAMPLE
        Get-HvDRSNodeMaintenanceStatus -ClusterName 'PROD-CLUSTER'
    #>

    [CmdletBinding()]
    param(
        [string]$ClusterName,
        [string]$NodeName
    )

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

    $nodes = Get-ClusterNode -Cluster $ClusterName -ErrorAction Stop
    if ($NodeName) { $nodes = @($nodes | Where-Object { $_.Name -eq $NodeName }) }

    foreach ($node in $nodes) {
        [PSCustomObject]@{
            ClusterName = $ClusterName
            NodeName    = $node.Name
            State       = $node.State.ToString()
            Paused      = ($node.State.ToString() -eq 'Paused')
        }
    }
}