Tips for using the Puppet PowerShell module

*Editor's Note: This post was originally posted on Glenn Sarti's blog, http://glennsarti.github.io. It appears here in a slightly modified form, with Glenn's permission.*

Puppet has great support for Windows, including a module on the Puppet Forge to run PowerShell-based commands. There are some instructions in the README to help people use the module, but there a few traps for people who are just starting out. So here are a few tips to help you.

PowerShell exit codes

The Puppet PowerShell module makes use of the exit codes produced by PowerShell, so it's important to understand how these codes are produced within a PowerShell process.

The exit command can be used to exit a PowerShell script and optionally return an exit code.

Unlike a lot of scripting languages, PowerShell has the concept of terminating and non-terminating errors (more information). A terminating error will stop the script, whereas a non-terminating error will not. This can cause some headaches for people who aren't expecting it!

Take this very simple batch file:

@ECHO OFF
powershell.exe "& { exit 255 }"
ECHO Errorlevel %ERRORLEVEL%

It produces

Errorlevel 255

This is what you would expect to happen. But what if I don't explicitly set an exit code?

@ECHO OFF
powershell.exe "& { }"
ECHO Errorlevel %ERRORLEVEL%

produces

Errorlevel 0

So far, so good. So what happens if I raise an error (division by zero)?

@ECHO OFF
powershell.exe "& { 1/0 }"
ECHO Errorlevel %ERRORLEVEL%

produces

Attempted to divide by zero.
At line:1 char:5
+ & { 1/0 }
+     ~~~
    + CategoryInfo          : NotSpecified: (:) [], RuntimeException
    + FullyQualifiedErrorId : RuntimeException

Errorlevel 0

But the exit code is still zero?!?!? What if the script syntax was broken, like a missing bracket?

@ECHO OFF
powershell.exe "& { (missing-bracket }"
ECHO Errorlevel %ERRORLEVEL%

produces

At line:1 char:21
+ & { (missing-bracket }
+                     ~
Missing closing ')' in expression.
    + CategoryInfo          : ParserError: (:) [], ParentContainsErrorRecordException
    + FullyQualifiedErrorId : MissingEndParenthesisInExpression

Errorlevel 1

Now the exit code is no longer zero.

What would happen if I changed the error from non-terminating to terminating? This behavior is normally changed by specifying the ErrorAction parameter on Cmdlets or setting the global $ErrorActionPreference variable to Stop

@ECHO OFF
powershell.exe "& { Get-Service -Name IDontExist }"
ECHO Errorlevel %ERRORLEVEL%

powershell.exe "& { Get-Service -Name IDontExist -ErrorAction Stop }"
ECHO Errorlevel %ERRORLEVEL%

produces

Get-Service : Cannot find any service with service name 'IDontExist'.
At line:1 char:5
+ & { Get-Service -Name IDontExist }
+     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ObjectNotFound: (IDontExist:String) [Get-Service], ServiceCommandException
    + FullyQualifiedErrorId : NoServiceFoundForGivenName,Microsoft.PowerShell.Commands.GetServiceCommand

Errorlevel 0

Get-Service : Cannot find any service with service name 'IDontExist'.
At line:1 char:5
+ & { Get-Service -Name IDontExist -ErrorAction Stop }
+     ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ObjectNotFound: (IDontExist:String) [Get-Service], ServiceCommandException
    + FullyQualifiedErrorId : NoServiceFoundForGivenName,Microsoft.PowerShell.Commands.GetServiceCommand

Errorlevel 1

So the non-terminating error (without the ErrorAction parameter) produced a zero exit code, while the terminating error (ErrorAction Stop) produced a non-zero exit code.

What does this all mean?

If you depend on PowerShell returning a meaningful exit code, you should use the exit command for all code branches. This ensures PowerShell returns the exit codes you expect.

# Don't do this
If ($foo -eq 'bar') {
  exit 1
}

rather,

# Do this instead
If ($foo -eq 'bar') {
  exit 1
}
exit 0

More information on -ErrorAction

More information on $ErrorActionPreference

Using onlyif and unless attributes

We’ll first look at version 1.0.6 of the Puppet PowerShell module. We’ll then look at the newly released version 2.0 of the Puppet Powershell module and what has changed.

The Puppet exec resource has onlyif and unless attributes, which can be used to limit when the command is invoked — e.g., "create this file only if it does not exist," or "start this windows service unless it's already running."

The onlyif parameter is defined as

If this parameter is set, then this exec will only run if the command has an exit code of 0.

Whereas the unless attribute is defined as

If this parameter is set, then this exec will run unless the command has an exit code of 0.

Using what we know about PowerShell exit codes, a simple test manifest can be used to see what would happen for different scenarios:

# OnlyIf tests
exec { 'onlyif check exit 0':
  command  => '"OnlyifExit0"',
  onlyif   => 'exit 0',
  provider => powershell,
} ->

exec { 'onlyif check exit 1':
  command  => '"OnlyifExit1"',
  onlyif   => 'exit 1', 
  provider => powershell,
} ->

exec { 'onlyif check noexit':
  command  => '"OnlyifNoExit"',
  onlyif   => '',
  provider => powershell,
} ->

exec { 'onlyif check non-term error':
  command  => '"OnlyifNonTerm"',
  onlyif   => 'Get-Service -Name IDontExist;',
  provider => powershell,
} ->

exec { 'onlyif check term error':
  command  => '"OnlyifTerm"',
  onlyif   => 'Get-Service -Name IDontExist -ErrorAction Stop',
  provider => powershell,
}  ->

# Unless tests
exec { 'unless check exit 0':
  command  => '"UnlessExit0"',
  unless   => 'exit 0',
  provider => powershell,
} ->

exec { 'unless check exit 1':
  command  => '"UnlessExit1"',
  unless   => 'exit 1',
  provider => powershell,
} ->

exec { 'unless check noexit':
  command  => '"UnlessNoExit"',
  unless   => '',
  provider => powershell,
} ->

exec { 'unless check non-term error':
  command  => '"UnlessNonTerm"',
  unless   => 'Get-Service -Name IDontExist;',
  provider => powershell,
} ->

exec { 'unless check term error':
  command  => '"UnlessTerm"',
  unless   => 'Get-Service -Name IDontExist -ErrorAction Stop',
  provider => powershell,
}

produces

Notice: Compiled catalog for win-edson23cglf.localdomain in environment production in 0.17 seconds
Notice: /Stage[main]/Main/Exec[onlyif check exit 0]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[onlyif check noexit]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[unless check exit 1]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[unless check non-term error]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[unless check term error]/returns: executed successfully
Notice: Applied catalog in 18.28 seconds

So what did these tests show?

  • The explicitly set exit code tests onlyif check exit 0, onlyif check exit 1, unless check exit 0 and unless check exit 1 behaved exactly as the documentation stated.
  • The no exit code set tests onlyif check noexit and unless check noexit behaved exactly as the documentation stated; i.e., default exit code of 0.
  • The terminating error tests (onlyif check term error and unless check term error) behaved exactly as the documentation stated, remembering that PowerShell returns exit code 1 for terminating errors.
  • The non-terminating error tests onlyif check non-term error and unless check non-term error are a bit strange. They behaved as if Powershell had returned an exit code of 1, but our PowerShell tests in the previous section showed that non-terminating errors return zero.

So as one last test, what would happen if a non-terminating error was thrown, but there was another command afterwards? After all, non-terminating errors do not terminate the script. Let's add Write-Host "Hello" to the end of the onlyif and unless, and see what happens:

# OnlyIf tests
exec { 'onlyif check non-term error then command':
  command  => '"OnlyifNonTermThenCommand"',
  onlyif   => 'Get-Service -Name IDontExist; Write-Host "Success"',
  provider => powershell,
} ->

# Unless tests
exec { 'unless check non-term error then command':
  command  => '"UnlessNonTermThenCommand"',
  onlyif   => 'Get-Service -Name IDontExist; Write-Host "Succes"',
  provider => powershell,
} ->

produces

Notice: Compiled catalog for win-edson23cglf.localdomain in environment production in 0.16 seconds
Notice: /Stage[main]/Main/Exec[onlyif check non-term error then command]/returns: executed successfully
Notice: Applied catalog in 18.28 seconds

What happened there?!?! So now the non-terminating errors are behaving as if PowerShell had returned exit code 0.

It appears that the exit code is being determined by the last command executed. This can cause a lot of headaches when debugging onlyif and unless clauses.

What does all this mean?

  • Always use explicit exit codes.
  • Watch out for terminating errors, as the exec resource will treat them as non-zero exit codes.

Example:

Create an exec resource that;

Starts the FOO service unless, it doesn't exist or it exists and is already running

# Don't do this

# Start the FOO service unless already running or does not exist
exec { 'Start FOO':
  command  => 'Start-Service -Name "FOO"',
  unless => '$foo = Get-Service -Name "FOO";
            if ($foo.Status -eq "Running") { Exit 1 }',
  provider  => powershell,
}

In the example above, if the service doesn't exist, it will throw a non-terminating error and then process the next command. Remembering the previous weird test case, it will then execute the exec resource, attempting to start a service that does not exist. However, this is not what we wanted.

# Do this instead

# Start the FOO service unless already running or does not exist
exec { 'Start FOO':
  command  => 'Start-Service -Name "FOO"',
  unless => '$foo = Get-Service -Name "FOO";
            if ($foo.Status -eq "Running") { Exit 1 } else { Exit 0 }',
  provider  => powershell,
}

Now that there is an explicit Exit 0, the previous non-terminating error is ignored, and the exec resource behaves as we wanted.

Update - Puppet PowerShell module, version 2.0

The previous section addresses version 1.0.6 of the Puppet PowerShell module. On 24 May, a new version was released which has made some huge performance improvements, and also fixed some bugs with error handling.

Performance Improvements

To give you some idea of the improvement, we can use the test manifest from the previous example and time how long it took to run. Of course, we can use PowerShell to test this:

#### OLD MODULE
Write-Host "*** OLD Module"
$oldModuleTime = Measure-Command { & puppet apply powershell.pp --modulepath C:\blog\old-module }

$oldModuleTime.TotalSeconds

#### NEW MODULE
Write-Host "*** NEW Module"
$newModuleTime = Measure-Command { & puppet apply powershell.pp --modulepath C:\blog\new-module }

$newModuleTime.TotalSeconds

#### BASELINE
Write-Host "*** Baseline"
$baselineTime = Measure-Command { & puppet apply -e "#" --modulepath C:\blog\old-module }

$baselineTime.TotalSeconds

### Improvement
$oldModuleTime = $oldModuleTime - $baselineTime
$newModuleTime = $newModuleTime - $baselineTime
Write-Host "*** Improvement"
Write-Host ($oldModuleTime.TotalMilliseconds / $newModuleTime.TotalMilliseconds).ToString("###%")

produces

*** OLD Module
Error: Write-error "Hello" returned 1 instead of one of [0]
Error: /Stage[main]/Main/Exec[write-error]/returns: change from notrun to 0 failed: Write-error "Hello" returned 1 instead of one of [0]
32.1743243
*** NEW Module
22.2315182
*** Baseline
12.4175615
*** Improvement
201%

So the old module took 32 seconds, while the new module took 22 seconds — but that isn't the whole story. The baseline measures how long Puppet takes to just start up, so it's not even executing PowerShell then. Taking the baseline time away, the old module took 20 seconds (32 - 12) and the new module took 10 seconds (22 - 12).

So the new module is two times faster than the old one! Also, there's less disk IO and there's no temporary file on disk, so that security concern is gone.

Obviously the performance improvement you see will change depending on your scenario, but in essence, the more PowerShell exec resources are in your manifests, the greater the improvement.

Error handling

Error handling has changed slightly in the new module, so let’s see what happens when we run the previous test manifest. I've also included the non-terminating error with extra command tests, too.

PowerShell module version 1.0.6

Notice: Compiled catalog for win-edson23cglf.localdomain in environment production in 0.17 seconds
Notice: /Stage[main]/Main/Exec[onlyif check exit 0]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[onlyif check noexit]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[onlyif check non-term error then command]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[unless check exit 1]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[unless check non-term error]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[unless check term error]/returns: executed successfully

PowerShell module version 2.0.0

Notice: Compiled catalog for win-edson23cglf.localdomain in environment production in 0.22 seconds
Notice: /Stage[main]/Main/Exec[onlyif check exit 0]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[onlyif check noexit]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[onlyif check non-term error]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[onlyif check non-term error then command]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[unless check exit 1]/returns: executed successfully
Notice: /Stage[main]/Main/Exec[unless check term error]/returns: executed successfully

So what's changed from version 1.0.6 to version 2.0?

  • The onlyif check non-term error test now runs the exec resource. This is the same behavior as onlyif check non-term error then command, and now treats all non-terminating errors the same — i.e., exit code zero.
  • The unless check non-term error no longer runs the exec resource. This is because the exit code is zero for non-terminating errors.

So when using the PowerShell module, remember:

  • Always use explicit exit codes.
  • Watch out for terminating errors, as the exec resource will treat them as non-zero exit codes.
  • Try to use the latest version of the PowerShell module, as it is faster and has improved error handling.

Glenn Sarti is a senior software engineer at Puppet.

Learn more

Puppet sites use proprietary and third-party cookies. By using our sites, you agree to our cookie policy.