I wrote an MSDN blog post a few years back (here) that demonstrated how to run PowerShell scripts by hosting the PowerShell runtime inside a C# / .NET application. A few things have changed since that post; including the introduction of .NET Core and PowerShell Core, better async support, and a few new best practices.
In this article we will jump forward to take a look at runspace execution for PowerShell Core and .NET Core applications. Including topics like project setup, runspace usage examples, stream handling, shared app domain use cases, and some helpful troubleshooting tips.
Full sample code
Complete example code for everything in this post can be found on Github at this repository: https://github.com/keithbabinec/PowerShellHostedRunspaceStarterkits
Project setup
Start by creating a new .NET Core console application. Then add a package reference to the PowerShell SDK. At the time of this writing, I’m using .NET Core 3.1 with version 6.2.4 of the PowerShell SDK. This is the foundation step for all three of the example scenarios below.
Install-Package Microsoft.PowerShell.SDK
Example 1 (Default runspace)
For the first example we are going to show the bare minimum code to run a script with a couple of input parameters, under the default runspace, and printing the resulting pipeline objects.
Sidebar: What is a runspace?
A PowerShell runspace executes your script code and maintains context/state for the session (loaded modules, imported functions, remote session state, etc). You can view the current runspaces in any PowerShell session by running the Get-Runspace cmdlet. In this example we use the default runspace.
Calling PowerShell.Create() will get you a new hosted instance of PowerShell you can use within your .NET process. We call the .AddScript() and .AddParameters() methods to pass our input, and then call .InvokeAsync() to execute the pipeline.
After the pipeline finishes we print any objects that were sent to the pipeline/output stream.
/// <summary> /// Runs a PowerShell script with parameters and prints the resulting pipeline objects to the console output. /// </summary> /// <param name="scriptContents">The script file contents.</param> /// <param name="scriptParameters">A dictionary of parameter names and parameter values.</param> public async Task RunScript(string scriptContents, Dictionary<string, object> scriptParameters) { // create a new hosted PowerShell instance using the default runspace. // wrap in a using statement to ensure resources are cleaned up. using (PowerShell ps = PowerShell.Create()) { // specify the script code to run. ps.AddScript(scriptContents); // specify the parameters to pass into the script. ps.AddParameters(scriptParameters); // execute the script and await the result. var pipelineObjects = await ps.InvokeAsync().ConfigureAwait(false); // print the resulting pipeline objects to the console. foreach (var item in pipelineObjects) { Console.WriteLine(item.BaseObject.ToString()); } } }
Example 2 (Custom runspace pool)
The previous example works fine in many simple hosted script scenarios. However if you need to handle additional stream output (warnings, non-terminating errors, etc), or if you need to execute many scripts simultaneously, this example will demonstrate those usage patterns.
Sidebar: What are PowerShell streams?
PowerShell uses Piping (|) to pass objects from one cmdlet to another. In order to differentiate between pipeline objects and other output (errors, warnings, traces, etc), PowerShell writes objects to the different streams (or pipes) for these different types of data. That way the next cmdlet in the chain can just watch the Output stream and ignore the other streams.
In this example we initialize and use a RunspacePool. Maintaining a runspace pool is helpful for multithreading scenarios where you need to run many scripts at the same time and have control over the throttle settings. It also allows us to import modules for each runspace used, configure thread re-use options, or specify remote connection settings.
We also watch for three of the other output streams (warning, error, and information). This is helpful if we need to monitor things other than pipeline output, like Write-Host/Write-Information.
using System; using System.Collections.Generic; using System.Management.Automation; using System.Management.Automation.Runspaces; using System.Threading.Tasks; namespace CustomRunspaceStarter { /// <summary> /// Contains functionality for executing PowerShell scripts. /// </summary> public class HostedRunspace { /// <summary> /// The PowerShell runspace pool. /// </summary> private RunspacePool RsPool { get; set; } /// <summary> /// Initialize the runspace pool. /// </summary> /// <param name="minRunspaces"></param> /// <param name="maxRunspaces"></param> public void InitializeRunspaces(int minRunspaces, int maxRunspaces, string[] modulesToLoad) { // create the default session state. // session state can be used to set things like execution policy, language constraints, etc. // optionally load any modules (by name) that were supplied. var defaultSessionState = InitialSessionState.CreateDefault(); defaultSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Unrestricted; foreach (var moduleName in modulesToLoad) { defaultSessionState.ImportPSModule(moduleName); } // use the runspace factory to create a pool of runspaces // with a minimum and maximum number of runspaces to maintain. RsPool = RunspaceFactory.CreateRunspacePool(defaultSessionState); RsPool.SetMinRunspaces(minRunspaces); RsPool.SetMaxRunspaces(maxRunspaces); // set the pool options for thread use. // we can throw away or re-use the threads depending on the usage scenario. RsPool.ThreadOptions = PSThreadOptions.UseNewThread; // open the pool. // this will start by initializing the minimum number of runspaces. RsPool.Open(); } /// <summary> /// Runs a PowerShell script with parameters and prints the resulting pipeline objects to the console output. /// </summary> /// <param name="scriptContents">The script file contents.</param> /// <param name="scriptParameters">A dictionary of parameter names and parameter values.</param> public async Task RunScript(string scriptContents, Dictionary<string, object> scriptParameters) { if (RsPool == null) { throw new ApplicationException("Runspace Pool must be initialized before calling RunScript()."); } // create a new hosted PowerShell instance using a custom runspace. // wrap in a using statement to ensure resources are cleaned up. using (PowerShell ps = PowerShell.Create()) { // use the runspace pool. ps.RunspacePool = RsPool; // specify the script code to run. ps.AddScript(scriptContents); // specify the parameters to pass into the script. ps.AddParameters(scriptParameters); // subscribe to events from some of the streams ps.Streams.Error.DataAdded += Error_DataAdded; ps.Streams.Warning.DataAdded += Warning_DataAdded; ps.Streams.Information.DataAdded += Information_DataAdded; // execute the script and await the result. var pipelineObjects = await ps.InvokeAsync().ConfigureAwait(false); // print the resulting pipeline objects to the console. Console.WriteLine("----- Pipeline Output below this point -----"); foreach (var item in pipelineObjects) { Console.WriteLine(item.BaseObject.ToString()); } } } /// <summary> /// Handles data-added events for the information stream. /// </summary> /// <remarks> /// Note: Write-Host and Write-Information messages will end up in the information stream. /// </remarks> /// <param name="sender"></param> /// <param name="e"></param> private void Information_DataAdded(object sender, DataAddedEventArgs e) { var streamObjectsReceived = sender as PSDataCollection<InformationRecord>; var currentStreamRecord = streamObjectsReceived[e.Index]; Console.WriteLine($"InfoStreamEvent: {currentStreamRecord.MessageData}"); } /// <summary> /// Handles data-added events for the warning stream. /// </summary> /// <param name="sender"></param> /// <param name="e"></param> private void Warning_DataAdded(object sender, DataAddedEventArgs e) { var streamObjectsReceived = sender as PSDataCollection<WarningRecord>; var currentStreamRecord = streamObjectsReceived[e.Index]; Console.WriteLine($"WarningStreamEvent: {currentStreamRecord.Message}"); } /// <summary> /// Handles data-added events for the error stream. /// </summary> /// <remarks> /// Note: Uncaught terminating errors will stop the pipeline completely. /// Non-terminating errors will be written to this stream and execution will continue. /// </remarks> /// <param name="sender"></param> /// <param name="e"></param> private void Error_DataAdded(object sender, DataAddedEventArgs e) { var streamObjectsReceived = sender as PSDataCollection<ErrorRecord>; var currentStreamRecord = streamObjectsReceived[e.Index]; Console.WriteLine($"ErrorStreamEvent: {currentStreamRecord.Exception}"); } } }
Shared App Domain
One final example I created explores the shared .NET Application Domain. Since a hosted PowerShell runspace lives in the same .NET app domain as your calling application, you can do the following:
- Send real (not serialized) class object instances defined in your hosting application as parameters into a script.
- Instantiate class object instances in the script (for types defined in your hosting application).
- Call static methods on types defined in your hosting application.
- Return class objects to the pipeline.
The sample code for this one is pretty lengthy, so head over to the Github repo to view the full example.
Note: There is no special configuration or setup required to enable a shared app domain. The shared app domain can be leveraged in all runspace use cases. The main purpose of this code sample is to demonstrate how to leverage it.
You can leverage the code in this example to do things like:
- Pass rich objects between different scripts in a multi-step pipeline.
- Provide pre-configured complex class instances in as parameters that may be easier to configure in .NET than in PowerShell.
- Return a results/summary class object to the pipeline, instead of a large array of pipeline objects you need to interpret to determine if the script was successful or not.
Common problems
Here are a few of the common pitfalls that you might encounter when running hosted PowerShell scripts:
Execution policy
Executing scripts can fail if your execution policy is configured to prevent scripts from running. You may need to change your execution policy to resolve this. Read more here.
Pipeline errors
Terminating errors are the fatal errors that can occur inside your script code. If this happens the pipeline stops immediately and .InvokeAsync() will throw an exception. Implement error handling in your scripts to resolve this. Read more here.
Pipeline output types
If your hosting application prints the pipeline output objects to the console with .ToString() like the examples above do, then you may see type names printed in some cases. For example: a script calling the Get-Service cmdlet would show this in your console output repeated several times: “System.ServiceProcess.ServiceController“.
This is expected behavior because the pipeline returns real class objects. The Get-Service command returns this type. For .NET types that do not implement a custom ToString() override, the type name will be printed instead. To solve this you can reach into the object and pull out or print other properties you need (like <serviceobject>.ServiceName).
Module loading problems
Trying to load a module inside your script and it isn’t loading? Check the basics:
- Is the module actually installed on the system?
- Is the module supported in PowerShell Core? Or is it a legacy PowerShell module?
- Is it the correct bitness? PowerShell comes in x86 and x64 architectures, and some modules only work in one architecture.
Hi! Great article, it helped me somewhat… I say somewhat because what I intend to do is to execute a lot of scripts over Azure. But, here’s the catch, the first command is Login-AzAccount, and when I use your code, the app stalls. On the powershell command line, a warning is shown when I execute that command, telling me to open the browser, etc, etc, etc. But on your sample code, nothing is output in any of the streams, and the app just stalls. Can you help me?
Jorge from Portugal.
LikeLiked by 1 person
To solve this problem you will need to run Login-AzAccount but provide additional parameters to authenticate using a service principal with a password or certificate. This way the command can run unattended (no dialog prompts or interaction). You can read more about that process here: https://docs.microsoft.com/en-us/powershell/azure/create-azure-service-principal-azureps
LikeLike
Hi
I am trying to run some azureAD commands in function app 3.0 in C#
and I am getting an exception “ClassName”: “System.Management.Automation.CommandNotFoundException”,
“Message”: “The ‘Get-AzureRmTenant’ command was found in the module ‘AzureRM.Profile’, but the module could not be loaded. For more information, run ‘Import-Module AzureRM.Profile’.”,
“Data”: null,
and when I try to load the module using this cmdlet Import-Module AzureRM.Profile’
it gives an error saying incorrect cmdlet
To cross-check whether the commands are available or not I ran Get-commands that listed whole lot of AzureRM commands
LikeLike
So I haven’t run into this particular problem before, but I’m guessing that it might be caused by trying to load AzureRM cmdlets from dotnet core / PowerShell Core. I would recommend trying to use the newer dotnet core supported Azure commands available in the ‘Az’ module. The equivalent/replacement command is Get-AzTenant. This also assumes that you have the Az module installed.
LikeLike
There is a restriction in function apps. It does not allow to add/install modules from nuget or powershell gallery. Either i download and manual upload and then try to import module
not sure it will work thats too hacky.
Function apps pwershell execution using C# has too many limitations.
My basic doubt is if module is not available why the command is listed ?
Anyway i am going to open issue on github let see if get answers.
Thanks Man
LikeLike
Hey,
I am trying to achieve something similar. I have explained my problem scenario at https://stackoverflow.com/questions/62895226/run-powershell-script-cli-command-from-c-sharp-azure-function-with-linux-system. Can you please check and help me?
LikeLike
Not quite sure what’s going on there, sorry– I haven’t tried to get this working within a linux hosted function app before.
LikeLike
I may be missing something – I’m trying to understand the Shared AppDomain example, and I don’t see any difference between that and the default runspace example. Is there code missing here, or does the default runspace use the same AppDomain as the caller anyway?
LikeLiked by 1 person
Hi Jon– yeah great question. The shared appdomain example does in fact use the same code sample as the default runspace. Meaning you don’t have to do anything special to the runspace setup/configuration to enable this, it just works out of the box. The main difference in the project files here is in the program.cs file has different script code, and the the output handling after the runspace is called. I will add some clarifying comments in the Github repo and the post to make this more clear.
LikeLike
Thank you – that’s *really* helpful to know 🙂
LikeLiked by 1 person
Thank you for this great article,but i run the code and it is run successful without any errors but it is not effecting at all. my script is creating new local user and when i test it directly from the power-shell it is works and the user created but when i try it from this code it is not creating any user.
LikeLiked by 1 person
Its likely that there is an error in your script when it is being executed in the runspace. To help troubleshoot I would recommend setting: $ErrorActionPreference = ‘Stop’, at the top of your script, that way any terminating errors bubble up. Its possible there are errors you are not seeing.
LikeLike
Great article, thanks a lot.
I have a question about the bitness. Apparently our VS environment opens the x86 PowerShell architecture, and then a call to MSOnline fails because it only works in x64. Setting the target plattform of the project to x64 does not seem to help. Do you have any suggestions on how to force PowerShell to run in the x64 architecture?
Thanks!
LikeLike
If you set the platform target in the project properties to x64 (instead of anycpu) that should normally help. Is it just happening while launching in Visual Studio debug mode? Or does it also run incorrectly when launched outside of Visual Studio? If it only behaves incorrectly in Visual Studio, perhaps it could be the Debug launch settings in VS (for example its specifically launching an x86 debug process)
LikeLike
Thanks a lot for your reply! The error is also happening when running the Release build of the executable outside Visual Studio. Thanks!
LikeLike
Hmm not really sure what’s happening then, I would recommend trying a new stackoverflow question where you can post the code and more details.
LikeLike
This is a really good article. Thanks Keith for putting this together.
Is there a way I can run PowerShell 2 commands using this? I have some legacy scripts that I need to run via a .netcore app. And they require running using PowerShell 2.
LikeLiked by 1 person
@Siva — great question. The answer depends on the cmdlets used in your scripts.
Using the runspace/SDK method described in this article means you can only run PowerShell Core compatible commands. Some of the commands used in your existing PS 2.0 scripts might be compatible, some might not be. I would review the breaking changes lists for PS 6.0 to help identify those potential issues: https://docs.microsoft.com/en-us/powershell/scripting/whats-new/breaking-changes-ps6?view=powershell-7
If you have commands that would break under PowerShell Core, then the only other options are to update your PowerShell scripts to be compatible, or to use the Process.Start() in .NET to launch a shell process to call Windows PowerShell. You just won’t get the integration used in the examples here.
LikeLike
Thank you Keith for your reply. We are to run some of the PS scripts in here – https://github.com/redcanaryco/atomic-red-team/tree/master/atomics
And we do not plan to refactor these scripts at this stage. One of the script that I was running as using `Get-WmiObject` which definitely seem to have been v1 cmdlet. So that indicates that I only have the option of using Process.Start()! I might give that a go.
LikeLiked by 1 person