Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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;
}
}
}
148 changes: 47 additions & 101 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.

readonly static string s_LogAndSetReturnValueScript = @"
param([Parameter(ValueFromPipeline=$true)]$return)

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";
Copy link
Contributor

Choose a reason for hiding this comment

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

This should be const.


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,19 @@ 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();

// Prepend the path to the internal Modules folder to the PSModulePath
var modulePath = Environment.GetEnvironmentVariable("PSModulePath");
var additionalPath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules");
Environment.SetEnvironmentVariable("PSModulePath", $"{additionalPath}{Path.PathSeparator}{modulePath}");
Copy link
Member Author

Choose a reason for hiding this comment

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

@daxian-dbw I could add one for the user as well:

myFuncApp/Modules:workerCode/Modules:normalPSModulePath

Copy link
Contributor

Choose a reason for hiding this comment

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

Sure, adding myFunApp/Modules makes sense.

}

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 +68,24 @@ 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
.AddScript($@". {scriptPath}")
.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", useLocalScope: true).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,39 @@ 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($"FROM FUNCTION: {psobject.ToString()}");
Copy link
Contributor

Choose a reason for hiding this comment

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

How about OUTPUT: as the prefix?

}

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

var result = _pwsh.AddCommand("Azure.Functions.PowerShell.Worker.Module\\Get-OutputBinding", 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.

The cmdlet will run in local scope by default when not dot-sourcing, so useLocalScope: true is not needed.

.AddParameter("Purge")
.InvokeAndClearCommands<Hashtable>()[0];

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

public Hashtable ReturnBindingHashtable(IDictionary<string, BindingInfo> outBindings)
private void ResetRunspace()
{
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();
}
}
}
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