powershellserializationinvoke-command

How do I pass a class object in a argument list to a another computer and call a function on it?


I am attempting to create a class object and use Invoke-Command to call a function on the class on a remote machine. When I use Invoke-Command with no computer name this works fine but when I attempt to do this on a remote computer I get an error saying the that the type does not contain my method. Here is the script I am using for testing this.

$ComputerName = "<computer name>"

[TestClass]$obj = [TestClass]::new("1", "2")

Get-Member -InputObject $obj

$credentials = Get-Credential

Invoke-Command -ComputerName $ComputerName -Credential $credentials -Authentication Credssp -ArgumentList ([TestClass]$obj) -ScriptBlock {
    $obj = $args[0]

    Get-Member -InputObject $obj

    $obj.DoWork()
    $obj.String3
}

class TestClass {
    [string]$String1
    [string]$String2
    [string]$String3

    [void]DoWork(){
        $this.String3 = $this.String1 + $this.String2
    }

    TestClass([string]$string1, [string]$string2) {
        $this.String1 = $string1
        $this.String2 = $string2
    }
}

Here is the output I get.

PS > .\Test-Command.ps1

cmdlet Get-Credential at command pipeline position 1
Supply values for the following parameters:
User: <my user>
Password for user <my user>: *

   TypeName: TestClass

Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}


   TypeName: Deserialized.TestClass

Name     MemberType Definition
----     ---------- ----------
GetType  Method     type GetType()
ToString Method     string ToString(), string ToString(string format, System.IFormatProvider formatProvider), string IFormattable.ToString(string format, System.IFormatProvider formatProvider)
String1  Property   System.String {get;set;}
String2  Property   System.String {get;set;}
String3  Property    {get;set;}
Method invocation failed because [Deserialized.TestClass] does not contain a method named 'DoWork'.
    + CategoryInfo          : InvalidOperation: (DoWork:String) [], RuntimeException
    + FullyQualifiedErrorId : MethodNotFound
    + PSComputerName        : <computer name>

I can see that the type changes from TestClass to Deserialized.TestClass and I am wondering if there is a way around this? My goal is to be able to ship the functions I need to each of the machines I am running a script on so that I don't have to rewrite the functions in the context of the Invoke-Command script block.


Solution

  • It's an older question, but it was relevant to me. I found another way for my purposes:

    1. To make TestClass known in the remote environment, it can be included in the abstract syntax tree (AST) of the script before processing this. The same is also very useful for using statements (which must be declared on top of the file only) or functions (which can be used local and in a remote script without double declaration). The Edit-RemoteScript function is used for this purpose. (The solution was inspired by this answer in another forum. This very useful tool can help exploring the AST.)
    2. In order to get an object of the self-defined class as a 'living' object remotely or after it has been returned from the remote environment, it can be casted from Deserialized.TestClass to TestClass. The new constructor, which accepts a PSObject, serves this purpose. Alternatively, an op_Implicit or op_Explicit operator also accepting a PSObject can do the same. Inside this operator a class constructor must be invoked. Both operators seem to work identically in PowerShell.

    This sample code illustrates the functionality:

    using namespace Microsoft.PowerShell.Commands
    using namespace System.Collections
    using namespace System.Diagnostics.CodeAnalysis
    using namespace System.Management.Automation
    using namespace System.Management.Automation.Language
    
    Set-StrictMode -Version ([Version]::new(3, 0))
    
    class TestClass {
        [string]$String1
        [string]$String2
        [string]$String3
    
        [void]DoWork() {
            $this.String3 = $this.String1 + $this.String2
        }
    
        TestClass([string]$string1, [string]$string2) {
            $this.String1 = $string1
            $this.String2 = $string2
        }
        TestClass([PSObject]$ClassObject) {
            $this.String1 = $ClassObject.String1
            $this.String2 = $ClassObject.String2
            $this.String3 = $ClassObject.String3
        }
    }
    
    <#
        .DESCRIPTION
            This function adds using statements, functions, filters and types to ScriptBlocks to be used for remote access.
    
        .PARAMETER ScriptBlock
            The ScriptBlock to be processed. Mandatory.
    
        .PARAMETER Namespace
            The list of namespaces to add. 'default' adds any namespaces listed in the root script's using statements. Alternatively or additionally, 
            any other namespaces can be added. These have to be fully qualified. The statement 'using namespace' must not be prefixed. 
            The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
            Defaut is an empty list.
    
        .PARAMETER Module
            The list of PowerShell modules to add. 'default' adds any module listed in the root script's using statements. Alternatively or additionally, 
            any other module can be added. The value of the argument can be a module name, a full module specification, or a path to a module file.
            When it is a path, the path can be fully qualified or relative. A relative path is resolved relative to the script that contains the using statement. 
            The modules referenced by path must be located identically in the file systems of the calling site and the remote site.
            The statement 'using namespace' must not be prefixed. 
            When it is a name or module specification, PowerShell searches the PSModulePath for the specified module.
            A module specification is a hashtable that has the following keys:
                - ModuleName - Required, specifies the module name.
                - GUID - Optional, specifies the GUID of the module.
                - It's also required to specify at least one of the three below keys.
                - ModuleVersion - Specifies a minimum acceptable version of the module.
                - MaximumVersion - Specifies the maximum acceptable version of the module.
                - RequiredVersion - Specifies an exact, required version of the module. This can't be used with the other Version keys.
            The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
            Defaut is an empty list.
        
        .PARAMETER Assembly
            The list of .NET assemblies to add. 'default' adds any assembly listed in the root script's using statements. Alternatively or additionally, 
            any other assembly can be added. The value can be a fully qualified or relative path. A relative path is resolved relative to the script that 
            contains the using statement. The assemblies referenced must be located identically in the file systems of the calling site and the remote site.
            The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
            Defaut is an empty list.
    
        .PARAMETER Type
            The list of names from types defined by the root script to add to the processed script.  
            The type definitions are inserted in the processed ScriptBlock at the position where it contains the #ImportedTypes comment.
            Defaut is an empty list.
    
        .PARAMETER Function
            The list of names from functions or filters defined by the root script to add to the processed script.  
            The function definitions are inserted in the processed ScriptBlock at the position where it contains the #ImportedFunctions comment.
            Defaut is an empty list.
    
        .PARAMETER SearchNestedScriptBlocks
            If this parameter is set, ScriptBlocks contained in the root script are also searched for functions, filters and types, otherwise only the root 
            script itself.
    
        .EXAMPLE
            In this example the namespaces used by the root script and two additional using namespace statements are added to $myScriptBlock.
            One type and two functions, defined by the root script, are also added:
    
            $myScriptBlock | Edit-RemoteScript `
                -Namespace 'default', 'System.Collections', 'System.Collections.Generic' `
                -Type 'MyType' `
                -Function 'ConvertTo-MyType', 'ConvertFrom-MyType'
    
        .NOTES
            Because the using statements must come before any other statement in a module and no uncommented statement can precede them, including parameters, 
            one cannot define any using statement in a nested ScriptBlock. Therefore, the only alternative to post-inserting the using statements into a 
            previously defined ScriptBlock, as is done in this function, is to define $myScript as a string and create the ScriptBlock using [ScriptBlock]::Create($myScript). 
            But then you lose syntax highlighting and other functionality of the IDE used.
    
            An alternative way of including functions, filters and types that are used in both, the root script and the remote script, in the latter is shown in 
            the links below. An alternative to post-insertion would be to redefine these functions, filters, and types in the remote script. However, the downside 
            is that changes to the code have to be kept in sync in different places, which reduces its maintainability. 
    
        .LINK 
            this function:
            https://stackoverflow.com/a/76695304/2883733
        
        .LINK 
            alternative for types:
            https://stackoverflow.com/a/59923349/2883733
    
        .LINK 
            alternative for functions:
            https://stackoverflow.com/a/71272589/2883733
    #>
    function Edit-RemoteScript {
    
        [CmdletBinding()]
        [OutputType([ScriptBlock])]
        [SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', 'functionText', Justification = "Doesn't apply")]
    
        param(
            [Parameter(Mandatory, ValueFromPipeline)] [ScriptBlock]$ScriptBlock,
            [Parameter()] [AllowEmptyCollection()] [String[]]$Namespace = @(),
            [Parameter()] [AllowEmptyCollection()] [ModuleSpecification[]]$Module = @(),
            [Parameter()] [AllowEmptyCollection()] [String[]]$Assembly = @(),
            [Parameter()] [AllowEmptyCollection()] [String[]]$Type = @(),
            [Parameter()] [AllowEmptyCollection()] [String[]]$Function = @(),
            [Parameter()] [Switch]$SearchNestedScriptBlocks
        )
    
        begin {
            [Ast]$cmdletAst = $MyInvocation.MyCommand.ScriptBlock.Ast
            do {
                [Ast]$tempAst = $cmdletAst.Parent
            } while ($null -ne $tempAst -and ($cmdletAst = $tempAst))
            [String[]]$remoteUsings = @()
            [String[]]$remoteTypes = @()
            [String[]]$remoteFunctions = @()
        } process {
            if (($Namespace -or $Module -or $Assembly) -and -not $remoteUsings) {
                if ('default' -iin $Namespace -or
                    'default' -iin $Assembly -or (
                        $Module | Where-Object -Property 'Name' -EQ -Value 'default' | Select-Object -First 1
                    )
                ) {
                    [UsingStatementAst[]]$allUsings = @($cmdletAst.FindAll({ $args[0] -is [UsingStatementAst] }, $false))
                }
                $remoteUsings = @(
                    @(
                        @{
                            Kind  = [UsingStatementKind]::Namespace
                            Names = $Namespace
                        },
                        @{
                            Kind  = [UsingStatementKind]::Module
                            Names = $Module
                        },
                        @{
                            Kind  = [UsingStatementKind]::Assembly
                            Names = $Assembly
                        }
                    ) | ForEach-Object -Process { 
                        [UsingStatementKind]$kind = $_.Kind
                        $_.Names | ForEach-Object -Process {
                            if (($kind -eq [UsingStatementKind]::Module -and $_.Name -ieq 'default') -or ($kind -ne [UsingStatementKind]::Module -and $_ -ieq 'default')) {
                                @($allUsings | Where-Object -Property 'UsingStatementKind' -EQ -Value $kind | ForEach-Object -Process { $_.ToString() })
                            } else {
                                if ($kind -eq [UsingStatementKind]::Assembly) {
                                    "using $( $kind.ToString().ToLowerInvariant() ) '$_'"
                                } else {
                                    "using $( $kind.ToString().ToLowerInvariant() ) $_"
                                }
                            }
                        }
                    }
                )
            }
            if ($Type -and -not $remoteTypes) {
                $remoteTypes = @(
                    $cmdletAst.FindAll({ $args[0] -is [TypeDefinitionAst] }, $SearchNestedScriptBlocks) | 
                        Where-Object -Property 'Name' -In $Type | 
                        ForEach-Object -Process { $_.ToString() }
                )
            }
            if ($Function -and -not $remoteFunctions) {
                $remoteFunctions = @(
                    if ($SearchNestedScriptBlocks) {
                        # this is slower
                        $cmdletAst.FindAll({
                                param(
                                    [Parameter()] [Ast]$Ast
                                )
                                <#
                                    Class methods have a FunctionDefinitionAst under them as well, but we don't want them.
                                    from: https://stackoverflow.com/a/45929412/2883733
                                #>
                                $Ast -is [FunctionDefinitionAst] -and $Ast.Parent -isnot [FunctionMemberAst]
                            },
                            $true) |
                            Where-Object -FilterScript {
                                $_.Name -iin $Function
                            } |
                            ForEach-Object -Process { $_.ToString() }
                    } else {
                        # this is faster
                        Get-ChildItem -Path 'Function:' |
                            Where-Object -Property 'Name' -In $Function |
                            ForEach-Object -Process {
                                if ($_.CommandType -eq [CommandTypes]::Filter) {
                                    "filter $( $_.Name ) {$( $_.ScriptBlock.ToString() )}" 
                                } else {
                                    "function $( $_.Name ) {$( $_.ScriptBlock.ToString() )}" 
                                }
                            }
                    }
                )
            }
            [ScriptBlock]::Create($ScriptBlock.ToString(). `
                    Replace('#ImportedUsings', $remoteUsings -join "`n"). `
                    Replace('#ImportedTypes', $remoteTypes -join "`n"). `
                    Replace('#ImportedFunctions', $remoteFunctions -join "`n"))
        } end {
        }
    }
    
    function TestFunction {
        42
    }
    
    $ComputerName = 'Server1'
    [TestClass]$obj = [TestClass]::new('1', '2')
    [ScriptBlock]$testScript = {
        #ImportedUsings # the imported using statements will be inserted here
    
        Set-StrictMode -Version ([Version]::new(3, 0))
    
        #ImportedTypes # the imported types will be inserted here
        #ImportedFunctions # the imported functions will be inserted here
    
        $obj = $args[0]
        [ArrayList]$results = @() # using statements are working remotely
        [TestClass]$castedObj = [TestClass]$obj # the type is known remotely
    
        [void]$results.Add('')
        [void]$results.Add('* * * remote * * *')
        [void]$results.Add((TestFunction)) # the function is known remotely
        $castedObj.DoWork() # the type has his functionality remotely
        [void]$results.Add($castedObj.String3)
        [void]$results.Add((Get-Member -InputObject $obj))
        [void]$results.Add((Get-Member -InputObject $castedObj))
        [void]$results.Add('')
        [void]$results.Add($castedObj)
        [void]$results.Add([TestClass]::new('3', '4'))
        $results
    }
    $testScript = $testScript | Edit-RemoteScript -Namespace 'default' -Type 'TestClass' -Function 'TestFunction'
    $credentials = Get-Credential
    
    '* * * local * * *'
    TestFunction
    Get-Member -InputObject $obj
    
    $results = Invoke-Command -ComputerName $ComputerName -Credential $credentials -ArgumentList ([TestClass]$obj) -ScriptBlock $testScript
    foreach ($ctr in 0..6) {
        $results[$ctr]
    }
    [TestClass]$resultObj = $results[7] # also returned objects can be casted back to the original type
    "this is the original instance, DoWork() is already done, String3 = '$( $resultObj.String3 )'"
    $resultObj = $results[8]
    "this is a new instance, DoWork() isn't done yet, String3 = '$( $resultObj.String3 )'"
    $resultObj.DoWork()
    "... but now, String3 = '$( $resultObj.String3 )'"
    

    Output:

    * * * local * * *
    42
    
       TypeName: TestClass
    
    Name        MemberType Definition
    ----        ---------- ----------
    DoWork      Method     void DoWork()
    Equals      Method     bool Equals(System.Object obj)
    GetHashCode Method     int GetHashCode()
    GetType     Method     type GetType()
    ToString    Method     string ToString()
    String1     Property   string String1 {get;set;}
    String2     Property   string String2 {get;set;}
    String3     Property   string String3 {get;set;}
    
    * * * remote * * *
    42
    12
    
       TypeName: Deserialized.TestClass
    
    Name     MemberType Definition
    ----     ---------- ----------
    GetType  Method     type GetType()
    ToString Method     string ToString(), string ToString(string format, System.IFormatProvider formatProvider), string IFormattable.ToString(string format, System.IFormatProvider formatPro… 
    String1  Property   System.String {get;set;}
    String2  Property   System.String {get;set;}
    String3  Property    {get;set;}
    
       TypeName: TestClass
    
    Name        MemberType Definition
    ----        ---------- ----------
    DoWork      Method     void DoWork()
    Equals      Method     bool Equals(System.Object obj)
    GetHashCode Method     int GetHashCode()
    GetType     Method     type GetType()
    ToString    Method     string ToString()
    String1     Property   string String1 {get;set;}
    String2     Property   string String2 {get;set;}
    String3     Property   string String3 {get;set;}
    
    this is the original instance, DoWork() is already done, String3 = '12'
    this is a new instance, DoWork() isn't done yet, String3 = ''
    ... but now, String3 = '34'
    

    In this case it is certainly a big overhead and it would actually be easier to re-define TestClass. In larger projects with complex classes, however, the procedure may worthwhile. Another advantage: there is no longer any need to synchronize functions and classes that have been declared multiple times when changes are made.

    If you are working with a PSSession in which several remote calls are passed one after the other, it may even be worthwhile to have a script executed remotely first that is used exclusively for the declarations. Then a specific typed parameter type TestClass can be used instead of Object or PSObject because type TestClass is already known when the script is invoked. A casting of the parameter can be ommitted in this case:

    [ScriptBlock]$TestScript = {
        param([Parameter()] [TestClass]$Obj)
        ....
        $Obj.DoWork() # the type has his functionality remotely
        [void]$results.Add($Obj.String3)
        ...
    }
    

    Edit 1: a small correction of the function code and inserted usefull links

    Edit 2: suggested by @mklement0 's answer: making the function more universal; a comment-based help has also been added

    Edit 3: clarification and small correction regarding casting operators