BreadcrumbHomeResourcesBlog How To Combine PowerShell Tasks, Bolt, and Puppet July 20, 2021 How to Combine PowerShell Tasks, Bolt, and PuppetHow to & Use CasesEcosystems & IntegrationsBy Glenn SartiDid 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?How to Set Up Puppet, Bolt, and PowerShell TasksWhy Test Tasks in PowerShell and Puppet?How to Write Testable PowerShell Tasks 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 TasksInstall BoltThere are a few ways we could do this on Windows, all of which are described in the Bolt documentation:Installing as an MSI.Installing with Chocolatey.Installing as a Ruby gem.WinRM ConfigurationBolt 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.CommandsA Bolt command is a single line of text which can be executed on a computer, for example in PowerShell Write-Host "Hello World!"ScriptsYou 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.TasksPuppet 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 systemsTasks use Scripts to execute them on remote machines.PlansPlans 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 CommandNow 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 secondsGreat! 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 plansGet-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 TasksTasks 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 TaskLet'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 TaskNow 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_historyLet'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_historyAll 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 secondsComparing 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-JSONBolt 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.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 ParametersThe 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 informationPS> .\tasks\update_history.ps1And for detailed informationPS> .\tasks\update_history.ps1 -DetailedBolt 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 MetadataTask 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 TypeIn our PowerShell script, the Detailed parameter is defined as; [Parameter(Mandatory = $False)] [Switch]$DetailedThis 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 TypePowerShell ParameterString[Parameter(Mandatory = $True)] [String] $ParamOptional[String][String] $ParamString[5][Parameter(Mandatory = $True)] [ValidateLength(5)] [String] $ParamPattern[/\A\w+\Z/][Parameter(Mandatory = $True)] [ValidatePattern({\A\w+\Z})] [String] $ParamInteger[Parameter(Mandatory = $True)] [Int] $ParamInteger[1, 20][Parameter(Mandatory = $True)] [ValidateRange(1, 20)] [Int] $ParamOptional[Integer][Int] $ParamBoolean[Parameter(Mandatory = $True)] [Switch] $ParamBoolean[Parameter(Mandatory = $True)] [Bool] $ParamOptional[Boolean][Switch] $ParamOptional[Boolean][Bool] $ParamThis is not a complete list, but commonly used script parametersIn Bolt, all parameters are mandatory unless the Optional[] type is used, whereas in PowerShell, parameters are optional unless Mandatory = $True is setThe default values of a task parameter need to be set in the PowerShell script but are generally documented in the task metadata fileWhile 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 MetadataNow 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 informationBy 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 ParametersThe 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 secondsWe 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 secondsWhat 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 StringBolt will validate the input, but as we'll see later, it's really useful when used with Puppet Enterprise.Adding More ParametersInstead 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 updatesupdateid : Return updates which the specified Update ID. Default is to all updatemaximumupdates : Limit the size of the history returned. Default is to return a maximum of 300 items1. Add the parameters to the PowerShell file. [Parameter(Mandatory = $False)] [String]$Title, [Parameter(Mandatory = $False)] [String]$UpdateID, [Parameter(Mandatory = $False)] [Int]$MaximumUpdates = 3002. 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 items5. 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 secondsPackaging the ModuleNow 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 TasksGreat, 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 FunctionFirstly 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 ExecutionSo 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 elseAt 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 TestsNow that we could successfully import the PowerShell Task file, it was time to write the tests.Writing Simple TestsThe 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 nameImport any shared helper functions (spec_helper.ps1). This blog post didn't add any, but in the future, they may be usedImports the script under test. Note the use of the new -NoOperation parameterWhen 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 3Writing More TestsSo 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:should return empty JSON if no historyshould return a JSON array for a single elementshould not return detailed information when Detailed specified as falseshould return detailed information when Detailed specified as trueshould return only the maximum number of updates when specifiedshould return a single update when UpdateID is specifiedshould return matching updates when the Title is specifiedMore Testing IssuesWhile 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 TestsRunning 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:I added a small helper script to install Pester if it didn't exist and then actually run Pester.I modified the Rakefile which is used by the PDK and Ruby, to run testing tasks. This change is called the helper script I created previously.I modified the AppVeyor configuration file to call the new spec_pester Rake task.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 PuppetWe 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 moreFor more examples of using Puppet tasks, check out the Puppet Tasks Hands-on-Lab GitHub repoHow to use PowerShell gallery with PuppetRead more about the benefits of using PowerShell for automationCheck out the podcast episode about PowerShell Gallery DSC ResourcesHow to use Onceover for repository testing of your Puppet codeHow to refactor legacy Ruby functionsThis blog was originally published in two parts on July 20, 2018, and December 13, 2018. It has since been consolidated and updated for relevance.
Glenn Sarti , Puppet by Perforce Glenn Sarti moved from his hometown of Perth in western Australia to work at Puppet's Portland office. He has spent well over a decade as a Windows client and infrastructure engineer, with a heavy focus on automation and infrastructure software development. Glenn was a co-organizer for the DevOps meetup in Perth and has spoken at local Dot Net, Java and DevOps meetups.