cmclustermaintenance.psm1

$script:localizedData = Import-LocalizedData -BaseDirectory "$PSScriptRoot\Docs\en-US" -FileName 'CmClusterMaintenance.strings.psd1'

function Get-FormattedDate
{
    [CmdletBinding()]
    param(
        [Parameter()]
        [String]
        $Format = (Get-Culture).DateTimeFormat.UniversalSortableDateTimePattern
    )

    # Returning current time based on UniversalSortableDateTimePattern
    if (-not $PSBoundParameters.ContainsKey('Format'))
    {
        $PSBoundParameters.Add('Format', $Format)
    }
    Get-Date @PSBoundParameters
}

function Resume-CmClusterNode
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [String]
        $Cluster,

        [Parameter(Mandatory = $true)]
        [String]
        $Name
    )

    Write-Verbose -Message ($script:localizedData.startingOnTarget -f $(Get-FormattedDate), 'Resume-CmClusterNode', $Cluster.ToUpper(), $Name.ToUpper())

    try
    {
        # Try to suspend the target node
        $clusterNodeParams = $PSBoundParameters
        $clusterNodeParams.ErrorAction = 'Stop'
        $clusterNodeParams.Verbose = $false
        Resume-ClusterNode @clusterNodeParams
    }
    catch
    {
        Write-Verbose -Message ($script:localizedData.failedToResume -f $(Get-FormattedDate), $Cluster.ToUpper(), $Name.ToUpper())
        throw $_
    }
}

function Start-CmClusterNode
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [String]
        $Cluster,

        [Parameter(Mandatory = $true)]
        [String]
        $Name
    )

    Write-Verbose -Message ($script:localizedData.startingOnTarget -f $(Get-FormattedDate), 'Start-CmClusterNode', $Cluster.ToUpper(), $Name.ToUpper())

    try
    {
        # Try to suspend the target node
        $clusterNodeParams = $PSBoundParameters
        $clusterNodeParams.ErrorAction = 'Stop'
        $clusterNodeParams.Verbose = $false
        Start-ClusterNode @clusterNodeParams
    }
    catch
    {
        Write-Verbose -Message ($script:localizedData.failedToStart -f $(Get-FormattedDate), $Cluster.ToUpper(), $Name.ToUpper())
        throw $_
    }
}

function Stop-CmClusterNode
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [String]
        $Cluster,

        [Parameter(Mandatory = $true)]
        [String]
        $Name
    )

    Write-Verbose -Message ($script:localizedData.startingOnTarget -f $(Get-FormattedDate), 'Stop-CmClusterNode', $Cluster.ToUpper(), $Name.ToUpper())

    try
    {
        # Try to suspend the target node
        $clusterNodeParams = $PSBoundParameters
        $clusterNodeParams.ErrorAction = 'Stop'
        $clusterNodeParams.Verbose = $false
        Stop-ClusterNode @clusterNodeParams
    }
    catch
    {
        Write-Verbose -Message ($script:localizedData.failedToStop-f $(Get-FormattedDate), $Cluster.ToUpper(), $Name.ToUpper())
        throw $_
    }
}

function Suspend-CmClusterNode
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [String]
        $Cluster,

        [Parameter(Mandatory = $true)]
        [String]
        $Name,

        [Parameter(Mandatory = $false)]
        [Switch]
        $ForceDrain
    )

    Write-Verbose -Message ($script:localizedData.startingOnTarget -f $(Get-FormattedDate), 'Suspend-CmClusterNode', $Cluster.ToUpper(), $Name.ToUpper())

    try
    {
        # Try to suspend the target node
        $clusterNodeParams = $PSBoundParameters
        $clusterNodeParams.Drain = $true
        $clusterNodeParams.ErrorAction = 'Stop'
        $clusterNodeParams.Verbose = $false
        Suspend-ClusterNode @clusterNodeParams
    }
    catch
    {
        Write-Verbose -Message ($script:localizedData.failedToSuspend-f $(Get-FormattedDate), $Cluster.ToUpper(), $Name.ToUpper())
        throw $_
    }
}

function Test-CmClusterNode
{
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [String]
        $Cluster,

        [Parameter(Mandatory = $true)]
        [String]
        $Name,

        [Parameter(Mandatory = $true)]
        [ValidateSet('Resume', 'Start', 'Stop', 'Suspend')]
        [String]
        $TestType,

        [Parameter(Mandatory = $false)]
        [Int32]
        $TimeOut = 600
    )

    Write-Verbose -Message ($script:localizedData.startingTestType -f $(Get-FormattedDate), $Cluster.ToUpper(), $Name.ToUpper(), $TestType)

    # Removing timeout & TestType / Adding ErrorAction for Get-ClusterNode
    $PSBoundParameters.Remove('TestType') | Out-Null
    $PSBoundParameters.Remove('TimeOut')  | Out-Null
    $PSBoundParameters.ErrorAction = 'Stop'

    # Setting stopwatch to capture elapsed time so that we can break out of the while based on the TimeOut value
    $stopWatch = [System.Diagnostics.Stopwatch]::StartNew()

    # Setting testResults to false
    $testResults = $false

    # Looping until $TimeOut expires or testResults equals $true; :breakOut is a label for the while statement, used with the break statement
    :breakOut while ($testResults -ne $true)
    {
        if ($stopWatch.Elapsed.TotalSeconds -ge $TimeOut) {
            $timerExpired = $true
            break breakOut
        }
        # try to query the node drain status from the clustered node
        try
        {
            $nodeStatus = Get-ClusterNode @PSBoundParameters
        }
        catch
        {
            Write-Verbose -Message ($script:localizedData.failedToQuery -f $(Get-FormattedDate), $Cluster.ToUpper(), $Name.ToUpper())
            throw $_
        }

        # Switching on TestType and returning $testResults
        switch ($TestType)
        {
            'Resume'
            {
                if ($nodeStatus.State -eq 'Up')
                {
                    # setting testResults to true and breaking out of the while
                    $testResults = $true
                    break breakOut
                }
            }

            'Start'
            {
                if ($nodeStatus.State -eq 'Paused')
                {
                    # setting testResults to true and breaking out of the while
                    $testResults = $true
                    break breakOut
                }
            }

            'Stop'
            {
                # if the node State is 'Down' setting $testResults and breaking out of the while
                if ($nodeStatus.State -eq 'Down')
                {
                    # setting testResults to true and breaking out of the while
                    $testResults = $true
                    break breakOut
                }
            }

            'Suspend'
            {
                # if the node State is 'Paused' using switch to determine DrainStatus and setting testResults bool
                if ($nodeStatus.State -eq 'Paused')
                {
                    # CLUSTER_NODE_DRAIN_STATUS (MSDN) - https://msdn.microsoft.com/en-us/library/dn622912(v=vs.85).aspx
                    switch ($nodeStatus.DrainStatus)
                    {
                        'NotInitiated'
                        {
                            # setting testResults to false and breaking out of the switch
                            $testResults = $false
                            break
                        }

                        'InProgress'
                        {
                            # setting testResults to false and breaking out of the switch
                            $testResults = $false
                            break
                        }

                        'Completed'
                        {
                            # setting testResults to true and breaking out of the while
                            $testResults = $true
                            break breakOut
                        }

                        'Failed'
                        {
                            # setting testResults to false and breaking out of the while
                            $testResults = $false
                            break breakOut
                        }

                        Default
                        {
                            # if the above four conditions do not apply; setting testResults to false
                            $testResults = $false
                        }
                    }
                }
            }
        }
        # sleeping to limit the queries for node status
        Start-Sleep -Milliseconds 750
    }

    # return true/false to the calling function
    Write-Verbose -Message ($script:localizedData.currentNodeState -f $(Get-FormattedDate), $Cluster.ToUpper(), $Name.ToUpper(), $($nodeStatus.State), $($nodeStatus.DrainStatus))
    if ($timerExpired)
    {
        Write-Verbose -Message ($script:localizedData.timerExpired -f $(Get-FormattedDate), $TimeOut, $Cluster.ToUpper(), $Name.ToUpper())
    }
    return $testResults
}

<#
    .SYNOPSIS
    Invoke-CmClusterMaintenance is used to start node maintenance on all nodes in a cluster, coordinating node drain and reboot (optional).

    .DESCRIPTION
    Invoke-CmClusterMaintenance is used to start node maintenance on all nodes in a cluster, coordinating node drain and reboot (optional).
    The function will query all cluster nodes using Get-ClusterNode with the given cluster name. Once it receives the cluster nodes it will loop through all nodes within the cluster, pausing, stopping, performing an action (specified via the Scriptblock parameter), rebooting (if specified via the Reboot switch parameter), starting, resuming and detecting success in each step of the coordinated process. If any one of the steps is not successful, the entire coordinated process will fail, if the Verbose parameter is used, more detial around possible causes for the failure can be used for troubleshooting purposes.

    .PARAMETER Cluster
    Specifies the name of the cluster on which to run this function. The function will loop through all nodes of the specified cluster invoking the action contained within the scriptblock parameter.

    .PARAMETER ForceDrain
    Specifies that workloads are moved from a node even in the case of an error.

    .PARAMETER ScriptBlock
    Specifies the commands to run. Enclose the commands in braces ( { } ) to create a script block. This parameter is required.

    By default, any variables in the command are evaluated on the remote computer.

    .PARAMETER ArgumentList
    Supplies the values of local variables in the command. The variables in the command are replaced by these values before the command is run on the remote computer. Enter the values in a comma-separated list. Values are associated with variables in the order that they are listed. The alias for ArgumentList is "Args".

    The values in ArgumentList can be actual values, such as "1024", or they can be references to local variables, such as "$max".

    To use local variables in a command, use the following command format:

    {param($<name1>[, $<name2>]...) <command-with-local-variables>} -ArgumentList <value> -or- <local-variable>

    or

    $localVariable = 'Data is stored here'
    Invoke-CmClusterMaintenance -Cluster HV-CLUS1 -ScriptBlock {$using:localVariable} -ArgumentList $localVariable -RebootNode

    The "param" keyword lists the local variables that are used in the command. The ArgumentList parameter supplies
    the values of the variables, in the order that they are listed.

    .PARAMETER TimeOut
    Specifies the number of seconds in which the function will wait for the drain action to complete for a node. If the TimeOut expires, then the function will fail for the cluster.

    *** In it's current state, if a drain fails, then the function will stop and take no further action for the entire cluster if you wish to modify this behavior, it is possible, but will require additional logic/modification. ***

    .PARAMETER RebootNode
    Specifying the RebootNode switch parameter will cause the node to reboot after the scriptblock contents is executed against the node.

    .EXAMPLE
    $scriptBlock = {"Action to perform on $env:ComputerName"}
    Invoke-CmClusterMaintenance -Cluster HV-CLUS1 -ScriptBlock $scriptBlock -RebootNode

    This example will execute the contents of the scriptblock then reboot the node, one at a time, for all nodes in the HV-CLUS1 cluster.

    .NOTES
    Created by Brian Wilhite
#>

function Invoke-CmClusterMaintenance
{
    [CmdletBinding(DefaultParameterSetName = 'ScriptBlock')]
    param(
        [Parameter(Mandatory = $true)]
        [String]
        $Cluster,

        [Parameter()]
        [Switch]
        $ForceDrain,

        [Parameter(Mandatory = $true, ParameterSetName = 'ScriptBlock')]
        [ScriptBlock]
        $ScriptBlock,

        [Parameter(Mandatory = $true, ParameterSetName = 'FilePath')]
        [ValidateScript({Test-Path -Path $_})]
        [String]
        $FilePath,

        [Parameter()]
        [System.Object[]]
        $ArgumentList,

        [Parameter()]
        [Int32]
        $TimeOut = 600,

        [Parameter()]
        [Switch]
        $RebootNode
    )

    Write-Verbose -Message ($script:localizedData.startingOnCluster -f $(Get-FormattedDate), 'Invoke-CmClusterMaintenance', $Cluster.ToUpper())

    # Get Cluster Nodes
    try
    {
        $clusterNodes = Get-ClusterNode -Cluster $Cluster -ErrorAction Stop
    }
    catch
    {
        throw $_
    }

    # Removing RebootNode, Scriptblock, FilePath & TimeOut from PSBoundParams
    $PSBoundParameters.Remove('RebootNode')   | Out-Null
    $PSBoundParameters.Remove('ScriptBlock')  | Out-Null
    $PSBoundParameters.Remove('FilePath')     | Out-Null
    $PSBoundParameters.Remove('ArgumentList') | Out-Null
    $PSBoundParameters.Remove('TimeOut')      | Out-Null

    # If the ForceDrain parameter was specified remove it from PSBoundParams and add ForceDrain = $true to the hashtable for splatting
    if ($PSBoundParameters.Remove('ForceDrain'))
    {
        [hashtable]$suspendCmClusterNodeParams = $PSBoundParameters
        $suspendCmClusterNodeParams.ForceDrain = $true
    }
    else
    {
        $suspendCmClusterNodeParams = $PSBoundParameters
    }

    # Loop, using for to track cluster progress
    for ($i = 0; $i -lt $clusterNodes.Count; $i++)
    {
        # Setting up the Write-Progress parameter - Splatting
        $writeProgressPrcent = [Math]::Round($i / $clusterNodes.Count * 100)
        $writeProgressParams = @{
            Activity        = $script:localizedData.progActvFirstMsg -f $Cluster.ToUpper(), $clusterNodes[$i].Name
            Status          = $script:localizedData.progStatusMsg -f $($i + 1), $clusterNodes.Count, $writeProgressPrcent
            PercentComplete = $writeProgressPrcent
            Id              = 1
        }
        Write-Progress @writeProgressParams

        # Adding the current cluster node to PSBoundParameters/SuspendCmClusterNodeParams
        $PSBoundParameters.Name          = $clusterNodes[$i].Name
        $suspendCmClusterNodeParams.Name = $clusterNodes[$i].Name

        try
        {
            # Suspending current cluster node
            Write-Progress -Activity ($script:localizedData.progActvSecondMsg -f $clusterNodes[$i].Name) -ParentId 1 -PercentComplete (1 / 8 * 100)
            Suspend-CmClusterNode @suspendCmClusterNodeParams | Out-Null

            # Testing successful cluster node SUSPEND operation
            Write-Progress -Activity ($script:localizedData.progActvThirdMsg -f $clusterNodes[$i].Name) -ParentId 1 -PercentComplete (2 / 8 * 100)
            if (Test-CmClusterNode @PSBoundParameters -TestType Suspend -TimeOut $TimeOut)
            {
                # If cluster node suspend was successful, stop the cluster node
                Write-Progress -Activity ($script:localizedData.progActvFourthMsg -f $clusterNodes[$i].Name) -ParentId 1 -PercentComplete (3 / 8 * 100)
                Stop-CmClusterNode @PSBoundParameters | Out-Null
            }
            else
            {
                # Otherwise throw a time stamped error
                throw ($script:localizedData.failedfunction -f $(Get-FormattedDate), 'Suspend-CmClusterNode', $Cluster.ToUpper(), $PSBoundParameters.Name)
            }

            # Testing successful cluster node STOP operation
            Write-Progress -Activity ($script:localizedData.progActvFifthMsg -f $clusterNodes[$i].Name) -ParentId 1 -PercentComplete (4 / 8 * 100)
            if (Test-CmClusterNode @PSBoundParameters -TestType Stop -TimeOut $TimeOut)
            {
                # Setting up Invoke-Command parameters
                Write-Progress -Activity ($script:localizedData.progActvSixthMsg -f $clusterNodes[$i].Name) -ParentId 1 -PercentComplete (5 / 8 * 100)
                $invokeCmdParams = @{
                    ComputerName = $clusterNodes[$i].Name
                    ErrorAction  = 'Stop'
                }

                # If the ScriptBlock param was specified, then add it to the invokeCmdParams hashtable for splatting
                if ($ScriptBlock)
                {
                    $invokeCmdParams.ScriptBlock = $ScriptBlock
                }

                # If the FilePath param was specified, then add it to the invokeCmdParams hashtable for splatting
                if ($FilePath)
                {
                    $invokeCmdParams.FilePath = $FilePath
                }

                # If the ArgumentList param was specified, then add it to the invokeCmdParams hashtable for splatting
                if ($ArgumentList)
                {
                    $invokeCmdParams.ArgumentList = $ArgumentList
                }

                # Invoking the specified scriptblock and parameters on the current cluster node
                Invoke-Command @invokeCmdParams

                # If the RebootNode param was specified, then Restart-Computer on the current cluster node and wait for PowerShell remoting on the target node before continuing
                if ($RebootNode)
                {
                    Write-Verbose -Message ($script:localizedData.startingOnTarget -f $(Get-FormattedDate), 'Restart-Computer', $Cluster.ToUpper(), $Name)
                    Restart-Computer -ComputerName $PSBoundParameters.Name -Wait -For PowerShell -Force
                }
            }
            else
            {
                throw ($script:localizedData.failedfunction -f $(Get-FormattedDate), 'Stop-CmClusterNode', $Cluster.ToUpper(), $PSBoundParameters.Name)
            }

            # Start the cluster service on the current cluster node
            Write-Progress -Activity ($script:localizedData.progActvSeventhMsg -f $clusterNodes[$i].Name) -ParentId 1 -PercentComplete (6 / 8 * 100)
            Start-CmClusterNode @PSBoundParameters | Out-Null

            # Testing successful cluster service START operation
            if (Test-CmClusterNode @PSBoundParameters -TestType Start -TimeOut $TimeOut)
            {
                # if cluster node START operation was successful, then attempt to resume the cluster node (Unpause)
                Write-Progress -Activity ($script:localizedData.progActvEightMsg -f $clusterNodes[$i].Name) -ParentId 1 -PercentComplete (7 / 8 * 100)
                Resume-CmClusterNode @PSBoundParameters | Out-Null
            }
            else
            {
                throw ($script:localizedData.failedfunction -f $(Get-FormattedDate), 'Start-CmClusterNode', $Cluster.ToUpper(), $PSBoundParameters.Name)
            }

            # Testing successful cluster RESUME operation
            Write-Progress -Activity ($script:localizedData.progActvNinthMsg -f $clusterNodes[$i].Name) -ParentId 1 -PercentComplete (8 / 8 * 100)
            if (Test-CmClusterNode @PSBoundParameters -TestType Resume -TimeOut $TimeOut)
            {
                Write-Verbose -Message ($script:localizedData.invokeCmComplete -f $(Get-FormattedDate), $Cluster.ToUpper(), $PSBoundParameters.Name)
            }
            else
            {
                throw ($script:localizedData.failedfunction -f $(Get-FormattedDate), 'Resume-CmClusterNode', $Cluster.ToUpper(), $PSBoundParameters.Name)
            }
        }
        catch
        {
            Write-Verbose -Message ($script:localizedData.invokeCmFailed -f $(Get-FormattedDate), $Cluster.ToUpper(), $PSBoundParameters.Name)
            throw $_
        }
    }
}