Event handling for automations | Quisitive
Event handling for automations
November 5, 2021
Matthew Dowst
Learn how to make adaptable automations in PowerShell.

This post is an except from my book Practical Automation with PowerShell. This book takes you beyond simple scripting basic and shows you how you can use PowerShell to build enterprise ready automations for a huge number of admin and DevOps tasks. This excerpt comes from the chapter on making adaptable automations. In addition to the event handling shown here, the chapter shows you how to create dynamic functions and use external data to control the execution of your scripts.

Event handling for automations

One of the toughest challenges you will face with automation is figuring out how to make things as efficient and maintainable as possible. The best way to achieve that is by making your code as smart and adaptable as possible. To reap the full potential and cost savings of automation, you must be able to build event handling into your scripts. Any time you have to go in after an automation runs and fix something, make a note of it. If you see something that happens on a regular basis, it is a good chance you need to add some event handling to your script.

Take, for example, the scenario of stopping and disabling services. Anyone who has worked with Windows knows that you can do this with a few lines of code.

Get-Service -Name Spooler | 
     Set-Service -StartupType Disabled -PassThru | 
     Stop-Service -PassThru

Now think about what happens if the service is not found. For example, if you pass the name of a service that does not exist to the Get-Service cmdlet, it will return an error. But, if the service does not exist, then there is nothing to stop or disable. So, is it really an error? I would say it is not an error but is something that you should record.

To prevent your script from throwing an error, you can choose to suppress errors on that command using the parameter -ErrorAction SilentlyContinue. However, when you do this, there is no way for you to know for sure that the service does not exist. You are just assuming that the reason for the error was that the service does not exist. But when someone suppresses the error message, there is no way to know for sure. For example, it could also throw an error if you do not have the appropriate permissions. The only way to know for sure is to capture and evaluate the error message using a try/catch block.

Using try/catch blocks for event handling

By default, a PowerShell script will stop executing when a command throws a terminating error except when that error happens inside of a try block. When there is a terminating error inside a try block, the script will skip the remainder of the code inside the try block and go to the catch block. If there are no errors, the script will skip the code in the catch block. You can also add a finally block that will execute last in all cases. So, let’s see how you can use this with services example.

If you open a PowerShell command and enter Get-Service -Name xyz, you will see an error stating it cannot find the service. However, if you run that command again but wrapped in a try/catch, you will still see the same error. That is because this particular error is not a terminating error. Therefore, the catch block is not triggered. So, to ensure the catch block is triggered, you can add -ErrorAction Stop to the end of the command to turn all error messages into terminating errors, ensuring that the catch block will be triggered.

try{ 
   Get-Service -Name xyz -ErrorAction Stop 
} 
catch{
   $_ 
}

When you run the command above in PowerShell, you will still see the error message but notice that the output is now white instead of red. This is because of the $_ in the catch block. When a catch block is triggered, the error that caused it is automatically saved to the variable $_. This is what you can use to test that the error received was the expected one.

Inside the catch, you can use an if/else conditional statement to check the error message. If it does not match the expected error, it will call the Write-Error cmdlet to let PowerShell error handling report the error but not terminate the execution. You should use non-terminating errors in situations where the subsequent steps can still process even though an error has occurred.

For example, if you are stopping multiple services, you do not need to stop processing all of them if only one fails. The other services can still be stopped and disabled. Then you can go back and address the failures. However, if an error on a particular step would cause subsequent failures, then you would want to terminate the execution. For instance, if you were setting up a web server and IIS failed to install. There would be no need to continue with the steps to configure IIS because it is not there. In these situations, you can simply replace the Write-Error command with throw.

$Name = 'xyz' 
try{
   $Service = Get-Service -Name $Name -ErrorAction Stop 
} 
catch{ 
   if($_.FullyQualifiedErrorId -ne 'NoServiceFoundForGivenName,Microsoft.PowerShell.Commands.GetServiceCommand'){ 
      Write-Error $_ 
   } 
}

In the next step, you want to set the service startup type to disabled. You do not need to stop a service before you disable it. And since we want to ensure they are disabled, it makes sense to put that command before the stop command. This way, if the function runs into an unexpected error in the stop process, we will have guaranteed that the service is at least disabled.

In this situation, we can put the Set-Service cmdlet directly under the Get-Service cmdlet because the Get-Service cmdlet has the error action set to stop. Thus, if there is an error in the Get-Service command, it will jump to the catch block, skipping the Set-Service command.

Creating custom event handles

Now that you have disabled the service, it is time to stop it. I am almost positive that everyone reading this has run into a situation where you tell a service to stop running, and it just hangs. If you’ve ever experienced this through PowerShell, then you have most likely seen your console fill with warning after warning stating, “Waiting for service ‘xyz’ to stop..”. And PowerShell will continue to repeat that message until the service stops or you manually kill the execution. Neither of which is an ideal situation in an automation scenario. So, let’s take a look at how we can avoid this through some parallel processing.

Most cmdlets that act upon an outside resource and wait for a particular state will have an option to bypass that wait. In this scenario, the Stop-Service cmdlet has a -NoWait switch. This switch tells the cmdlet to send the stop command but do not wait for it to stop. Doing this will allow you to send multiple stop commands one after another without waiting for one to finish stopping. It will also allow you to create your own event handling to kill the process after a predetermined amount of time. So, we need to make the functionality to do the following:

  1. Send the stop command to multiple services without waiting.
  2. Check the status of the services to ensure they have all stopped.
  3. If any have not stopped after 60 seconds, attempt to kill the process.
  4. If any have not stopped after 90 seconds, notify that a reboot is required.

Unlike with the Get-Service cmdlet, we do not care if the Stop-Service cmdlet throws an error. This is because regardless of what happens on the stop, the service has already been disabled. So, even if there is an error or it does not stop in the time we allotted, a reboot will be requested, which will ensure the service does not come back up. Therefore, there is no problem adding the -ErrorAction SilentlyContinue argument to the command in this situation.

If you will be checking multiple services for multiple conditions, it is good to create a custom PowerShell object to keep track of the status and startup type for every service. Then when you can create a while loop to check that the services have stopped, you not do not check ones you know have stopped or were not found.

The while loop will need to run as long as services are running but should also contain a timer to terminate after a set amount of time, even if all the services do not stop. You will also want to add the ability to perform a hard kill of the running process if it does not stop on its own. You can do this but using the Get-CimInstance cmdlet to get the process ID of the service, then using the Stop-Process cmdlet to force it to stop. Since you do not want to run the Stop-Process repeatedly, you can add a property to the object to record that there was an attempt to stop it. Therefore, the custom PowerShell object will need the following properties.

  • Service = Service Name
  • Status = The status of the service
  • Startup = The startup type of the service
  • HardKill = A Boolean value set to true after the Stop-Process command

Once you put everything together, the process should look like figure below.

And finally, you can put it all together into a PowerShell function, that you can use in any automation you need.

Function Disable-WindowsService { 
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Services,
        [Parameter(Mandatory = $true)]
        [int]$HardKillSeconds,
        [Parameter(Mandatory = $true)]
        [int]$SecondsToWait
    )
 
    [System.Collections.Generic.List[PSObject]] $ServiceStatus = @()
    foreach ($Name in $Services) {
        # Create a custom PowerShell object to track the status of each service
        $ServiceStatus.Add([pscustomobject]@{
                Service  = $Name
                HardKill = $false
                Status   = $null
                Startup  = $null
            })
        try {
            # Attempt to find the service, then disable and stop it
            $Get = @{
                Name        = $Name
                ErrorAction = 'Stop'
            }
            $Service = Get-Service @Get
            $Set = @{
                InputObject = $Service
                StartupType = 'Disabled'
            }
            Set-Service @Set
            $Stop = @{
                InputObject = $Service
                Force       = $true
                NoWait      = $true
                ErrorAction = 'SilentlyContinue'
            }
            Stop-Service @Stop
            Get-Service -Name $Name | ForEach-Object {
                $ServiceStatus[-1].Status = $_.Status.ToString()
                $ServiceStatus[-1].Startup = $_.StartType.ToString()
            }
        }
        catch {
            $msg = 'NoServiceFoundForGivenName,Microsoft.PowerShell' +
                '.Commands.GetServiceCommand'
            if ($_.FullyQualifiedErrorId -eq $msg) {
                # If the service doesn't exist, then there is nothing to stop, so consider that a success
                $ServiceStatus[-1].Status = 'Stopped'
            }
            else {
                Write-Error $_
            }
        }
    }
 
    $timer = [system.diagnostics.stopwatch]::StartNew()
    # Monitor the stopping of each service
    do {
        $ServiceStatus | Where-Object { $_.Status -ne 'Stopped' } | 
        ForEach-Object { 
            $_.Status = (Get-Service $_.Service).Status.ToString()
            
            # If any services have not stopped in the predetermined amount of time, kill the process.
            if ($_.HardKill -eq $false -and 
                $timer.Elapsed.TotalSeconds -gt $HardKillSeconds) {
                Write-Verbose "Attempting hard kill on $($_.Service)"
                $query = "SELECT * from Win32_Service WHERE name = '{0}'"
                $query = $query -f $_.Service
                $svcProcess = Get-CimInstance -Query $query
                $Process = @{
                    Id          = $svcProcess.ProcessId
                    Force       = $true
                    ErrorAction = 'SilentlyContinue'
                }
                Stop-Process @Process
                $_.HardKill = $true
            }
        }
        $Running = $ServiceStatus | Where-Object { $_.Status -ne 'Stopped' }
    } while ( $Running -and $timer.Elapsed.TotalSeconds -lt $SecondsToWait )
    # set reboot required if any services did not stop
    $ServiceStatus | 
        Where-Object { $_.Status -ne 'Stopped' } | 
        ForEach-Object { $_.Status = 'Reboot Required' }
    
    # return results
    $ServiceStatus
}

Like with many things covered in this book, event handling is a pivotal part of creating successful automation. This post is intended to give you an overview of some different ways that you can use it in your automations. There are many other ways to achieve event handling, many of which are will cover in the book. Along with event handling you will learn many other useful skills to apply to your automations. Including:

  • Creating adaptable automations, that uses configuration data to control the script’s execution.
  • Schedule your scripts across multiple platforms.
  • Securely use passwords and secrets in your scripts.
  • Set up PowerShell remoting across Windows and Linux devices.
  • Share your automations with your team and non-technical colleague.
  • Use source control to maintain and test code changes.

Practical Automation with PowerShell