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.
It's an older question, but it was relevant to me. I found another way for my purposes:
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.)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