July 20, 2021

How to Combine PowerShell Tasks, Bolt, and Puppet

How to & Use Cases
Ecosystems & Integrations

Did you know you can combine Puppet, Bolt, and PowerShell tasks for powerful automation? In this blog, we break down the basics and include examples.

Table of Contents

What Are PowerShell Tasks Used For?

Tasks in PowerShell are used for automation. 

 

In this blog, we’ll cover what you need to know to get started, from installing Bolt and configuring WinRM to writing a PowerShell Task and running it on a remote computer.

How to Set Up Puppet, Bolt, and PowerShell Tasks

Install Bolt

There are a few ways we could do this on Windows, all of which are described in the Bolt documentation:

WinRM Configuration

Bolt can use SSH or WinRM to communicate with nodes, but with Windows the natural choice is WinRM. While it is outside the scope of this blog to go over how to configure your WinRM Service, for these examples I am using the HTTP Listener (not recommended for production use), with only the Kerberos and Negotiate authentication methods enabled. This is a typical configuration when using the winrm quickconfig command.

What Are Commands, Scripts, Tasks, and Plans?

Bolt uses the terms commands, scripts, tasks, and plans, so it is best to define what they mean first.

Commands

A Bolt command is a single line of text which can be executed on a computer, for example in PowerShell Write-Host "Hello World!"

Scripts

You can execute scripts on remote machines with Bolt.

Bolt copies the script from the local system to the remote node, executes it on that remote node, and then deletes the script from the remote node.

A Bolt script is a single file containing many commands. In this instance, it will be a PowerShell file, for example update_history.ps1.

Tasks

Puppet tasks are single, ad hoc actions that you can run on target machines in your infrastructure, allowing you to make as-needed changes to remote systems

Tasks use Scripts to execute them on remote machines.

Plans

Plans are sets of tasks that can be combined with other logic. This allows you to do more complex task operations, such as running multiple tasks with one command, computing values for the input for a task, or running certain tasks based on the results of another task.

Related: Check out the podcast episode on Bolt: Uniting Models and Tasks >>

Running a Single Command

Now that we have Bolt installed, we can run a test command to make sure it's working. Let's list all the running processes:

PS> bolt command run "Get-Process" --nodes winrm://localhost --no-ssl --user Administrator --password
Please enter your password:
Started on localhost...
Finished on localhost:
  STDOUT:
    Handles  NPM(K)    PM(K)      WS(K)     CPU(s)     Id  SI ProcessName
    -------  ------    -----      -----     ------     --  -- -----------
        183       9     3408       1660       0.53   4356   0 AdminService
        790      45    38128      32720     355.83  12396   2 ApplicationFrameHost
        156       8     1724        952       0.05  14296   0 AppVShNotify
...
         72       6     1492       1524       0.08  38212   0 WUDFCompanionHost
        318      14     4668       5000     140.84    500   0 WUDFHost
        674      23    49272      30332   6,460.53  35468   0 WUDFHost
Successful on 1 node: localhost
Ran on 1 node in 5.28 seconds

Great! This just listed all of the processes on my machine. Let's breakdown the command line:

bolt command run : In this case, we simply want to run a command. Bolt can also run script files, tasks, and plans

Get-Process : This is the PowerShell command that we will run on the remote computer

--nodes winrm://localhost : We want to run the command against our local computer, so we specify the transport as winrm and the name localhost. The Bolt documentation describes the ways you can specify multiple nodes, or use an inventory file.

--no-ssl : We then specify we want Bolt to use the HTTP listener (as opposed to HTTPS)

--user Administrator --password : We then specify the username as Administrator, and prompt for the password.

While you can add the password on the command line, it's not very secure as it may appear in your console history or in a tool like Process Explorer, which can see the command line for a process.

It's also a good idea to put --password at the end of the command so that Bolt doesn't misinterpret the password. For example, don't write ... --password detailed=true, as Bolt will try to authenticate with the password detailed=true, instead of prompting for the password and then passing the script parameter called detailed.

Let's move on to writing more than just a one-line command with Puppet Tasks.

Writing PowerShell Tasks

Tasks are similar to PowerShell script files, but they are kept in Puppet modules and can have metadata. This allows you to reuse and share them more easily. The first thing you need when writing a PowerShell task is a Puppet module. Tasks reside in the tasks directory, for example, in the Windows Reboot module or in the MySQL module.

You can create a new task using the Puppet Development Kit (PDK) with the pdk new task command, or by creating a PS1 file in the tasksdirectory.

<MODULE NAME>
  +- lib
  |    +- facter
  |    ...
  +- manifests
  ...
  +- spec
  +- tasks      <---- Tasks go here!
  +- templates
  ...

Creating a PowerShell Task

Let's start with a task in the WSUS Client module to return the updated history of a computer.

We create the file tasks/update_history.ps1 with using an example. You may ask, why don't we use the popular PSWindowsUpdate PowerShell module? As we'll be running this on remote computers, we don't know if that module is installed, which means we shouldn't use it. This is important to remember if you later publish your module for other people to use.

One of the great things about having a script file is that we can use our normal PowerShell tools (VS Code, ISE, etc.) to write and test the script, and then use Bolt to execute it.

To run the task manually, we can use normal PowerShell commands:

PS> .\tasks\update_history.ps1
    {
        "ServerSelection":  "WindowsUpdate",
        "ClientApplicationID":  "Windows Defender Antivirus (77BDAF73-B396-481F-9042-AD358843EC24)",
        "Categories":  [
                           "Windows Defender"
                       ],
        "UnmappedResultCode":  0,
        "Title":  "Definition Update for Windows Defender Antivirus - KB2267602 (Definition 1.269.1089.0)",
        "UpdateIdentity":  {
                               "RevisionNumber":  200,
                               "UpdateID":  "340544b8-e4f0-4eb3-b7d8-04e6608986ea"
                           },
        "UninstallationNotes":  "",
        "Description":  "Install this update to revise the definition files that are used to detect viruses, spyware, and other potentially unwanted software. Once you have installed this item, it cannot be removed.",
        "SupportUrl":  "http://go.microsoft.com/fwlink/?LinkId=52661",
        "ServiceID":  "",
        "UninstallationSteps":  [
                                ],
        "Operation":  "Installation",
        "Date":  "2018-06-12 01:48:16Z",
        "ResultCode":  "Succeeded",
        "HResult":  0
    },
...
    {
        "ServerSelection":  "Other",
        "ClientApplicationID":  "UpdateOrchestrator",
        "Categories":  [
                       ],
        "UnmappedResultCode":  0,
        "Title":  "Feature update to Windows 10, version 1803",
        "UpdateIdentity":  {
                               "RevisionNumber":  1,
                               "UpdateID":  "6850722b-d202-417f-b6d3-f45419191852"
                           },
        "UninstallationNotes":  "",
        "Description":  "Install the latest update for Windows 10: the Windows 10 April 2018 Update.",
        "SupportUrl":  "",
        "ServiceID":  "8b24b027-1dee-babb-9a95-3517dfb9c552",
        "UninstallationSteps":  [
                                ],
        "Operation":  "Installation",
        "Date":  "2018-05-02 01:41:20Z",
        "ResultCode":  "Succeeded",
        "HResult":  0
    }
]

Running a PowerShell Task

Now we can use Bolt to run the task remotely. But first, let's make sure the task exists:

PS> bolt task show --modulepath modules
apply::resource               Apply a single Puppet resource
facts                         Gather system facts
facts::bash
facts::powershell
facts::ruby
package                       Manage and inspect the state of packages
puppet_conf                   Inspect puppet agent configuration settings
service                       Manage and inspect the state of services
service::linux                Manage the state of services (without a puppet agent)
service::windows              Manage the state of Windows services (without a puppet agent)
wsus_client::update_history

Let's breakdown the command line:

bolt task show : This instructs Bolt to list all of the tasks it knows about

--modulepath C:\modules : As tasks are located in Puppet modules, we need to tell Bolt where the modules are located. In this case, my modules are located in C:\modules, and the WSUS Client module is at C:\modules\wsus_client.

The output shows lots of task names with our new task down the bottom of the list.

wsus_client::update_history
  • All of the other tasks come as part of Bolt itself. In this instance, we're using Bolt v0.20.5.
  • Tasks are uniquely named by the name of the module (wsus_client), a double colon (::) and then the task filename (update_history)

So now we know Bolt can see our new task, let's run it;

PS> bolt task run wsus_client::update_history --modulepath modules --nodes winrm://localhost --no-ssl --user Administrator --password
Started on localhost...
Finished on localhost:
  [
      {
          "ServerSelection":  "WindowsUpdate",
          "ClientApplicationID":  "Windows Defender Antivirus (77BDAF73-B396-481F-9042-AD358843EC24)",
                             "Windows Defender"
          "Categories":  [
                         ],
          "UnmappedResultCode":  0,
          "Title":  "Definition Update for Windows Defender Antivirus - KB2267602 (Definition 1.269.1089.0)",
          "UpdateIdentity":  {
                                 "RevisionNumber":  200,
                                 "UpdateID":  "340544b8-e4f0-4eb3-b7d8-04e6608986ea"
                             },
          "UninstallationNotes":  "",
          "Description":  "Install this update to revise the definition files that are used to detect viruses, spyware, and other potentially unwanted software. Once you have installed this item, it cannot be removed.",
          "SupportUrl":  "http://go.microsoft.com/fwlink/?LinkId=52661",
          "ServiceID":  "",
          "UninstallationSteps":  [
                                  ],
          "Operation":  "Installation",
          "Date":  "2018-06-12 01:48:16Z",
          "ResultCode":  "Succeeded",
          "HResult":  0
      },
...
      {
          "ServerSelection":  "Other",
          "UninstallationSteps":  [
          "ClientApplicationID":  "UpdateOrchestrator",
          "Categories":  [
                         ],
          "UnmappedResultCode":  0,
          "Title":  "Feature update to Windows 10, version 1803",
          "UpdateIdentity":  {
                                 "RevisionNumber":  1,
                                 "UpdateID":  "6850722b-d202-417f-b6d3-f45419191852"
                             },
          "UninstallationNotes":  "",
          "Description":  "Install the latest update for Windows 10: the Windows 10 April 2018 Update.",
          "UninstallationSteps":  [
          "SupportUrl":  "",
          "ServiceID":  "8b24b027-1dee-babb-9a95-3517dfb9c552",
          "Date":  "2018-05-02 01:41:20Z",
          "Operation":  "Installation",
          "ResultCode":  "Succeeded",
                                  ],
      }
          "HResult":  0
  ]
  {
  }
Successful on 1 node: localhost
Ran on 1 node in 12.43 seconds

Comparing the output of the manual process versus the Bolt process, they look almost the same. There's additional data added at the end of the Bolt output, which can be ignored.

...
{
}

Why use ConvertTo-JSON?

You may have noticed that the output from the script is not pure text, but is JSON encoded text. This comes from the last line in the PowerShell script:

} | ConvertTo-JSON

Bolt tasks return text, but if we want the output of the task to be used by other tools or processes, the output should be structured text. In particular, the output can be used by Bolt plans which can orchestrate multiple Bolt tasks. Bolt uses JSON structured text for it's structured output format, which is great as PowerShell has native support for JSON, through the ConvertTo-JSON function in PowerShell 3.0 and above.

 

If You're Reading This Blog, You're Probably Ready for Some Advanced Training

Thousands of companies are looking for people who know Puppet. Stand out with free-to-start training + official certification.

START TODAY

 

So what about PowerShell 2.0? Right now you would need to output the equivalent text by yourself in the PowerShell script, for example:

PS> $value = 'This is some text'; Write-Output "{ `"output`": `"${value}`"}"
{ "output": "This is some text"}

The string is now in a JSON format.

For small, simple PowerShell scripts this method is adequate, but for complex data, like the update_history.ps1 file we used earlier, it's quite difficult to do. A quick search in your favourite search engine for "convertto-json powershell 2" can provide some good workarounds.

Adding Script Parameters

The output from the script is quite verbose. What we really want is for it to only return the information we need, but to still have the ability to get everything. So we need to add a Detailed script parameter:

For brief information

PS> .\tasks\update_history.ps1

And for detailed information

PS> .\tasks\update_history.ps1 -Detailed

Bolt supports passing parameters to PowerShell scripts through named parameters. So we need to add cmdlet binding to the top of our script and specify the Detailed parameter.

[CmdletBinding()]
Param(
  [Parameter(Mandatory = $False)]
  [Switch]$Detailed
)
...

And then change our output to add the additional settings. I've left this out of this blog, but you can see them on the WSUS Client GitHub repository.

Let's try this locally in PowerShell:

PS> .\tasks\update_history.ps1
...
    {
        "Categories":  [
                       ],
        "ServiceID":  "8b24b027-1dee-babb-9a95-3517dfb9c552",
        "UpdateIdentity":  {
                               "RevisionNumber":  1,
                               "UpdateID":  "6850722b-d202-417f-b6d3-f45419191852"
                           },
        "Date":  "2018-05-02 01:41:20Z",
        "ResultCode":  "Succeeded",
        "Operation":  "Installation",
        "Title":  "Feature update to Windows 10, version 1803"
    }
]
PS> .\tasks\update_history.ps1 -Detailed
...
    {
        "ServerSelection":  "Other",
        "ClientApplicationID":  "UpdateOrchestrator",
        "ServiceID":  "8b24b027-1dee-babb-9a95-3517dfb9c552",
        "Title":  "Feature update to Windows 10, version 1803",
        "UnmappedResultCode":  0,
        "UpdateIdentity":  {
                               "RevisionNumber":  1,
                               "UpdateID":  "6850722b-d202-417f-b6d3-f45419191852"
                           },
        "UninstallationNotes":  "",
        "Description":  "Install the latest update for Windows 10: the Windows 10 April 2018 Update.",
        "SupportUrl":  "",
        "Categories":  [
                       ],
        "Operation":  "Installation",
        "Date":  "2018-05-02 01:41:20Z",
        "ResultCode":  "Succeeded",
        "HResult":  0,
        "UninstallationSteps":  [
                                ]
    }
]

Great! We can now change how much information we return. How does Bolt use this? Bolt uses a metadata file to store information about the task, including the available parameters and their type.

Adding Task Metadata

Task metadata files are JSON formatted files with the same name as their script. This means with our script called tasks\update_history.ps1, the metadata file will be called tasks\update_history.json. So let's create that file with information about our task:

{
  "description": "Returns a history of installed Windows Updates.",
  "parameters": {
    "detailed": {
      "description": "Return detailed update information.  Default is to return basic information",
      "type": "Optional[Boolean]"
    }
  },
  "input_method": "powershell"
}

Let's break this down:

"description": "Returns a history of installed Windows Updates.", : This is a short description of the task. When we previously ran the bolt task show command, there was a description column, and some tasks had information there. This is where that information comes from.

"parameters": { : This is where we define the new Detailed parameter

"detailed": { : The is the name of the new parameter. Note that it is in lowercase, compared to the scripts which are mixed case

"description": "Return detailed update ..., : This is a short description of the parameter and is useful for people to understand how your task works

"type": "Optional[Boolean]" : This defines the type of data we expect from the user when running the task and whether it is mandatory or optional. We will go into more detail about Bolt types below.

"input_method": "powershell" : This tells Bolt that it should use the PowerShell method when sending script parameters. Normally this is not required, as PowerShell script files (.PS1) will automatically use this method. 

The Bolt documentation lists all of the available settings in the metadata file.

Choosing a Bolt Parameter Type

In our PowerShell script, the Detailed parameter is defined as;

  [Parameter(Mandatory = $False)]
  [Switch]$Detailed

This is a Boolean parameter which is not mandatory. The equivalent definition in a Bolt type is;

Optional[Boolean]

This reads as a Boolean type which is optional; that is, not mandatory.

The Bolt parameter types come from the Puppet type system and can, mostly, be directly translated into PowerShell types and PowerShell parameter attributes:

Bolt Type

PowerShell Parameter

String

[Parameter(Mandatory = $True)] [String] $Param

Optional[String]

[String] $Param

String[5]

[Parameter(Mandatory = $True)] [ValidateLength(5)] [String] $Param

Pattern[/\A\w+\Z/]

[Parameter(Mandatory = $True)] [ValidatePattern({\A\w+\Z})] [String] $Param

Integer

[Parameter(Mandatory = $True)] [Int] $Param

Integer[1, 20]

[Parameter(Mandatory = $True)] [ValidateRange(1, 20)] [Int] $Param

Optional[Integer]

[Int] $Param

Boolean

[Parameter(Mandatory = $True)] [Switch] $Param

Boolean

[Parameter(Mandatory = $True)] [Bool] $Param

Optional[Boolean]

[Switch] $Param

Optional[Boolean]

[Bool] $Param

  • This is not a complete list, but commonly used script parameters
  • In Bolt, all parameters are mandatory unless the Optional[] type is used, whereas in PowerShell, parameters are optional unless Mandatory = $True is set
  • The default values of a task parameter need to be set in the PowerShell script but are generally documented in the task metadata file
  • While you can create complex Bolt types and PowerShell parameters, it is best to keep them as simple as possible (String, Int, Boolean), as the translation between both types is not always exact. For example, PowerShell parameters can use Position, ParameterSetName, and ValidateScript, but they have no comparable Bolt type.

Viewing Task Metadata

Now that we have some task metadata, let's display that information in Bolt:

PS> bolt task show --modulepath modules
...
wsus_client::update_history   Returns a history of installed Windows Updates.

We can now see the description of the task in the output. Now let's get more information about our task:

PS> bolt task show wsus_client::update_history --modulepath modules
wsus_client::update_history - Returns a history of installed Windows Updates.
USAGE:
bolt task run --nodes, -n <node-name> wsus_client::update_history [detailed=<value>]
PARAMETERS:
- detailed: Optional[Boolean]
    Return detailed update information.  Default is to return basic information

By adding the task name to the show command (... show show wsus_client::update_history), the output shows the complete information about the task, including all available parameters.

Running a Task With Parameters

The task show Bolt command gives us an example of how to use task parameters ... [detailed=<value>]. So let's run the task with detailed output:

PS> bolt task run wsus_client::update_history detailed=true --modulepath modules --nodes winrm://localhost --no-ssl --user Administrator --password
Please enter your password:
Started on localhost...
Finished on localhost:
...
      },
      {
          "ServerSelection":  "Other",
          "ClientApplicationID":  "UpdateOrchestrator",
          "ServiceID":  "8b24b027-1dee-babb-9a95-3517dfb9c552",
          "Title":  "Feature update to Windows 10, version 1803",
          "UnmappedResultCode":  0,
          "UpdateIdentity":  {
                                 "RevisionNumber":  1,
                                 "UpdateID":  "6850722b-d202-417f-b6d3-f45419191852"
                             },
          "UninstallationNotes":  "",
          "Description":  "Install the latest update for Windows 10: the Windows 10 April 2018 Update.",
          "SupportUrl":  "",
          "Categories":  [
                         ],
          "Operation":  "Installation",
          "Date":  "2018-05-02 01:41:20Z",
          "ResultCode":  "Succeeded",
          "HResult":  0,
          "UninstallationSteps":  [
                                  ]
      }
  ]
  {
  }
Successful on 1 node: localhost
Ran on 1 node in 3.14 seconds

We added detailed=true to the command line, which passes the parameter to the PowerShell script. We can also use detailed=false to return only the basic information, which is the same as the default behavior.

PS> bolt task run wsus_client::update_history detailed=false --modulepath modules --nodes winrm://localhost --no-ssl --user Administrator --password
Please enter your password:
Started on localhost...
Finished on localhost:
...
      },
      {
          "Categories":  [
                         ],
          "ServiceID":  "8b24b027-1dee-babb-9a95-3517dfb9c552",
          "UpdateIdentity":  {
                                 "RevisionNumber":  1,
                                 "UpdateID":  "6850722b-d202-417f-b6d3-f45419191852"
                             },
          "Date":  "2018-05-02 01:41:20Z",
          "ResultCode":  "Succeeded",
          "Operation":  "Installation",
          "Title":  "Feature update to Windows 10, version 1803"
      }
  ]
  {
  }
Successful on 1 node: localhost
Ran on 1 node in 2.65 seconds

What if we pass in something other than true or false? Such as abc123?

PS> bolt task run wsus_client::update_history detailed=abc123 --modulepath modules --nodes winrm://localhost --no-ssl --user Administrator --password
Please enter your password:
Task wsus_client::update_history:
 parameter 'detailed' expects a value of type Undef or Boolean, got String

Bolt will validate the input, but as we'll see later, it's really useful when used with Puppet Enterprise.

Adding More Parameters

Instead of returning all of the updates, it would be great to add some filtering, for example, by name, a unique identification number (UpdateID), and by total number returned. Let's add three more parameters using the same development process.

The parameters we'll add are:

title : Return updates that match the specified regular expression. The default is to all updates

updateid : Return updates which the specified Update ID. Default is to all update

maximumupdates : Limit the size of the history returned. Default is to return a maximum of 300 items

1. Add the parameters to the PowerShell file.

  [Parameter(Mandatory = $False)]
  [String]$Title,
  [Parameter(Mandatory = $False)]
  [String]$UpdateID,
  [Parameter(Mandatory = $False)]
  [Int]$MaximumUpdates = 300

2. Make changes to the PowerShell file and test locally.

PS> get-help .\tasks\update_history.ps1
update_history.ps1 [[-Title] <string>] [[-UpdateID] <string>] [[-MaximumUpdates] <int>] [-Detailed] [<CommonParameters>]
PS> .\tasks\update_history.ps1 -UpdateID 6850722b-d202-417f-b6d3-f45419191852
{
    "Categories":  [
                   ],
    "ServiceID":  "8b24b027-1dee-babb-9a95-3517dfb9c552",
    "UpdateIdentity":  {
                           "RevisionNumber":  1,
                           "UpdateID":  "6850722b-d202-417f-b6d3-f45419191852"
                       },
    "Date":  "2018-05-02 01:41:20Z",
    "ResultCode":  "Succeeded",
    "Operation":  "Installation",
    "Title":  "Feature update to Windows 10, version 1803"
}

3. Add the parameters to the task metadata.

    "title": {
      "description": "Return updates which match the specified regular expression.  Default is to all updates",
      "type": "Optional[String]"
    },
    "updateid": {
      "description": "Return updates which the specified Update ID.  Default is to all updates",
      "type": "Optional[String]"
    },
    "maximumupdates": {
      "description": "Limit the size of the history returned.  Default is to return a maximum of 300 items",
      "type": "Optional[String]"
    }

Note: I should not have used Optional[String] for the maximumupdates parameter. It really should have been Optional[Integer[0]] as it’s a number, not text. This will be fixed later.

4. Test that the metadata changes can be seen by Bolt.

PS> bolt task show wsus_client::update_history --modulepath modules
wsus_client::update_history - Returns a history of installed Windows Updates.
USAGE:
bolt task run --nodes, -n <node-name> wsus_client::update_history [detailed=<value>] [title=<value>] [updateid=<value>] [maximumupdates=<value>]
PARAMETERS:
- detailed: Optional[Boolean]
    Return detailed update information.  Default is to return basic information
- title: Optional[String]
    Return updates which match the specified regular expression.  Default is to all updates
- updateid: Optional[String]
    Return updates which the specified Update ID.  Default is to all updates
- maximumupdates: Optional[String]
    Limit the size of the history returned.  Default is to return a maximum of 300 items

5. Run the task with the new parameters using Bolt.

PS> bolt task run wsus_client::update_history updateid=6850722b-d202-417f-b6d3-f45419191852 --modulepath modules --nodes winrm://localhost --no-ssl --user Administrator --password
Please enter your password:
Started on localhost...
Finished on localhost:
  {
    "Categories": [
    ],
    "ServiceID": "8b24b027-1dee-babb-9a95-3517dfb9c552",
    "UpdateIdentity": {
      "RevisionNumber": 1,
      "UpdateID": "6850722b-d202-417f-b6d3-f45419191852"
    },
    "Date": "2018-05-02 01:41:20Z",
    "ResultCode": "Succeeded",
    "Operation": "Installation",
    "Title": "Feature update to Windows 10, version 1803"
  }
Successful on 1 node: localhost
Ran on 1 node in 3.39 seconds

Packaging the Module

Now that we have our task working we can share the module, and the task, on Puppet Forge, or an internal repository. 

Next, we'll cover how to test tasks. 

Why Test Tasks in PowerShell and Puppet?

So the most obvious question is, why would I want to test my Puppet Tasks? When we first start writing tasks, testing isn't really at the front of our minds. However, if you stop and look at the actions we took, you can start to see that we were actually testing our code— it was just a manual process.

This means we're already doing some kind of testing, but it still doesn't answer the question of "Why should I test?". Testing our tasks means that as we add functionality or change things we can be sure that it still behaves the same way. And by using an automated testing tool (because let's be honest who likes manual testing anyway!), we can run the tests in our module CI pipeline.

What Testing Tools Are Out There?

While we could use a testing tool to create a Windows Virtual Machine, in the case of the WSUS Client Module, this would be difficult and time-consuming to do. So instead we can use Pester which is a testing and mocking framework for PowerShell. 

How to Write Testable PowerShell Tasks

Great, so we can use Pester to test our PowerShell task file, but ... there is a problem. In order to test the script we need to import it. We do this by dot-sourcing the test script. However, this actually runs the script and outputs information.

Use the following code:

 
PS> . .\tasks\update_history.ps1
[
    {
        "ServiceID":  "",
        "Title":  "Definition Update for Windows Defender Antivirus - KB2267602 (Definition 1.279.737.0)",
        "UpdateIdentity":  {
                               "RevisionNumber":  200,
                               "UpdateID":  "7cfce973-b755-460c-a1a4-e92512ae2dec"
                           },
        "Categories":  [
                           "Windows Defender"
                       ],
        "Operation":  "Installation",
        "Date":  "2018-10-29 06:55:40Z",
        "ResultCode":  "Succeeded"
    },
    {
...

Also, because the script is written with the logic in the root, instead of in a function, we have no easy way to execute the script in our tests.

In short, the code I wrote may work, but it was not easily testable!

Wrapping the Main Function

Firstly we need to be able to separate loading the script and running the script. To do this, we needed to move all of the logic into its own function. For example, if the script used to look like this;

 
$Session = New-Object -ComObject "Microsoft.Update.Session"
$Searcher = $Session.CreateUpdateSearcher()
# Returns IUpdateSearcher https://msdn.microsoft.com/en-us/library/windows/desktop/aa386515(v=vs.85).aspx
$historyCount = $Searcher.GetTotalHistoryCount()
if ($historyCount -gt $MaximumUpdates) { $historyCount = $MaximumUpdates }
$Searcher.QueryHistory(0, $historyCount) |
  Where-Object { [String]::IsNullOrEmpty($Title) -or ($_.Title -match $Title) } |
  Where-Object { [String]::IsNullOrEmpty($UpdateID) -or ($_.UpdateIdentity.UpdateID -eq $UpdateID) } |
...

We would wrap all of this in a PowerShell function and then call it;

 
Function Invoke-ExecuteTask($Detailed, $Title, $UpdateID, $MaximumUpdates) {
  $Searcher = Get-UpdateSessionObject
  # Returns IUpdateSearcher https://msdn.microsoft.com/en-us/library/windows/desktop/aa386515(v=vs.85).aspx
  $historyCount = $Searcher.GetTotalHistoryCount()
  if ($historyCount -gt $MaximumUpdates) { $historyCount = $MaximumUpdates }
  $Result = $Searcher.QueryHistory(0, $historyCount) |
    Where-Object { [String]::IsNullOrEmpty($Title) -or ($_.Title -match $Title) } |
    Where-Object { [String]::IsNullOrEmpty($UpdateID) -or ($_.UpdateIdentity.UpdateID -eq $UpdateID) } |
  ...
}
Invoke-ExecuteTask -Detailed $Detailed -Title $Title -UpdateID $UpdateID -MaximumUpdates $MaximumUpdates

Review the full source code.

Notice how the function Invoke-ExecuteTask just wraps around the old logic. It still does the same thing, just in a function.

Note: For those more advanced in PowerShell you may ask why I didn't use Cmdlet Binding in the function header. I could have easily defined this as an advanced function however I did not think it was necessary. The input validation already happens at the top of the script, and as this is a private function, no user would be explicitly calling it.

Stopping Execution

So now we could call the logic of the script in Pester, but we still had the problem of it actually running the script when we imported it. What we needed was a flag of some kind that could tell the script to execute or not when imported. There are a number of different types of flags; Setting environment variables or registry keys of files on disk. However, in PowerShell, the simplest method is to just have a script parameter.

Note: Using a script parameter was appropriate for the WSUS Client module, but you may prefer to use something else

At the top of the script, we added the NoOperation parameter;

 
...
  [Parameter(Mandatory = $False)]
  [Switch]$NoOperation
...

We also added a simple if statement at the bottom of the script which conditionally executes the script

 
if (-Not $NoOperation) { Invoke-ExecuteTask }

This created a switch parameter called NoOperation which would default to false, that is, it would execute the script. By using . .\tasks\update_history.ps1 -NoOperation we could tell the script to not execute and just import the functions for testing.

Note: For those more advanced in PowerShell you may ask why I didn't use the WhatIf parameter instead. The WhatIf parameter is more geared toward user interaction. While yes it could've been used, all we needed was just a simple switch parameter. Also, noop or NoOperation, are common terms for Puppet users.

Writing Pester Tests

Now that we could successfully import the PowerShell Task file, it was time to write the tests.

Writing Simple Tests

The first tests simply tested the enumeration functions. These functions converted the number style codes into their text version; for example an OperationResultCode of 1 means the update is "In Progress"

So first we add the Pester standard PowerShell commands:

$here = Split-Path -Parent $MyInvocation.MyCommand.Path
$sut = (Split-Path -Leaf $MyInvocation.MyCommand.Path).Replace(".Tests.", ".")
$helper = Join-Path (Split-Path -Parent $here) 'spec_helper.ps1'
. $helper
$sut = Join-Path -Path $src -ChildPath "tasks/${sut}"
. $sut -NoOperation

These commands:

  • Calculate the name of the script being tested (also known as the System Under Test or $sut) based on the test file name
  • Import any shared helper functions (spec_helper.ps1). This blog post didn't add any, but in the future, they may be used
  • Imports the script under test. Note the use of the new -NoOperation parameter

When then test each of the enumeration functions to ensure the conversions of numbers to text are what we expect. For example, the tests for the Convert-ToServerSelectionString function check the output for the numbers 0 to 3

Writing More Tests

So now we had some simple tests written, and passing, we could finish off writing the rest of the tests. Fortunately, with testing, we should be describing each of our tests in simple English. I decided that the following tests would be sufficient:

More Testing Issues

While writing the test, it became apparent that the Invoke-ExecuteTask function still wasn't easily testable. The function created aMicrosoft.Update.Session COM object. This object was then used to query the system for update history. However, this meant the testing could only query the existing system, and we wouldn't be able to see the behavior if there were no updates available, or 1000 updates. What we needed to do was mock the response of the COM object so we could test the function properly.

Fortunately, Pester provides a mocking feature, however the function needed to be modified so we could mock the response. So again we wrapped the logic in another function:

Previously we had:

 
powershell
Function Invoke-ExecuteTask() {
  $Session = New-Object -ComObject "Microsoft.Update.Session"
  $Searcher = $Session.CreateUpdateSearcher()
  # Returns IUpdateSearcher https://msdn.microsoft.com/en-us/library/windows/desktop/aa386515(v=vs.85).aspx
...

and after wrapping the object creation:

 
powershell
Function Get-UpdateSessionObject() {
  $Session = New-Object -ComObject "Microsoft.Update.Session"
  Write-Output $Session.CreateUpdateSearcher()
}
Function Invoke-ExecuteTask($Detailed, $Title, $UpdateID, $MaximumUpdates) {
  $Searcher = Get-UpdateSessionObject
  # Returns IUpdateSearcher https://msdn.microsoft.com/en-us/library/windows/desktop/aa386515(v=vs.85).aspx
...

Now we could mock the response from Get-UpdateSessionObject to simulate any number or kind of updates with the testing helpers New-MockUpdateSession and New-MockUpdate.

For example, the should return empty JSON if no history test mocks an update session with no updates, using the Pester Mock function;

 
  Mock Get-UpdateSessionObject { New-MockUpdateSession 0 }

Failing Tests

Running the Pester tests showed a failure. The should return a JSON array for a single element was failing;

 
  Describing Invoke-ExecuteTask
    [+] should return empty JSON if no history 472ms
    [-] should return a JSON array for a single element 162ms
      Expected regular expression '^\[' to match '{
          "Categories":  [
                         ],
          "ServiceID":  "d605c6f0-cdea-4b1e-a225-e643254056d4",
          "UpdateIdentity":  {
                                 "RevisionNumber":  3,
                                 "UpdateID":  "d306e6b6-dd95-46ed-be96-137ecddd8611"
                             },
          "Date":  "2018-11-15 14:30:15Z",
          "ResultCode":  "Succeeded With Errors",
          "Operation":  "Uninstallation",
          "Title":  "Mock Update Title 1724034957"
      }', but it did not match.
      82:     $ResultJSON | Should -Match "^\["
      at <ScriptBlock>, C:\Source\puppetlabs-wsus_client\spec\tasks\update_history.Tests.ps1: line 82
    [+] should not return detailed information when Detailed specified as false 156ms
    [+] should return detailed information when Detailed specified as true 74ms
    [+] should return only the maximum number of updates when specified 73ms
    [+] should return a single update when UpdateID is specified 71ms
    [+] should return a matching updates when Title is specified 73ms

This failure turned out to be valid. When the bolt task runs, it should return a JSON Array, even for a single update. This turned out to be a peculiarity with PowerShell and piping objects. With a single object in the pipe, the JSON conversion just returns the object, whereas with two or more objects the JSON conversion returns an array.

In this case, the fix was fairly simple. I manually added the opening and closing brackets to the string if there was only one object in the pipe! Running Pester again showed all tests passed!

 
  Describing Invoke-ExecuteTask
    [+] should return empty JSON if no history 42ms
    [+] should return a JSON array for a single element 60ms
    [+] should not return detailed information when Detailed specified as false 99ms
    [+] should return detailed information when Detailed specified as true 61ms
    [+] should return only the maximum number of updates when specified 68ms
    [+] should return a single update when UpdateID is specified 31ms
    [+] should return a matching updates when Title is specified 32ms

Running Tests Automatically (Example 1 & Example 2)

Having a suite of tests to run was nice, but we really needed them to be run in a Continuous Integration (CI) pipeline. Fortunately, the WSUS_Client module was already set up with an AppVeyor CI pipeline:

Now whenever anyone raised a Pull Request, the pester test suite would be run!

Note: Why did I create a Rake task instead of calling the helper script directly? All the of PuppetLabs modules execute Rake tasks in the AppVeyor configuration file. While I could have hacked the configuration to run the script directly, it would cause this module to become a unique configuration that is hard to manage over time.

Get Started With PowerShell Tasks and Puppet

We modified the Bolt Task PowerShell script to be easily testable and then wrote a test suite. We then configured our CI tool, AppVeyor, to run the tests for new Pull Requests. Now we can more easily make changes to the task and be confident we don't break the existing behavior.

Not using Puppet yet? Get started with a free trial.

START MY TRIAL

 

Learn more

This blog was originally published in two parts on July 20, 2018, and December 13, 2018. It has since been consolidated and updated for relevance.