Scott Hanselman

ScriptBlock and Runspace Remoting in PowerShell

July 15, '06 Comments [2] Posted in PowerShell | XmlSerializer | Web Services | Bugs
Sponsored By

When I first saw grokked PowerShell I wanted to be able to issue commands remotely. There's some great stuff going on with PowerShellRemoting over at GotDotNet, but that's remoting of the User Interface.

I want to be able to issue commands to many different machines in a distributed fashion.

After some pair programming with Brian Windheim, we set up a Windows Service that would get a string of commands and return a string that was the output o those commands. I could then issue remote commands, but the result at the client was just strings. I was in PowerShell but I'd just made the equivalent of PSEXEC for PowerShell...so basically I'd gotten nowhere.

Ideally I'd like to have behavior like this (but I don't):

using (Runspace myRunSpace = RunspaceFactory.CreateRunspace("COMPUTERNAME"))

{

    myRunSpace.Open();
}

But a Runspace is local and inproc. I don't see a really obvious and straightforward way to do this, considering that there's LOTS of internal and private stuff going on within PowerShell.

I liked that the string in, string out remoting stuff worked fine, but really I want to get Objects back from the remote machine. So, I started using Reflection to poke around inside System.Management.Automation.Serializer, but that got evil quickly. Truly evil.

Then I had an epiphany and remember the Export-CliXml cmdlet. It is the public cmdlet that uses the serializer I was trying to get to. It isn't the XmlSerializer. It's a serialized graph of objects with a rich enough description of those objects that the client doesn't necessarily need the CLR types. If reflection had a serialization format, it might look like this format.

Now, if I take the commands I was issuing to the remote "invoker" and export the result of the pipeline to this function XML format, I've just discovered my remoting server's wire format.

This RunspaceInvoker type is hosted in a Windows Service, but it could be in any Remoting hosting process. I'll likely move it inside IIS for security reasons. The app.config for my service looks like this:

<?xml version="1.0"  encoding="utf-8" ?>

<configuration xmlns="http://schemas.microsoft.com/.NetConfiguration/v2.0">

  <system.runtime.remoting>

    <customErrors mode="Off"/>

    <application>

      <channels>

        <channel ref="http" port="8081"/>

      </channels>

      <service>

        <wellknown mode="SingleCall"

                   type="Hanselman.RemoteRunspace.RunspaceInvoker,
                   Hanselman.RemoteRunspace
" objectUri="remoterunspace.rem"/>

      </service>

    </application>

  </system.runtime.remoting>

</configuration>

Note the objectUri and port. We'll need those in a second. There's an installer class that I run using installutil.exe. I set the identity of the Windows Service and it starts up with net start RemoteRunspaceService.

This is the RunspaceInvoker (not the best name):

 public class RunspaceInvoker : MarshalByRefObject

 {

    public RunspaceInvoker(){}

 

    public string InvokeScriptBlock(string scriptString)

    {

        using (Runspace myRunSpace = RunspaceFactory.CreateRunspace())

        {

            myRunSpace.Open();

 

            string tempFileName = System.IO.Path.GetTempFileName();

            string newCommand = scriptString +
                " | export-clixml " + "\"" + tempFileName + "\"";

            Pipeline cmd = myRunSpace.CreatePipeline(newCommand);

 

            Collection<PSObject> objectRetVal = cmd.Invoke();

 

            myRunSpace.Close();

 

            string retVal = System.IO.File.ReadAllText(tempFileName);

            System.IO.File.Delete(tempFileName);

            return retVal;

        }

    }

 }

A command for the remote service comes into the scriptString parameter. For example we might pass in dir c:\temp as the string, or a whole long pipeline. We create a Runspace, open it and append "| export-clixml" and put the results in a tempFileName.

THOUGHT: It's a bummer I can't put the results in a variable or get it out of the Pipeline, but I think I understand why they force me to write the CLI-XML to a file. They are smuggling the information out of the system. It's the Heisenberg Uncertainly Principle of PowerShell. If you observe something, you change it. Writing the results to a file is a trapdoor that doesn't affect the output of the pipeline. I could be wrong though.

Anyway, this doesn't need to be performant. I write it to a temp file, read the file in and delete it right away away. Then I return the serialized CLI-XML to the caller.The client portion is two parts. I probably should make a custom cmdlet, but I didn't really see a need. Perhaps someone can offer me a reason why.

For simplicity I first made this RunspaceProxy. Remember, this is the class that the client uses to invoke the command remotely.

    public class RunspaceProxy

    {

        public RunspaceProxy()

        {

            HttpChannel chan = new HttpChannel();

            if (ChannelServices.GetChannel("http") != null)

            {

                ChannelServices.RegisterChannel(chan, false);

            }

        }

 

        public Collection<PSObject> Execute(string command, string remoteurl)

        {

            RunspaceInvoker proxy = (RunspaceInvoker)Activator.GetObject(
                   typeof(RunspaceInvoker), remoteurl);

            string stringRetVal = proxy.InvokeScriptBlock(command);

 

            using (Runspace myRunSpace = RunspaceFactory.CreateRunspace())

            {

                myRunSpace.Open();

                string tempFileName = System.IO.Path.GetTempFileName();

                System.IO.File.WriteAllText(tempFileName, stringRetVal);

                Pipeline cmd = myRunSpace.CreatePipeline(
                    "import-clixml " + "\"" + tempFileName + "\"");

                Collection<PSObject> retVal = cmd.Invoke();

                System.IO.File.Delete(tempFileName);

                return retVal;

            }

        }

    }

I'm using the HTTP channel for debugging and ease of use with TcpTrace. The command to be executed comes in along with the remoteUrl. We make a RunspaceInvoker (the class we talked about a second ago) on the remote machine and it does the work via a call to InvokeScriptBlock. The CLI-XML comes back over the wire and now I have to make a tempfile on the client. Then, in order to 'deserialize' - a better word would be re-hydrate - the Collection of PSObjects, I make a local Runspace and call import-clixml and poof, a Collection<PSObject> is returned to the client. I delete the file immediately.

Why is returning real PSObjects so important when I had strings working? Because now I can select, sort, and where my way around these PSObjects as if they were local - because they are. They are real and substantial. This will allow us to write scripts that blur the line between the local admin and remote admin.

Now, all this has been C# so far, when does the PowerShell come in? Also, since I've worked so hard (well, not that hard) to get the return values integrated cleanly with PowerShell, what's a good way to get the remote calling of scripts integrated cleanly?

My first try I made a function RemoteInvoke() that took a command string. It worked, but felt tacky. Then I remembered how Jeffrey Snover said to look to Type Extensions when adding functionality rather than functions and cmdlets.

I made a My.Types.ps1xml file in my PSConfiguration directory and put this in it:

<Types>

  <Type>

    <Name>System.Management.Automation.ScriptBlock</Name>

    <Members>

      <ScriptMethod>

        <Name>RemoteInvoke</Name>

        <Script>

          if ($GLOBAL:remoteUrl -eq $null) { throw 'Set $GLOBAL:remoteUrl first!' }


          [System.reflection.assembly]::LoadWithPartialName("System.Runtime.Remoting") |
               out-null

          $someDll = "C:\foo\Hanselman.RemoteRunspace.dll"

          $asm = [System.Reflection.Assembly]::LoadFrom($someDll) | out-null


          $runspace = new-object Hanselman.RemoteRunspace.RunspaceProxy


          $runspace.Execute([string]$this, $GLOBAL:remoteUrl);

        </Script>

      </ScriptMethod>

    </Members>

  </Type>

</Types>

Then called Update-TypeData My.Types.ps1xml (actually it's in my profile so it happens automatically.)  This file adds a new method to the ScriptBlock type. A ScriptBlock is literally a block of script. It's a very natural "atom" for us to use.

NOTE: I'd like to have the RemoteUrl be a parameter to the RemoteInvoke ScriptMethod, but I can't fine really any documentation on this. I'll update it when I figure it out, but for now it uses a $GLOBAL variable and freaks out if it's not set.

The RemoteInvoke loads the .NET System.Runtime.Remoting assembly, then it loads our Proxy assembly. Then it calls Execute, casting the [ScriptBlock] to a [string] because the Runspace takes a string.

For example, at a PowerShell prompt if I do this:

PS[79] C:\> $remoteUrl="http://remotecomputer:8081/RemoteRunspace.rem"

PS[80] C:\PS[80] C:\> 2+2
4

PS[81] C:\> (2+2).GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Int32                                    System.ValueType

PS[82] C:\> {2+2}.GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     False    ScriptBlock                              System.Object

PS[83] C:\> {2+2}
4

PS[84] C:\> {2+2}.RemoteInvoke()
4

PS[85] C:\>
{2+2}.RemoteInvoke().GetType()

IsPublic IsSerial Name                                     BaseType
-------- -------- ----                                     --------
True     True     Int32                                    System.ValueType

Note the result of the last line. The value that comes out of RemoteInvoke is an Int32, not a string. The result of that ScriptBlock executing is a PowerShell type that I can work with elsewhere in my local script.

Here's the CLI-XML that went over the wire (just to make it clear it's not XmlSerializer XML):

<Objs Version="1.1" xmlns="http://schemas.microsoft.com/powershell/2004/04">

  <I32>4</I32>

</Objs>

This 2+2 stuff is a terse and simple example, but this technique works with even large and complex object graphs like the FileInfos and FileSystemInfo objects that are returned from dir (get-childitem).

Remoterunspace

In this screenshot we do a get-process on the remote machine then sort and filter the results just as we would/could if the call were local.

My WishList for the Next Version of PowerShell

  • All this stuff I did, built in already with security and wonderfulness.
  • All the stuff in PowerShellRemoting, with security and wonderfulness.
  • Some kind of editor or schema installed in VS.NET for editing My.Types.ps1xml.
  • TabExpansion for all Types in the current AppDomain (this of course, already done by MonadBlog and MOW).

Thanks again to Brian Windheim for the peer programming today that jump started this!

About Scott

Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. I am a failed stand-up comic, a cornrower, and a book author.

facebook twitter subscribe
About   Newsletter
Sponsored By
Hosting By
Dedicated Windows Server Hosting by ORCS Web
Saturday, July 15, 2006 12:44:54 PM UTC
I wrote a simple "find-type" function because I could never remember the namespace a given type was in. (Not quite as nice as tab expansion, but better than nothing.)

For example:
find-type StringBuild

FullName
--------
System.Text.StringBuilder
System.Data.Common.DbConnectionStringBuilder
...

function find-type
{
$appDomain = [System.Threading.Thread]::GetDomain()

$matchedTypes = @()

foreach($assembly in $appDomain.GetAssemblies())
{
$types = $assembly.GetTypes()

foreach($type in $types)
{
foreach ($arg in $args)
{
if ($type.Name -like "*${arg}*")
{
$matchedTypes += $type
}
}
}
}

$matchedTypes | format-table FullName
}
John Bush
Saturday, July 15, 2006 5:07:46 PM UTC
I discovered you can implement find-type (from my other comment) more succinctly like so:

function find-type
{
$searchString = $args[0]
$appDomain = [System.Threading.Thread]::GetDomain()
$appDomain.GetAssemblies() | foreach-object { $_.GetTypes() } | where-object { $_.Name -like "*${searchString}*" } | format-table FullName
}
John Bush
Comments are closed.

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