diff --git a/README.md b/README.md index b85aedd7..ce4682f6 100644 --- a/README.md +++ b/README.md @@ -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 ``` \ No newline at end of file diff --git a/examples/PSCoreApp/MyHttpTrigger/run.ps1 b/examples/PSCoreApp/MyHttpTrigger/run.ps1 index 58a61a1c..93ed1742 100644 --- a/examples/PSCoreApp/MyHttpTrigger/run.ps1 +++ b/examples/PSCoreApp/MyHttpTrigger/run.ps1 @@ -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' @@ -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' -} \ No newline at end of file +}) diff --git a/src/Modules/Azure.Functions.PowerShell.Worker.Module/Azure.Functions.PowerShell.Worker.Module.psm1 b/src/Modules/Azure.Functions.PowerShell.Worker.Module/Azure.Functions.PowerShell.Worker.Module.psm1 index 31e5d3bb..d4616d60 100644 --- a/src/Modules/Azure.Functions.PowerShell.Worker.Module/Azure.Functions.PowerShell.Worker.Module.psm1 +++ b/src/Modules/Azure.Functions.PowerShell.Worker.Module/Azure.Functions.PowerShell.Worker.Module.psm1 @@ -30,7 +30,10 @@ function Get-OutputBinding { param( [Parameter(ValueFromPipeline = $True, ValueFromPipelineByPropertyName = $True)] [string[]] - $Name = '*' + $Name = '*', + + [switch] + $Purge ) begin { $bindings = @{} @@ -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 } } diff --git a/src/PowerShell/PowerShellExtensions.cs b/src/PowerShell/PowerShellExtensions.cs new file mode 100644 index 00000000..1d4e7723 --- /dev/null +++ b/src/PowerShell/PowerShellExtensions.cs @@ -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 InvokeAndClearCommands(this PowerShell pwsh) + { + var result = pwsh.Invoke(); + pwsh.Commands.Clear(); + return result; + } + } +} diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 53a7f514..1f30a760 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -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; @@ -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 _TriggerMetadataParameterName = "TriggerMetadata"; - 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 @@ -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 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 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 ExecuteScriptAndClearCommands(string script) - { - var result = _pwsh.AddScript(script).Invoke(); - _pwsh.Commands.Clear(); - return result; - } - - public PowerShellManager InvokeFunctionAndSetGlobalReturn( + internal Hashtable InvokeFunction( string scriptPath, string entryPoint, Hashtable triggerMetadata, @@ -130,20 +66,25 @@ 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($@"Get-Command {entryPoint}")[0].Parameters; - _pwsh.AddScript($@". {entryPoint} @args"); + parameterMetadata = _pwsh + .AddCommand("Microsoft.PowerShell.Core\\Import-Module").AddParameter("Name", scriptPath) + .AddStatement() + .AddCommand("Microsoft.PowerShell.Core\\Get-Command").AddParameter("Name", entryPoint) + .InvokeAndClearCommands()[0].Parameters; + + _pwsh.AddCommand(entryPoint); } else { - parameterMetadata = ExecuteScriptAndClearCommands($@"Get-Command {scriptPath}")[0].Parameters; - _pwsh.AddScript($@". {scriptPath} @args"); + parameterMetadata = _pwsh.AddCommand("Microsoft.PowerShell.Core\\Get-Command").AddParameter("Name", scriptPath) + .InvokeAndClearCommands()[0].Parameters; + + _pwsh.AddCommand(scriptPath); } } @@ -154,41 +95,52 @@ public PowerShellManager InvokeFunctionAndSetGlobalReturn( } // Gives access to additional Trigger Metadata if the user specifies TriggerMetadata - if(parameterMetadata.ContainsKey(s_TriggerMetadataParameterName)) + if(parameterMetadata.ContainsKey(_TriggerMetadataParameterName)) { - _pwsh.AddParameter(s_TriggerMetadataParameterName, triggerMetadata); + _pwsh.AddParameter(_TriggerMetadataParameterName, triggerMetadata); _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 pipelineItems = _pwsh.InvokeAndClearCommands(); + 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()[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 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(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("Microsoft.PowerShell.Core\\Remove-Module") + .AddParameter("Name", moduleName) + .AddParameter("ErrorAction", "SilentlyContinue") + .InvokeAndClearCommands(); } } } diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index ab3a5de9..9398d235 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -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(); } @@ -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 } }; } @@ -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) {