How to run PowerShell Core scripts from .NET Core applications

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:

  1. Send real (not serialized) class object instances defined in your hosting application as parameters into a script.
  2. Instantiate class object instances in the script (for types defined in your hosting application).
  3. Call static methods on types defined in your hosting application.
  4. 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:

  1. Pass rich objects between different scripts in a multi-step pipeline.
  2. Provide pre-configured complex class instances in as parameters that may be easier to configure in .NET than in PowerShell.
  3. 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:

  1. Is the module actually installed on the system?
  2. Is the module supported in PowerShell Core? Or is it a legacy PowerShell module?
  3. Is it the correct bitness? PowerShell comes in x86 and x64 architectures, and some modules only work in one architecture.

9 thoughts on “How to run PowerShell Core scripts from .NET Core applications

  1. Jorge Silva May 15, 2020 / 4:00 am

    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.

    Liked by 1 person

  2. Saurabh June 6, 2020 / 11:34 am

    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

    Like

    • keithbabinec June 6, 2020 / 12:01 pm

      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.

      Like

      • Saurabh June 6, 2020 / 9:57 pm

        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

        Like

  3. jonskeet July 14, 2020 / 7:15 am

    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?

    Liked by 1 person

    • keithbabinec July 14, 2020 / 9:19 am

      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.

      Like

      • jonskeet July 14, 2020 / 10:07 am

        Thank you – that’s *really* helpful to know 🙂

        Liked by 1 person

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s