Running functions remotely in PowerShell


We have developed a PowerShell module at work that takes cares of setting up new VMs from scratch or reconfiguring existing ones to run our workloads. One of the key elements of this configuration process is that the module initially configures PowerShell remotely in the target computer, as a prerequisite to install itself locally on the target and run the rest of the configuration process locally from that computer. The PowerShell configuration on the target machine includes the following concerns:

  • Bootstrap the NugetProvider.dll into the VM
  • Register our internal repositories as PSRepositories
  • Install the running version of the configuration module running remotely into the target
  • Install common internal and third-party modules specified in the conf files

In this post, we are going to look at the original implementation of how we handle this PowerShell remote process, evaluate some of the advantages and disadvantages and finally go over improvements made to the code.

Iteration 1: A single ScriptBlock with nested functions

On the very first prototype of our remote PowerShell setup, we ended up with a big monolith of PowerShell code encapsulated within a scriptblock. Reviewing the code and understanding the different parts proved to be a hard cognitive task and time consuming. This led us eventually to encapsulate the different concerns within functions that would be invoked inside the scriptblock.

At the time, trying to extract these new functions out of the scriptblock and be able to use it on the remote session proved to be far challenging than what we were expecting. Even when the code was lacking Pester tests, we took a leap of faith and release our code to handle our computers setup. We ended up with a PowerShell function similar to what is shown below.

function Invoke-PowerShellSetup
{
    [CmdletBinding(DefaultParameterSetName='Default')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [Hashtable]
        $Config,
        [Parameter(ParameterSetName = 'Remote')]
        [ValidateNotNullOrEmpty()]
        [string]
        $ComputerName,
        [Parameter(Mandatory, ParameterSetName = 'Remote')]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        $Credential
    )
    try {
        $powershellBlock = {
            param(
                [Parameter(Mandatory, Position = 0)]
                [ValidateNotNull()]
                [Hashtable]
                $Config
            )

            function Install-ModulesInConfig
            {
                [CmdletBinding()]
                [OutputType([string[]])]
                param(
                    [Parameter(Mandatory, Position=0)]
                    [Hashtable]
                    $PowerShellConfig
                )

                try {
                    ## Function code
                    ...
                }
                catch {
                    $PSCmdlet.ThrowTerminatingError($_)
                }
            }

            function Invoke-ModuleInstallation
            {
                [CmdletBinding()]
                param(
                    [Parameter(Mandatory)]
                    [ValidateNotNullOrEmpty()]
                    [string]
                    $Name,
                    [Parameter(Mandatory)]
                    [ValidateNotNullOrEmpty()]
                    [string]
                    $RequiredVersion,
                    [Parameter()]
                    [ValidateNotNullOrEmpty()]
                    [string]
                    $Scope = 'CurrentUser'
                )

                try {
                    ## Function code
                    ...
                }
                catch {
                    $PSCmdlet.ThrowTerminatingError($_)
                }
            }

            function Initialize-PSRepository {
                [CmdletBinding()]
                param(
                    [Parameter(Mandatory)]
                    [ValidateNotNull()]
                    [hashtable]
                    $PowerShellConfig
                )
                try {
                    ## Function code
                    ...
                }
                catch {
                    $PSCmdlet.ThrowTerminatingError($_)
                }
            }

            function Resolve-NugetProviderLibrary
            {
                [CmdletBinding()]
                param(
                    [Parameter(Mandatory)]
                    [ValidateNotNull()]
                    [hashtable]
                    $GenericConfig
                )

                try {
                    ## Function code
                    ...
                }
                catch {
                    $PSCmdlet.ThrowTerminatingError($_)
                }
            }

            try {
                Write-Information "Resolve Nuget as a Package provider"
                Resolve-NugetProviderLibrary -GenericConfig $Config.Generic

                $PowerShellConfig = $Config.PowerShell

                Initialize-PSRepository -PowerShellConfig $PowerShellConfig

                Install-ModulesInConfig -PowerShellConfig $PowerShellConfig
            }
            catch {
                $PSCmdlet.ThrowTerminatingError($_)
            }
        }

        if ($PSCmdlet.ParameterSetName -eq 'Remote') {
            $invokeCommandParams = @{
                ComputerName = $ComputerName
                Credential = $Credential
                ScriptBlock = $powershellBlock
            }

            Invoke-Command @invokeCommandParams -ArgumentList @($Config)
        }
        else {
            & $powershellBlock -Config $Config
        }
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}

Pros and Cons

As with any solution, the previous code has several advantages and disadvantages:

Pros:

  • All the code is one place
  • We don’t have to deal with serialization of the parameters (we’ll see why later on)

Cons:

  • The scriptblock grows larger the more function we need to add.
  • The functions invoked in the scriptblock cannot be tested nor mocked since they are not defined in a scope reachable by Pester

Iteration 2: Injecting functions in the ScriptBlock

Unfortunately, we were bitten by the lack of testing of the scriptblock content recently. Some changes in a config file that affected some of these functions were only found while setting up a new node in our infrastructure. Why did we miss it? Well, due to the lack of Pester tests executing that part of the code I would say :(. Hence, we decided to improve the code and make it more testable by extracting the functions out of the scripblock.

Thankfully for us, I was able to apply some of the learnings I have gather while doing open source work in Pester, regarding the manipulation of functions and functions body.

The key element is the usage of the Function Provider to get the associated blocks with each corresponding function and then passing them to the scriptblock as a hashtable parameter as it is shown in the code below, where we gather the functions' code using the syntax ${function:FunctionName} (in Resolve-FunctionAsBlock function). With this in place, we ran into an unexpected quirk: since we are doing remoting operations, serialization comes into play by the PowerShell engine as part of the transmission of information from the origin to the target machine. After a few minutes of manual testing and some google-fu, I discovered that a scriptblock is serialized as string and that the solution was to re-create them once again (using [scriptblock]::Create method) within the main running scriptblock. The remaining change was that, since we no longer deal directly with functions (a.ka. named blocks), we need to resort to invoke the blocks using the call operator as shown below.

Set-StrictMode -Version 2.0

function Invoke-PowerShellSetup {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [Hashtable]
        $Config,
        [Parameter(Mandatory, ParameterSetName = 'Remote')]
        [ValidateNotNullOrEmpty()]
        [string]
        $ComputerName,
        [Parameter(Mandatory, ParameterSetName = 'Remote')]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        $Credential
    )
    try {
        $powershellBlock = {
            param(
                [Parameter(Mandatory, Position = 0)]
                [ValidateNotNull()]
                [Hashtable]
                $Config,
                [Parameter(Mandatory, Position = 2)]
                [ValidateNotNull()]
                [Hashtable]
                $Functions
            )

            try {
                @($Functions.Keys) |
		            Where-Object { $Functions[$_] -is [string] } |
		            ForEach-Object {
                        Write-Verbose "$_ function needs to be casted back to a scriptblock"
                        $Functions[$_] = [scriptblock]::Create($Functions[$_])
                    }

                & $Functions.ResolveNugetProviderLibrary -GenericConfig $Config.Generic

                $PowerShellConfig = $Config.PowerShell

                & $Functions.InitializePSRepository -PowerShellConfig $PowerShellConfig

                & $Functions.InstallModulesInConfig -PowerShellConfig $PowerShellConfig -Functions $functions
            }
            catch {
                $PSCmdlet.ThrowTerminatingError($_)
            }
        }

        $functions = Resolve-FunctionsAsBlock

        if ($PSCmdlet.ParameterSetName -eq 'Remote') {
            $invokeCommandParams = @{
                ComputerName = "$ComputerName.$($env:USERDNSDOMAIN)"
                Credential = $Credential
            }

            Invoke-Command @invokeCommandParams -ArgumentList @($Config, $functions) -ScriptBlock $powershellBlock
        }
        else {
            & $powershellBlock -Config $Config -Functions $functions
        }
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}


function Resolve-FunctionsAsBlock {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param()
    try {
        @{
            ResolveNugetProviderLibrary = ${Function:Resolve-NugetProviderLibrary}
            InitializePSRepository = ${Function:Initialize-PSRepository}
            InstallModulesInConfig = ${Function:Install-ModulesInConfig}
            InvokeModuleInstallation = ${Function:Invoke-ModuleInstallation}
        }
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}


function Resolve-NugetProviderLibrary {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [hashtable]
        $GenericConfig
    )

    try {
        ## Function code
        ...
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}


function Initialize-PSRepository {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNull()]
        [hashtable]
        $PowerShellConfig
    )

    try {
        ## Function code
        ...
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}


function Install-ModulesInConfig {
    [CmdletBinding()]
    [OutputType([string[]])]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateNotNull()]
        [Hashtable]
        $PowerShellConfig,
        [Parameter(Mandatory, Position = 2)]
        [ValidateNotNull()]
        [Hashtable]
        $Functions
    )

    try {
        ## Function code
        ...
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}


function Invoke-ModuleInstallation {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]
        $RequiredVersion,
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Scope = 'CurrentUser'
    )

    try {
        ## Function code
        ...
    }
    catch {
        $PSCmdlet.ThrowTerminatingError($_)
    }
}

With the current version, we can easily add tests for each of the functions since they are now defined at the module level and Pester is then capable of interacting with them. Also the gain in readability cannot be underestimated, since it is easier to analyze the whole logic of the main block that is going to run remotely and each individual function on its own.

Conclusions

In this post, we went over to different ways of running code remotely and quirks of using multiple functions as part the remote execution. We briefly discussed the pros and cons of a single monolith block containing every required bit; and then showed a better approach of extracting the functions out of the block and injecting them within, that facilitates testing and readability of the code.

To conclude, thank you so much for reading this post. Hope you liked reading it as much as I did writing it. See you soon and stay tuned for more!!

Disclaimer: The opinions expressed herein are my own personal opinions and do not represent my employer’s view in any way.