Wednesday 15 February 2012

Running PowerShell Scripts on Remote Machines from MSBuild

Cross-posted from Jason Lee's Blog

Today's tricky topic is how to get a PowerShell script to execute on a remote machine from a custom MSBuild project file. I won't go into scenarios here, let's get straight to the point. Most of the difficulties encountered in this area revolve around handling parameters, managing paths with spaces, and escaping special characters.

Let's say we have a PowerShell script named LogDeploy.ps1 (it's trivial, but I basically want a test case that needs more than one parameter value). This contains a simple function that writes a single-line entry to a log file:
 
function LogDeployment
{
param([string]$filepath,[string]$deploydestination)
$datetime = Get-Date
$filetext = "Deployed package to " + $deploydestination + " on " + $datetime
$filetext | Out-File -filepath $filepath -Append
}
LogDeployment $args[0] $args[1]

The LogDeploy.ps1 script accepts two parameters. The first parameter represents the full path to the log file to which you want to add an entry, and the second parameter represents the deployment destination that you want to record in the log file. When you run the script and provide the required parameter values, it adds a line to the log file in the following format:

Deployed package to TESTWEB1 on 02/11/2012 09:28:18

So how do we run this script on a remote machine? You need to use the Invoke-Command cmdlet. From a PowerShell window, you'd use the following syntax:

Invoke-Command –ComputerName 'REMOTESERVER1'
               –ScriptBlock { &"C:\Path With Spaces\LogDeploy.ps1"
                               'C:\Path With Spaces\Log.txt'
                               'TESTWEB1' }

(There are various other ways of running a script using Invoke-Command, but this is the most painless approach when you need to manage parameters and deal with reserved XML characters, as you'll see shortly.)

If you wanted to run your PowerShell instructions from a regular command prompt, you'd need to invoke the PowerShell executable and provide your PowerShell commands through the -command argument:

powershell.exe –command
  "& {Invoke-Command –ComputerName 'REMOTESERVER1'
                     –ScriptBlock { &'C:\Path With Spaces\LogDeploy.ps1'
                                     'C:\Path With Spaces\Log.txt'
                                     'TESTWEB1' } "

The key points here are:
  • Wrap your command in double quotes and include an ampersand, i.e. &"your command".
  • Use single quotes preceded by an ampersand to enclose the path to your ps1 file, i.e. '&your path'.
(I'd tried many, many combinations of double quotes, single quotes, and ampersands before I arrived at this point.)

This brings us closer to the command we need to run from the MSBuild project file. However, there are a few additional considerations when you invoke this command from MSBuild. First, you should include the –NonInteractive flag to ensure the script executes quietly. Next, you should include the –ExecutionPolicy flag with an appropriate argument value. This specifies the execution policy that PowerShell will apply to your script and allows you to override the default execution policy, which may prevent execution of your script. You can choose from the following argument values:
  • A value of Unrestricted will allow PowerShell to execute your script, regardless of whether or not the script is signed.
  • A value of RemoteSigned will allow PowerShell to execute unsigned scripts that were created on the local machine. However, scripts that were created elsewhere must be signed. (In practice, you're very unlikely to have created a PowerShell script locally on a build server).
  • A value of AllSigned will allow PowerShell to execute signed scripts only.
The default execution policy is Restricted, which prevents PowerShell from running any script files.

Finally, you need to escape any reserved XML characters that occur in your PowerShell command:
  • Replace single quotes with '
  • Replace double quotes with "
  • Replace ampersands with &
When you make these changes, your command will resemble the following:

powershell.exe -NonInteractive -executionpolicy Unrestricted
               -command "& Invoke-Command
                 –ComputerName 'REMOTESERVER1'
                 -ScriptBlock { &'C:\Path With Spaces\LogDeploy.ps1'
                                ' C:\Path With Spaces\Log.txt ' 
                                'TESTWEB1' } "

The command is now in a format you can use from MSBuild. Within your custom MSBuild project file, you can create a new target and use the Exec task to run this command:

<Target Name="WriteLogEntry" Condition=" '$(WriteLogEntry)'!='false' ">
  <PropertyGroup>
    <PowerShellExe Condition=" '$(PowerShellExe)'=='' ">
      %WINDIR%\System32\WindowsPowerShell\v1.0\powershell.exe
    </PowerShellExe>
    <ScriptLocation Condition=" '$(ScriptLocation)'=='' ">
      C:\Path With Spaces\LogDeploy.ps1
    </ScriptLocation>
    <LogFileLocation Condition=" '$(LogFileLocation)'=='' ">
      C:\Path With Spaces\ContactManagerDeployLog.txt
    </LogFileLocation>
  </PropertyGroup>
  <Exec Command="$(PowerShellExe) -NonInteractive -executionpolicy Unrestricted
                 -command &quot;&amp; invoke-command -scriptblock {
                          &amp;&apos;$(ScriptLocation)&apos;
                          &apos;$(LogFileLocation)&apos; 
                          &apos;$(MSDeployComputerName)&apos;}
                          &quot;"/> 
</Target>

When you execute this target as part of your build process, PowerShell will run your script on the computer you specified in the -computername argument.

One final note - before you can use the Invoke-Command cmdlet to execute PowerShell scripts on a remote computer, you need to configure a WinRM listener to accept remote messages. You can do this by running the command winrm quickconfig on the remote computer. For more information, see Installation and Configuration for Windows Remote Management.