AutoPatchDSC.psm1

Get-ChildItem (Join-Path $PSScriptRoot *.ps1) | % { . $_.FullName}

#region AutoPatchInstall Class
    <#
        --------------------------------------------------------------------------------
        This resource provides automated patching during a defined patch window.
        It is designed for use with WaitFor* constructs to allow reboots of different
        servers in a farm to reboot at different times to maintain high availability of
        a particular service such as SQL or SharePoint. However, the module should
        work with farms of any type.
    #>


    [DscResource()]
    class AutoPatchInstall {

    #region AutoPatchInstall PROPERTIES
        <# Provides a unique name for the instance of the resource. #>
            [DscProperty(Key)]
            [string] $Name
          
        <# Provides a starting date and time for the maintenance window. Patching and
            reboot can always occur after this time until the $PatchWindowEnd date and time. #>

            [DscProperty()]
            [Nullable[datetime]] $PatchWindowStart

        <# Provides a ending date and time for the maintenance window. Patching and reboot
            can always occur before this time and after the $PatchWindowStart date and time. #>

            [DscProperty()]
            [Nullable[datetime]] $PatchWindowEnd

        <# Provides a preparation window before the PatchWindowStart when patches can be
            installed, but reboots will not occur until the maintenance window, unless
            $RebootDuringPreFlight is set to $True, then they can also occur during the
            preflight window. Parameter accepts a datetime object. #>

            [DscProperty()]
            [Nullable[datetime]] $PreflightWindowStart

        <# Path to file for logging. Must be local.#>
            [DscProperty()]
            [String]$LogFile

        <# Gives the option to install patches during preflight instead of just downloading them. #>
            [DscProperty()]
            [bool]$InstallPatchesDuringPreflight = $True

        <# Read-only property to report the status of the WSUS installer (idle/busy/offline) #>
            [DscProperty(NotConfigurable)]
            [string]$WsusInstallerStatus

        <# Read-only property to report the which KBs by number are pending installation. #>
            [DscProperty(NotConfigurable)]
            [string]$UpdatesPendingInstall
    
        <# Read-only property to report whether updates installed by WSUS are pending a reboot. #>
            [DscProperty(NotConfigurable)]
            [bool]$PendingRebootRequired

        <# Read-only property that servers as a point in time when DSC configuration ran. #>
            [DscProperty(NotConfigurable)]
            [datetime]$RunTime = $(Get-Date)

        <# Read-only property for whether the server is in or out of the preflight window at runtime. #>
            [DscProperty(NotConfigurable)]
            [bool]$inPreFlightWindow

        <# Read-only property for whether the server is in or out of the maintenance window at runtime. #>
            [DscProperty(NotConfigurable)]
            [bool]$inMaintenanceWindow

        <# Read-only identifying last bootuptime of the server. #>
            [DscProperty(NotConfigurable)]
            [String]$LastBootUpTime

        <# Non-DscProperty - tracks whether the wsus installer is busy or not. #>
            [bool]$WsusInstallerBusy

        <# Non-DscProperty - contains object returned by get-wulist with information about needed patches #>
            [Object]$wuList

        <# Non-DscProperty - tracks whether the wsus installer is busy or not. #>
            [int]$patchInstallPassNumber
    #endregion AutoPatchInstall PARAMETERS

    #region AutoPatchInstall Set
        <# When a server is found out of compliance (e.g. in a maintenance window + needing patches) this Set
            method will cause the server to download patches, patch the server and reboot. #>

        [void] Set()
        {
            $this.initialize()

            #Just in case, if there are no updates, no pending reboot and the installer isn't busy, don't do anything!
            If ( ($this.wulist -eq $null) -and !$this.pendingRebootRequired -and !$this.WsusInstallerBusy)
            {
                Write-AutoPatchLog '[AutoPatchInstall:Set] Server does not need patches.'
            }
            #If there ARE updates, and there are no reboots pending, install patches
            ElseIf (($this.wulist -ne $Null) -and !$this.pendingRebootRequired)
            {
                Write-AutoPatchLog '[AutoPatchInstall:Set] Server needs patches.'
            
                If     ($this.inPreFlightWindow -and $this.InstallPatchesDuringPreflight)  {$this.installPatches()}
                elseif ($this.inPreFlightWindow -and !$this.InstallPatchesDuringPreflight) {$this.downloadPatches()}
                elseif ($this.inMaintenanceWindow)                                         {$this.installPatches()}
                else   {Write-AutoPatchLog '[AutoPatchInstall:Set] Cannot install patches until preflight or maintenance window.'}
            } 
            ElseIf ($this.WsusInstallerBusy)
            {
                Write-AutoPatchLog '[AutoPatchInstall:Set] Server is busy installing updates.'
            }
            ElseIf ($this.pendingRebootRequired)
            {
                Write-AutoPatchLog '[AutoPatchInstall:Set] Server is pending a reboot.'
            }
        } 
    #endregion AutoPatchInstall SET

    #region AutoPatchInstall TEST
        <# This method tests the compliance of the automated patching configuration and reports $True or $False #>
        [bool] Test()
        {
            $this.initialize()

            If ( ($this.wulist -eq $null) -and !$this.pendingRebootRequired -and !$this.WsusInstallerBusy)
            {   #no updates to install and no reboot pending
                Write-AutoPatchLog '[AutoPatchInstall:Test] Server does not need patches.'
                Return $True
            }            
            ElseIf (($this.wulist -ne $Null) -and !$this.pendingRebootRequired) {
                Write-AutoPatchLog "[AutoPatchInstall:Test] Server needs patches: $($this.updatesPendingInstall)"
                Return $false
            }
            ElseIf ($this.WsusInstallerBusy) {
                Write-AutoPatchLog '[AutoPatchInstall:Test] Server is installing updates.'
                Return $false
            }
            ElseIf ($this.pendingRebootRequired) {
                Write-AutoPatchLog '[AutoPatchInstall:Test] Server is pending reboots.'
                Return $false
            }
            Else {
                Write-AutoPatchLog '[AutoPatchInstall:Test] The AutoPatch DSC Script resource could not determine the patch state of the server.'
                Return $False
            }
        }
     #endregion AutoPatchInstall Test

    #region AutoPatchInstall GET
        <# This method gets the status of patch need and defined maintenance windows. #>
        [AutoPatchInstall] Get()
        {
            $this.initialize()
            Return $this
        }
    #endregion AutoPatchInstall GET

    #region AutoPatchInstall HELPER FUNCTIONS
        <# Function to initialize AutoPatch evaluating window status and what updates are pending installation. #>
        [void] initialize(){
            if (-not (Get-Module PSWindowsUpdate)) {Import-Module PSWindowsUpdate}

            $this.LastBootUpTime        = $(Get-CimInstance -ClassName win32_OperatingSystem).lastbootuptime  #Note: this is producing extra output; could be improved by supressing it becuase it's not helpful. Try: -Verbose 4>&1 | Out-Null
            $this.inPreFlightWindow     = ($this.RunTime -ge $this.preflightStart)   -and ($this.RunTime -lt $this.patchWindowStart)
            $this.inMaintenanceWindow   = ($this.RunTime -ge $this.patchWindowStart) -and ($this.RunTime -lt $this.patchWindowEnd)
            $this.wuList                = Get-WUList -NotCategory 'Definition Updates' # this produces extra output, but it's helpful, so I'm leaving it for now.
            $this.UpdatesPendingInstall = [String]$(($this.wuList).kb)
            $this.PendingRebootRequired = $(Get-WURebootStatus -Silent)
            $this.WsusInstallerStatus   = Get-WUInstallerStatus
            $this.WsusInstallerBusy     = (Get-WUInstallerStatus -Silent)

            if ($this.inPreFlightWindow -or $this.inMaintenanceWindow) {
                Write-AutoPatchLog "Runtime: $($this.RunTime)"
                Write-AutoPatchLog "Last BootUp Time: $($this.LastBootUpTime)"
                Write-AutoPatchLog "In PreFlight Window: $($this.inPreFlightWindow)"
                Write-AutoPatchLog "In Maintenance Window: $($this.inMaintenanceWindow)"
                Write-AutoPatchLog "Updates Pending Installation: $($this.UpdatesPendingInstall)"
                Write-AutoPatchLog "Pending Reboot Required: $($this.PendingRebootRequired)"
                Write-AutoPatchLog "WSUS Installer Status: $($this.WsusInstallerStatus)"
            }
        }


        #Helper Function to install patches
        [void]installPatches() {
            $this.patchInstallPassNumber++
                
            Write-AutoPatchLog "Installing Patches: `n$($this.updatesPendingInstall)"
                
            $output = Get-WUInstall -IgnoreReboot -AcceptAll

            if (!$?) {
                Write-AutoPatchLog $output #error was with 59 WC01 Prod... check the event log for wsus errors.
            }
                
            # Recursive call to expedite further patching or a reboot if needed;
            # Otherwise, the subsequent patching or reboot won't go until the next DSC cycle.
            # The recursion should always terminate, however the patchInstallPassNumber check
            # is a simple precaution to prevent runaway recursion.
            if ($this.patchInstallPassNumber -lt 3) { $this.set() }
            #>
        }

        #Helper Function to download patches
        [void]downloadPatches() {
            Write-AutoPatchLog "Downloading Patches: `n$($this.updatesPendingInstall)"
            $output = Get-WUInstall -DownloadOnly -AcceptAll
            if (!$?) {
                Write-AutoPatchLog $output
            }
        }
    #endregion AutoPatchInstall HELPER FUNCTIONS
    }
#endregion AutoPatchInstall

#region AutoPatchServices Class
    <#
        This resource makes sure that all specified services are running so that for
        automated patching with other AutoPatchDSC resources occurs successfully.
    #>


    [DscResource()]
    class AutoPatchServices {
        <# Provides a unique name for the instance of the resource. #>
            [DscProperty(Key)]
            [string] $Name

        <# Provides a list of services which should be running in order for AutoPatch to proceed #>
            [DscProperty()]
            [string[]] $ServicesRequiredToContinuePatching

            [DscProperty()]
            [string[]] $ServicesToAttemptStarting = (Get-WmiObject Win32_Service | Where-Object { $_.StartMode -eq 'Auto'}).Name

            [DscProperty(NotConfigurable)]
            [String[]]$currentServiceState

        #region AutoPatchServices Set
            <# This method will attempt to start all services defined in the $ServicesRequiredToContinuePatching array #>
            [void] Set()
            {
                if ($this.ServicesRequiredToContinuePatching) {
                    Write-AutoPatchLog "[AutoPatchServices] Attempting to start required services ($this.ServicesRequiredToContinuePatching)"
                
                    Foreach ($service in $this.ServicesRequiredToContinuePatching) {
                        try {
                            (Get-Service -Name $service -ErrorAction Stop | ? Status -ne 'Running').Name | Start-Service -ErrorAction Stop
                        } catch {
                            Write-AutoPatchLog "[AutoPatchServices] Failed to start required service $service."
                        }
                    }
                }
            }
        #endregion AutoPatchServices Set

        #region AutoPatchServices Test
            <# This method will check if all services defined in the $services array are started#>
            [bool] Test()
            {
                <#
                $servicesString = $null
                (Get-Service -Name $this.ServicesRequiredToContinuePatching | ? Status -eq 'Running').Name | % {$servicesString += "$_, "}
                $currentRunningServices = $servicesString.Trim(', ')
                #>

                $currentRunningServices  = (Get-Service | ? Status -eq 'Running').Name
                $optionalServicesToStart = (Compare-Object $This.ServicesToAttemptStarting $currentRunningServices | ? SideIndicator -eq '<=').inputobject

                if ($optionalServicesToStart) {
                    Write-AutoPatchLog "[AutoPatchServices] Attempting to start optional services $optionalServicesToStart"
                    
                    Foreach ($service in $optionalServicesToStart) {
                        try {
                            (Get-Service -Name $service -ErrorAction Stop | ? Status -ne 'Running').Name | Start-Service -ErrorAction Stop
                        } catch {
                            Write-AutoPatchLog "[AutoPatchServices] Failed to start optional service $service."
                        }
                    }
                }

                if ($this.ServicesRequiredToContinuePatching) {
                    $requiredServicestoStart = (Compare-Object $This.ServicesRequiredToContinuePatching $currentRunningServices | ? SideIndicator -eq '<=').Name

                    if ($requiredServicestoStart) {
                        Write-AutoPatchLog "[AutoPatchServices] The following required services are not running $requiredServicestoStart."
                        Return $false
                    } else {
                        Return $true
                    }
                } else {
                    Return $true
                }
            } 
        #endregion AutoPatchServices Test

        #region AutoPatchServices Get
            <# This method will return information about whether the services are started#>
            [AutoPatchServices] Get()
            {
                $this.currentServiceState = Get-Service -Name $this.ServicesRequiredToContinuePatching | sort -Property Status
                Return $this
            }
        #endregion AutoPatchServices Get
    }
#endregion AutoPatchServices Class

#region AutoPatchReboot Class
    <#
        --------------------------------------------------------------------------------
        This resource provides automated rebooting of machines that have been patched
        during a defined patch window. It's designed for concerted use with the
        AutoPatchInstall DSC resource and with WaitFor* constructs to allow reboots
        servers in a farm to reboot at different times to maintain high availability of
        a particular service such as SQL, SharePoint, etc.
    #>


    [DscResource()]
    class AutoPatchReboot {

    #region AutoPatchReboot PROPERTIES
        <# Provides a unique name for the instance of the resource. #>
            [DscProperty(Key)]
            [string] $Name
          
        <# Provides a starting date and time for the maintenance window. Patching and
            reboot can always occur after this time until the $PatchWindowEnd date and time. #>

            [DscProperty()]
            [Nullable[datetime]] $PatchWindowStart

        <# Provides a ending date and time for the maintenance window. Patching and reboot
            can always occur before this time and after the $PatchWindowStart date and time. #>

            [DscProperty()]
            [Nullable[datetime]] $PatchWindowEnd

        <# Provides a preparation window before the PatchWindowStart when patches can be
            installed, but reboots will not occur until the maintenance window, unless
            $RebootDuringPreFlight is set to $True, then they can also occur during the
            preflight window. Parameter accepts a datetime object. #>

            [DscProperty()]
            [Nullable[datetime]] $PreflightWindowStart

        <# When AutoPatchReboot runs on a hypervisor, by default the hosted VMs will follow
            their Automatic Stop and Automatic Start settings in Hyper-V Manager. This
            setting allows you to force suspend the VMs. This will prevent shutdown
            #>

            [DscProperty()]
            [ValidateSet('Save','Shutdown','')]
            [String] $HyperVisorVMPreference

        <# By default, AutoPatch will only cause servers to reboot during the maintenance
            window. Set RebootDuringPreFlight = $True if you want the AutoPatch to also reboot
            servers during the preflight window. This might be useful for servers like an SQL
            witness server. #>

            [DscProperty(Mandatory)]
            [ValidateSet('PreFlightWindowAutomaticReboot','MaintenanceWindowAutomaticReboot','AnyWindowAutomaticReboot','ManualReboot')]
            [String] $RebootMode

        <# The maximum number of reboots the server can have as executed by AutoPatchReboot
            in a given patch window. #>

            [DscProperty()]
            [int]$maxReboots = 3

        <# Read-only identifying last bootuptime of the server. #>
            [DscProperty()]
            [String]$LogFile

        <# Read-only property to report the which KBs by number are pending installation. #>
            [DscProperty(NotConfigurable)]
            [string]$UpdatesPendingInstall

        <# Read-only property to report the status of the WSUS installer (idle/busy/offline) #>
            [DscProperty(NotConfigurable)]
            [string]$WsusInstallerStatus

        <# Non-DscProperty - tracks whether the wsus installer is busy or not. #>
            [bool]$WsusInstallerBusy

        <# Read-only property to report whether updates installed by WSUS are pending a reboot. #>
            [DscProperty(NotConfigurable)]
            [bool]$PendingRebootRequired

        <# Read-only property that servers as a point in time when DSC configuration ran. #>
            [DscProperty(NotConfigurable)]
            [datetime]$RunTime = $(Get-Date)

        <# Read-only property for whether the server is in or out of the preflight window at runtime. #>
            [DscProperty(NotConfigurable)]
            [bool]$inPreFlightWindow

        <# Read-only property for whether the server is in or out of the maintenance window at runtime. #>
            [DscProperty(NotConfigurable)]
            [bool]$inMaintenanceWindow

        <# Read-only identifying last bootuptime of the server. #>
            [DscProperty(NotConfigurable)]
            [String]$LastBootUpTime
                
        <# Non-DscProperty - contains object returned by get-wulist with information about needed patches #>
            [Object]$wuList

    #endregion AutoPatchReboot PROPERTIES

    #region AutoPatchReboot SET
        <# When a server is found out of compliance (e.g. in a maintenance window + needs reboot) this Set
            method will cause the server to reboot.
        #>

        [void] Set()
        {
            $this.initialize()

            if     ($this.inPreflightWindow   -and $this.RebootMode -eq 'AnyWindowAutomaticReboot')         {$this.executeReboot()}
            elseif ($this.inPreflightWindow   -and $this.RebootMode -eq 'PreFlightWindowAutomaticReboot')   {$this.executeReboot()}
            elseif ($this.inPreflightWindow   -and $this.RebootMode -eq 'MaintenanceWindowAutomaticReboot') {Write-AutoPatchLog 'Cannot execute a reboot until the maintenance windows is reached.'}
            elseif ($this.inMaintenanceWindow -and $this.RebootMode -eq 'AnyWindowAutomaticReboot')         {$this.executeReboot()}
            elseif ($this.inMaintenanceWindow -and $this.RebootMode -eq 'PreFlightWindowAutomaticReboot')   {Write-AutoPatchLog 'Computer is set to reboot during preflight window, but the window has expired.'}
            elseif ($this.inMaintenanceWindow -and $this.RebootMode -eq 'MaintenanceWindowAutomaticReboot') {$this.executeReboot()}
            elseif ($this.RebootMode -eq 'ManualReboot')                                                    {Write-AutoPatchLog 'Computer is set to be manually rebooted.'}
            else   {Write-AutoPatchLog '[AutoPatchReboot:Set] Reboot action cannot be performed outside Preflight and Maintenance windows.' -writeWarning}
        } 
    #endregion AutoPatchReboot SET

    #region AutoPatchReboot TEST
        <# This method tests the compliance of the automated patching configuration and reports $True or $False #>
        [bool] Test()
        {
            $this.initialize()

            If ( $this.PendingRebootRequired        -and `
                ($this.WsusInstallerBusy -ne $true) -and `
                ($this.RebootMode -ne 'ManualReboot'))
            {
               #The test failed; the node needs a reboot and is in a state safe for reboot
               Return $False
            } else {
                Return $true
            }
        }
    #endregion AutoPatchReboot TEST

    #region AutoPatchReboot GET
        <# This method gets the status of patch need and defined maintenance windows. #>
        # Developer's Note: This output could be better by return a custom formatted hash table: $Configuration = [hashtable]::new()
        [AutoPatchReboot] Get()
        {
            $this.initialize()
            Return $this
        }
    #endregion AutoPatchReboot GET

    #region AutoPatchReboot HELPER FUNCTIONS
        <# Function to initialize AutoPatch evaluating window status and what updates are pending installation. #>
        [void] initialize(){
            if (-not (Get-Module PSWindowsUpdate)) {Import-Module PSWindowsUpdate}

            if ($this.LogFile) {$Global:AutoPatchDSCLogFile = $this.LogFile}

            $this.LastBootUpTime        = $(Get-CimInstance -ClassName win32_OperatingSystem).lastbootuptime  #Note: this is producing extra output; could be improved by supressing it becuase it's not helpful. Try: -Verbose 4>&1 | Out-Null
            $this.inPreFlightWindow     = ($this.RunTime -ge $this.preflightStart)   -and ($this.RunTime -lt $this.patchWindowStart)
            $this.inMaintenanceWindow   = ($this.RunTime -ge $this.patchWindowStart) -and ($this.RunTime -lt $this.patchWindowEnd)
            $this.PendingRebootRequired = $(Get-WURebootStatus -Silent)
            $this.WsusInstallerStatus   = Get-WUInstallerStatus
            $this.WsusInstallerBusy     = (Get-WUInstallerStatus -Silent)
            $this.wuList                = Get-WUList -NotCategory 'Definition Updates' # this produces extra output, but it's helpful, so I'm leaving it for now.
            $this.UpdatesPendingInstall = [String]$(($this.wuList).kb)

            if ($this.inPreFlightWindow -or $this.inMaintenanceWindow) {
                Write-AutoPatchLog "[AutoPatchReboot:Initialize] Last BootUp Time: $($this.LastBootUpTime)"
                Write-AutoPatchLog "[AutoPatchReboot:Initialize] In PreFlight Window: $($this.inPreFlightWindow)"
                Write-AutoPatchLog "[AutoPatchReboot:Initialize] In Maintenance Window: $($this.inMaintenanceWindow)"
                Write-AutoPatchLog "[AutoPatchReboot:Initialize] Updates Pending Installation: $($this.UpdatesPendingInstall)"
                Write-AutoPatchLog "[AutoPatchReboot:Initialize] WSUS Installer Status: $($this.WsusInstallerStatus)"
                Write-AutoPatchLog "[AutoPatchReboot:Initialize] Pending Reboot Required: $($this.PendingRebootRequired)"
            }
        }

        [void] executeReboot() {
            $window = ''
            if ($this.inMaintenanceWindow) {$window = 'MaintenanceWindow'} elseif ($this.inPreFlightWindow) {$window = 'PreflightWindow'}
            Write-AutoPatchLog "Reboot executed during $window with RebootMode of $($this.RebootMode)"

            #If the computer is a hypervisor, we must first handle the VMs
            if ((Get-WindowsFeature Hyper-V).'InstallState' -eq 'Installed') {
                if (-not (get-module hyper-v)) {Import-Module Hyper-V -ErrorAction Continue}
    
                switch ($this.HyperVisorVMPreference) {
                    'Save' {
                        Write-AutoPatchLog 'Saving VMs on Hyper-V Host'
                        Get-VM | Save-VM

                        $checkVMS = (get-vm).where{$_.state -eq 'running'}
                        if ($checkVMs) {
                            Write-AutoPatchLog "Some VMs on the host did not save correctly. $($checkVMs.Name)" -writeError
                        } else {
                            Write-AutoPatchLog "All VMs were successfully saved."
                        }
                    }
                    'Shutdown' {
                        Write-AutoPatchLog 'Shutting down VMs on Hyper-V Host.'
                            
                        $jobs = Get-VM | Stop-VM -Force -AsJob
                        $jobs | Wait-Job

                        $checkVMS = (get-vm).where{$_.state -eq 'running'}
                        if ($checkVMs) {
                            Write-AutoPatchLog "Some VMs on the host did not shutdown correctly. $($checkVMs.Name)" -writeError
                        } else {
                            Write-AutoPatchLog "SUCCESS: All VMs were successfully shutdown."
                        }
                    }
                    default {
                        Write-AutoPatchLog "No HyperVisorVMPreference action was specified so if any VMs are present they will follow the Automatic Stop and Automatic Start settings specified in Hyper-V. If you do not wish to use these settings, set HyperVisorVMPreference='Suspend' in the AutoPatchReboot configuration and create xVMHyperV resources to ensure the desired VMs are running." -writeWarning
                    }
                } #end Switch
            } #end if
            
            [array]$rebootEvents = (Get-WinEvent -FilterHashtable @{logname='System'; id=1074; StartTime=$this.PreflightWindowStart} -ErrorAction SilentlyContinue).Where{$_.message -match 'DSC is restarting the computer'}

            if ($rebootEvents.Count -lt $this.maxReboots) {
                Write-AutoPatchLog '[AutoPatchReboot:executeReboot] EXECUTING REBOOT - Setting DSC Machine Status Reboot to 1. DSC will shortly reboot this computer.' -writeWarning
                $global:DSCMachineStatus = 1
            } else {
                Write-AutoPatchLog "[AutoPatchReboot:executeReboot] FAILURE: AUTOPATCHDSC WILL NOT EXECUTE REBOOT - AutoPatchDSC has already rebooted this server $($rebootEvents.Count) times since the start of the preflight Window. The maximum number of reboots is $($this.maxReboots). The server will not be rebooted now to prevent reboot loops. This indicates a patch may be failing to install properly -- please troubleshoot this server manually." -writeError
            }

        } #end executeReboot()

    #endregion AutoPatchReboot HELPER FUNCTIONS
    }
#endregion AutoPatchReboot Class