Running Functions Remotely

Here's a quick way to execute a PowerShell function remotely on one or more computers.

In a previous post, I discussed how you can read the Registry and come up with a list of installed software, using this function:

function Get-Software
{
    <#
        .SYNOPSIS
        Reads installed software from registry

        .PARAMETER DisplayName
        Name or part of name of the software you are looking for

        .EXAMPLE
        Get-Software -DisplayName *Office*
        returns all software with "Office" anywhere in its name
    #>

    param
    (
    # emit only software that matches the value you submit:
    [string]
    $DisplayName = '*'
    )


    #region define friendly texts:
    $Scopes = @{
        HKLM = 'All Users'
        HKCU = 'Current User'
    }

    $Architectures = @{
        $true = '32-Bit'
        $false = '64-Bit'
    }
    #endregion

    #region define calculated custom properties:
        # add the scope of the software based on whether the key is located
        # in HKLM: or HKCU:
        $Scope = @{
            Name = 'Scope'
            Expression = {
            $Scopes[$_.PSDrive.Name]
            }
        }

        # add architecture (32- or 64-bit) based on whether the registry key 
        # contains the parent key WOW6432Node:
        $Architecture = @{
        Name = 'Architecture'
        Expression = {$Architectures[$_.PSParentPath -like '*\WOW6432Node\*']}
        }
    #endregion

    #region define the properties (registry values) we are after
        # define the registry values that you want to include into the result:
        $Values = 'AuthorizedCDFPrefix',
                    'Comments',
                    'Contact',
                    'DisplayName',
                    'DisplayVersion',
                    'EstimatedSize',
                    'HelpLink',
                    'HelpTelephone',
                    'InstallDate',
                    'InstallLocation',
                    'InstallSource',
                    'Language',
                    'ModifyPath',
                    'NoModify',
                    'PSChildName',
                    'PSDrive',
                    'PSParentPath',
                    'PSPath',
                    'PSProvider',
                    'Publisher',
                    'Readme',
                    'Size',
                    'SystemComponent',
                    'UninstallString',
                    'URLInfoAbout',
                    'URLUpdateInfo',
                    'Version',
                    'VersionMajor',
                    'VersionMinor',
                    'WindowsInstaller',
                    'Scope',
                    'Architecture'
    #endregion

    #region Define the VISIBLE properties
        # define the properties that should be visible by default
        # keep this below 5 to produce table output:
        [string[]]$visible = 'DisplayName','DisplayVersion','Scope','Architecture'
        [Management.Automation.PSMemberInfo[]]$visibleProperties = [System.Management.Automation.PSPropertySet]::new('DefaultDisplayPropertySet',$visible)
    #endregion

    #region read software from all four keys in Windows Registry:
        # read all four locations where software can be registered, and ignore non-existing keys:
        Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
                            'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
                            'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
                            'HKCU:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction Ignore |
        # exclude items with no DisplayName:
        Where-Object DisplayName |
        # include only items that match the user filter:
        Where-Object { $_.DisplayName -like $DisplayName } |
        # add the two calculated properties defined earlier:
        Select-Object -Property *, $Scope, $Architecture |
        # create final objects with all properties we want:
        Select-Object -Property $values |
        # sort by name, then scope, then architecture:
        Sort-Object -Property DisplayName, Scope, Architecture |
        # add the property PSStandardMembers so PowerShell knows which properties to
        # display by default:
        Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $visibleProperties -PassThru
    #endregion 
}

Obviously, the thought is intriguing to run this function remotely and get a software inventory for multiple servers, or test whether a given software is in fact installed. But how would you do this? Get-Software runs locally.

This was something Mike Maggs puzzled, and he came up with a great solution (plus some new questions). I am addressing all of it below.

Running Functions Remotely

PowerShell comes with a general way to execute code remotely on one or more computers, and this article is not the right place to discuss this concept in detail. Key is the cmdlet Invoke-Command which takes a scriptblock, transfers it to one or many remote systems, and executes it there in a hidden PowerShell host named wsmprovhost.exe. The results come back to you.

If you’ve never used Invoke-Command before, you might want to wrap your head around it first.

By default, remote access is enabled for Servers and available to Administrators when you specify computer names. If you want anything different, i.e. use IP addresses or access Clients, you need to manually configure the remoting settings. Again, that’s beyond the scope of this article.

However, this is something you cannot do:

# will NOT work:
Invoke-Command -ScriptBlock { Get-Software -DisplayName *Office* } -ComputerName server01

The scriptblock is executed on the target machine, and the target machine doesn’t know your new function Get-Software.

You could turn the function into a module, distribute it, and load it on the server side, and in fact that might be a good idea for commands you plan to use often. But for quick ad-hoc scenarios, there is a much easier way!

# will work (provided server01 exists and is set up for remoting):
Invoke-Command -ScriptBlock ${function:Get-Software} -ArgumentList '*Office*' -ComputerName server01

The trick is to use the drive function: which holds all your PowerShell functions. The expression ${function:Get-Software} returns the source code of your function which happens to be a ScriptBlock. A ScriptBlock is what Invoke-Command executes, so instead of calling a function that doesn’t exist on the server side, you call the actual code of the function.

If you want to submit arguments to the function, simply use -ArgumentList and provide the arguments you want to submit to the function in the correct order as a comma-separated list (array). So if I wanted to find all Office-related software on Server01, I would use the code above.

Adding Remoting Capabilities

If you need to remote often, you could of course incorporate this code into your function:

function Get-Software
{
  <#
      .SYNOPSIS
      Reads installed software from registry

      .PARAMETER DisplayName
      Name or part of name of the software you are looking for

      .EXAMPLE
      Get-Software -DisplayName *Office*
      returns all software with "Office" anywhere in its name
  #>

  param
  (
    # emit only software that matches the value you submit:
    [string]
    $DisplayName = '*',
    
    # add parameters for computername and credentials:
    [string[]]
    $ComputerName,
    
    [PSCredential]
    $Credential
  )

  # wrap all logic in scriptblock and make sure to add a parameter 
  # to submit the argument "DisplayName":
  $code = {
    param
    (
      [string]
      $DisplayName
    )
  
    #region define friendly texts:
    $Scopes = @{
      HKLM = 'All Users'
      HKCU = 'Current User'
    }

    $Architectures = @{
      $true = '32-Bit'
      $false = '64-Bit'
    }
    #endregion

    #region define calculated custom properties:
    # add the scope of the software based on whether the key is located
    # in HKLM: or HKCU:
    $Scope = @{
      Name = 'Scope'
      Expression = {
        $Scopes[$_.PSDrive.Name]
      }
    }

    # add architecture (32- or 64-bit) based on whether the registry key 
    # contains the parent key WOW6432Node:
    $Architecture = @{
      Name = 'Architecture'
      Expression = {$Architectures[$_.PSParentPath -like '*\WOW6432Node\*']}
    }
    #endregion

    #region define the properties (registry values) we are after
    # define the registry values that you want to include into the result:
    $Values = 'AuthorizedCDFPrefix',
    'Comments',
    'Contact',
    'DisplayName',
    'DisplayVersion',
    'EstimatedSize',
    'HelpLink',
    'HelpTelephone',
    'InstallDate',
    'InstallLocation',
    'InstallSource',
    'Language',
    'ModifyPath',
    'NoModify',
    'PSChildName',
    'PSDrive',
    'PSParentPath',
    'PSPath',
    'PSProvider',
    'Publisher',
    'Readme',
    'Size',
    'SystemComponent',
    'UninstallString',
    'URLInfoAbout',
    'URLUpdateInfo',
    'Version',
    'VersionMajor',
    'VersionMinor',
    'WindowsInstaller',
    'Scope',
    'Architecture'
    #endregion

    #region Define the VISIBLE properties
    # define the properties that should be visible by default
    # keep this below 5 to produce table output:
    [string[]]$visible = 'DisplayName','DisplayVersion','Scope','Architecture'
    [Management.Automation.PSMemberInfo[]]$visibleProperties = [System.Management.Automation.PSPropertySet]::new('DefaultDisplayPropertySet',$visible)
    #endregion

    #region read software from all four keys in Windows Registry:
    # read all four locations where software can be registered, and ignore non-existing keys:
    Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
    'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
    'HKCU:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction Ignore |
    # exclude items with no DisplayName:
    Where-Object DisplayName |
    # include only items that match the user filter:
    Where-Object { $_.DisplayName -like $DisplayName } |
    # add the two calculated properties defined earlier:
    Select-Object -Property *, $Scope, $Architecture |
    # create final objects with all properties we want:
    Select-Object -Property $values |
    # sort by name, then scope, then architecture:
    Sort-Object -Property DisplayName, Scope, Architecture |
    # add the property PSStandardMembers so PowerShell knows which properties to
    # display by default:
    Add-Member -MemberType MemberSet -Name PSStandardMembers -Value $visibleProperties -PassThru
    #endregion 
  }
  
  # remove private parameters from PSBoundParameters so only ComputerName and Credentials remain:
  $null = $PSBoundParameters.Remove('DisplayName')
  # invoke the code and splat the remoting parameters. Supply the local argument $DisplayName.
  # it will be received inside the $code by the param() block
  Invoke-Command -ScriptBlock $code @PSBoundParameters -ArgumentList $DisplayName
  
}

Now it is easy to get software locally and remotely:

# get software locally
Get-Software -DisplayName *Office*

# get software remotely
Get-Software -DisplayName *Office* -ComputerName server1, server2, server3

You can even authenticate using a different account:

Get-Software -DisplayName *Office* -ComputerName server1, server2, server3 -Credential SuperAdmin

There are plenty of learning points:

  • Adding Remoting Parameters: Get-Software now uses two additional parameters: ComputerName and Credential. They are optional, and when submitted, they will be forwarded to Invoke-Command.
  • Portable Code: The code isn’t invoked directly but instead assigned to a scriptblock variable. The portable code uses its own param() block to receive any arguments it needs.
  • Invoking: The code is invoked internally via Invoke-Command. The argument(s) are submitted to the code via the parameter -ArgumentList. The trick here is to not hard-code the parameters -ComputerName and -Credential but instead use Splatting: only when the user submitted these parameters will they be forwarded to Invoke-Command. This way, the command still works locally, too, because when you run Invoke-Command without specifying -ComputerName, it runs the code locally without requiring Administrator privileges.