Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ Prototype for Azure Functions PowerShell Language Worker
```powershell
# Windows if you installed the Azure Functions Core Tools via npm
Remove-Item -Recurse -Force ~\AppData\Roaming\npm\node_modules\azure-functions-core-tools\bin\workers\powershell
Copy-Item src\Azure.Functions.PowerShell.Worker\bin\Debug\netcoreapp2.1\publish ~\AppData\Roaming\npm\node_modules\azure-functions-core-tools\bin\workers\powershell -Recurse -Force
Copy-Item src\bin\Debug\netcoreapp2.1\publish ~\AppData\Roaming\npm\node_modules\azure-functions-core-tools\bin\workers\powershell -Recurse -Force

# macOS if you installed the Azure Functions Core Tools via brew
Remove-Item -Recurse -Force /usr/local/Cellar/azure-functions-core-tools/2.0.1-beta.33/workers/powershell
Copy-Item src/Azure.Functions.PowerShell.Worker/bin/Debug/netcoreapp2.1/publish /usr/local/Cellar/azure-functions-core-tools/2.0.1-beta.33/workers/powershell -Recurse -Force
Copy-Item src/bin/Debug/netcoreapp2.1/publish /usr/local/Cellar/azure-functions-core-tools/2.0.1-beta.33/workers/powershell -Recurse -Force
```
12 changes: 5 additions & 7 deletions examples/PSCoreApp/MyHttpTrigger/run.ps1
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
param($req, $TriggerMetadata)

# Write-Host $TriggerMetadata["Name"]

# Invoked with Invoke-RestMethod:
# irm http://localhost:7071/api/MyHttpTrigger?Name=Tyler
# Input bindings are added to the scope of the script: ex. `$req`
# Input bindings are added via param block

param($req, $TriggerMetadata)

# If no name was passed by query parameter
$name = 'World'
Expand All @@ -22,7 +20,7 @@ Write-Warning "Warning $name"
$name

# You set the value of your output bindings by assignment `$nameOfOutputBinding = 'foo'`
$res = [HttpResponseContext]@{
Push-OutputBinding -Name res -Value ([HttpResponseContext]@{
Body = @{ Hello = $name }
ContentType = 'application/json'
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ function Get-OutputBinding {
param(
[Parameter(ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)]
[string[]]
$Name = '*'
$Name = '*',

[switch]
$Purge
)
begin {
$bindings = @{}
Expand All @@ -39,6 +42,9 @@ function Get-OutputBinding {
$script:_OutputBindings.GetEnumerator() | Where-Object Name -Like $Name | ForEach-Object { $null = $bindings.Add($_.Name, $_.Value) }
}
end {
if($Purge.IsPresent) {
$script:_OutputBindings.Clear()
}
return $bindings
}
}
Expand Down
27 changes: 27 additions & 0 deletions src/PowerShell/PowerShellExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
//

using System.Collections.ObjectModel;

namespace Microsoft.Azure.Functions.PowerShellWorker.PowerShell
{
using System.Management.Automation;

internal static class PowerShellExtensions
{
public static void InvokeAndClearCommands(this PowerShell pwsh)
{
pwsh.Invoke();
pwsh.Commands.Clear();
}

public static Collection<T> InvokeAndClearCommands<T>(this PowerShell pwsh)
{
var result = pwsh.Invoke<T>();
pwsh.Commands.Clear();
return result;
}
}
}
161 changes: 56 additions & 105 deletions src/PowerShell/PowerShellManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Text;
using System.IO;

using Microsoft.Azure.Functions.PowerShellWorker.Utility;
using Microsoft.Azure.WebJobs.Script.Grpc.Messages;
Expand All @@ -20,33 +20,16 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.PowerShell

internal class PowerShellManager
{
// This script handles when the user adds something to the pipeline.
// It logs the item that comes and stores it as the $return out binding.
// The last item stored as $return will be returned to the function host.
private const string s_TriggerMetadataParameterName = "TriggerMetadata";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The field name should be changed to TriggerMetadataParameterName, since it's const.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a _ since it's private


readonly static string s_LogAndSetReturnValueScript = @"
param([Parameter(ValueFromPipeline=$true)]$return)
private RpcLogger _logger;
private PowerShell _pwsh;

Write-Information $return

Set-Variable -Name '$return' -Value $return -Scope global
";

readonly static string s_SetExecutionPolicyOnWindowsScript = @"
if ($IsWindows)
{
Set-ExecutionPolicy -ExecutionPolicy Unrestricted -Scope Process
}
";

readonly static string s_TriggerMetadataParameterName = "TriggerMetadata";

RpcLogger _logger;
PowerShell _pwsh;

PowerShellManager(RpcLogger logger)
internal PowerShellManager(RpcLogger logger)
{
_pwsh = PowerShell.Create(InitialSessionState.CreateDefault());
var initialSessionState = InitialSessionState.CreateDefault();
initialSessionState.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Unrestricted;
_pwsh = PowerShell.Create(initialSessionState);
_logger = logger;

// Setup Stream event listeners
Expand All @@ -59,64 +42,17 @@ internal class PowerShellManager
_pwsh.Streams.Warning.DataAdding += streamHandler.WarningDataAdding;
}

public static PowerShellManager Create(RpcLogger logger)
{
var manager = new PowerShellManager(logger);

internal void InitializeRunspace()
{
// Add HttpResponseContext namespace so users can reference
// HttpResponseContext without needing to specify the full namespace
manager.ExecuteScriptAndClearCommands($"using namespace {typeof(HttpResponseContext).Namespace}");
manager.ExecuteScriptAndClearCommands(s_SetExecutionPolicyOnWindowsScript);
return manager;
}

static string BuildBindingHashtableScript(IDictionary<string, BindingInfo> outBindings)
{
// Since all of the out bindings are stored in variables at this point,
// we must construct a script that will return those output bindings in a hashtable
StringBuilder script = new StringBuilder();
script.AppendLine("@{");
foreach (KeyValuePair<string, BindingInfo> binding in outBindings)
{
script.Append("'");
script.Append(binding.Key);

// since $return has a dollar sign, we have to treat it differently
if (binding.Key == "$return")
{
script.Append("' = ");
}
else
{
script.Append("' = $");
}
script.AppendLine(binding.Key);
}
script.AppendLine("}");

return script.ToString();
}

void ResetRunspace()
{
// Reset the runspace to the Initial Session State
_pwsh.Runspace.ResetRunspaceState();
_pwsh.AddScript($"using namespace {typeof(HttpResponseContext).Namespace}").InvokeAndClearCommands();

// Set the PSModulePath
Environment.SetEnvironmentVariable("PSModulePath", Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules"));
}

void ExecuteScriptAndClearCommands(string script)
{
_pwsh.AddScript(script).Invoke();
_pwsh.Commands.Clear();
}

public Collection<T> ExecuteScriptAndClearCommands<T>(string script)
{
var result = _pwsh.AddScript(script).Invoke<T>();
_pwsh.Commands.Clear();
return result;
}

public PowerShellManager InvokeFunctionAndSetGlobalReturn(
internal Hashtable InvokeFunction(
string scriptPath,
string entryPoint,
Hashtable triggerMetadata,
Expand All @@ -130,20 +66,26 @@ public PowerShellManager InvokeFunctionAndSetGlobalReturn(
// If it does, we invoke the command of that name. We also need to fetch
// the ParameterMetadata so that we can tell whether or not the user is asking
// for the $TriggerMetadata

using (ExecutionTimer.Start(_logger, "Parameter metadata retrieved."))
{
if (entryPoint != "")
{
ExecuteScriptAndClearCommands($@". {scriptPath}");
parameterMetadata = ExecuteScriptAndClearCommands<FunctionInfo>($@"Get-Command {entryPoint}")[0].Parameters;
_pwsh.AddScript($@". {entryPoint} @args");
parameterMetadata = _pwsh
.AddCommand("Import-Module")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use fully qualified name, same to other places like Get-Command.

.AddParameter("Name", scriptPath)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be moved to the above line, as this parameter and the command are logically grouped.

.AddStatement()
.AddCommand("Get-Command", useLocalScope: true).AddParameter("Name", entryPoint)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When running c# cmdlet, there is no need to run it in a local scope, as c# cmdlet doesn't create scope anyway.

.InvokeAndClearCommands<FunctionInfo>()[0].Parameters;

_pwsh.AddCommand(entryPoint, useLocalScope: true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to use useLocalScope: true when you are not dot-sourcing. Please check all occurrences.


}
else
{
parameterMetadata = ExecuteScriptAndClearCommands<ExternalScriptInfo>($@"Get-Command {scriptPath}")[0].Parameters;
_pwsh.AddScript($@". {scriptPath} @args");
parameterMetadata = _pwsh.AddCommand("Get-Command").AddParameter("Name", scriptPath)
.InvokeAndClearCommands<ExternalScriptInfo>()[0].Parameters;

_pwsh.AddCommand(scriptPath, useLocalScope: true);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are not dot-sourcing the script, it will run in its script scope, which is a local scope, so no need to specify useLocalScope: true.

}
}

Expand All @@ -160,35 +102,44 @@ public PowerShellManager InvokeFunctionAndSetGlobalReturn(
_logger.LogDebug($"TriggerMetadata found. Value:{Environment.NewLine}{triggerMetadata.ToString()}");
}

// This script handles when the user adds something to the pipeline.
PSObject returnObject = null;
using (ExecutionTimer.Start(_logger, "Execution of the user's function completed."))
{
ExecuteScriptAndClearCommands(s_LogAndSetReturnValueScript);
// Log everything we received from the pipeline and set the last one to be the ReturnObject
Collection<PSObject> pipelineItems = _pwsh.InvokeAndClearCommands<PSObject>();
foreach (var psobject in pipelineItems)
{
_logger.LogInformation($"OUTPUT: {psobject.ToString()}");
}

returnObject = pipelineItems[pipelineItems.Count - 1];
}

var result = _pwsh.AddCommand("Azure.Functions.PowerShell.Worker.Module\\Get-OutputBinding")
.AddParameter("Purge")
.InvokeAndClearCommands<Hashtable>()[0];

if(returnObject != null)
{
result.Add("$return", returnObject);
}
return this;
return result;
}
catch(Exception e)
finally
{
ResetRunspace();
throw e;
ResetRunspace(scriptPath);
}
}

public Hashtable ReturnBindingHashtable(IDictionary<string, BindingInfo> outBindings)
private void ResetRunspace(string scriptPath)
{
try
{
// This script returns a hashtable that contains the
// output bindings that we will return to the function host.
var result = ExecuteScriptAndClearCommands<Hashtable>(BuildBindingHashtableScript(outBindings))[0];
ResetRunspace();
return result;
}
catch(Exception e)
{
ResetRunspace();
throw e;
}
// Reset the runspace to the Initial Session State
_pwsh.Runspace.ResetRunspaceState();

// If the function had an entry point, this will remove the module that was loaded
var moduleName = Path.GetFileNameWithoutExtension(scriptPath);
_pwsh.AddCommand("Get-Module").AddParameter("Name", moduleName)
.AddCommand("Remove-Module").InvokeAndClearCommands();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A Remove-Module -ErrorAction SilentlyContinue would do. A bit faster than having a pipeline.

}
}
}
25 changes: 18 additions & 7 deletions src/RequestProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ internal RequestProcessor(MessagingStream msgStream)
{
_msgStream = msgStream;
_logger = new RpcLogger(msgStream);
_powerShellManager = PowerShellManager.Create(_logger);
_powerShellManager = new PowerShellManager(_logger);
_functionLoader = new FunctionLoader();
}

Expand Down Expand Up @@ -63,15 +63,27 @@ internal async Task ProcessRequestLoop()

internal StreamingMessage ProcessWorkerInitRequest(StreamingMessage request)
{
StatusResult status = new StatusResult()
{
Status = StatusResult.Types.Status.Success
};

try
{
_powerShellManager.InitializeRunspace();
}
catch (Exception e)
{
status.Status = StatusResult.Types.Status.Failure;
status.Exception = e.ToRpcException();
}

return new StreamingMessage()
{
RequestId = request.RequestId,
WorkerInitResponse = new WorkerInitResponse()
{
Result = new StatusResult()
{
Status = StatusResult.Types.Status.Success
}
Result = status
}
};
}
Expand Down Expand Up @@ -143,8 +155,7 @@ internal StreamingMessage ProcessInvocationRequest(StreamingMessage request)
try
{
result = _powerShellManager
.InvokeFunctionAndSetGlobalReturn(scriptPath, entryPoint, triggerMetadata, invocationRequest.InputData)
.ReturnBindingHashtable(functionInfo.OutputBindings);
.InvokeFunction(scriptPath, entryPoint, triggerMetadata, invocationRequest.InputData);
}
catch (Exception e)
{
Expand Down