From f5ecfe3c5c8afe2b7d53f3b788557cc3c2eeaeac Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Wed, 12 Dec 2018 21:28:02 -0800 Subject: [PATCH 1/4] Initial Renaming --- examples/PSCoreApp/{Profile.ps1 => profile.ps1} | 0 src/PowerShell/PowerShellManager.cs | 2 +- src/RequestProcessor.cs | 2 +- test/Unit/PowerShell/PowerShellManagerTests.cs | 8 ++++---- 4 files changed, 6 insertions(+), 6 deletions(-) rename examples/PSCoreApp/{Profile.ps1 => profile.ps1} (100%) diff --git a/examples/PSCoreApp/Profile.ps1 b/examples/PSCoreApp/profile.ps1 similarity index 100% rename from examples/PSCoreApp/Profile.ps1 rename to examples/PSCoreApp/profile.ps1 diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 91a51d4a..2ca85ae3 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -57,7 +57,7 @@ internal void InitializeRunspace() Environment.SetEnvironmentVariable("PSModulePath", Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules")); } - internal void InvokeProfile() + internal void PerformRunspaceLevelInitialization() { string functionAppProfileLocation = FunctionLoader.FunctionAppProfilePath; if (functionAppProfileLocation == null) diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index aef33862..7038b60b 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -115,7 +115,7 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) _powerShellManager.PrependToPSModulePath(FunctionLoader.FunctionAppModulesPath); - _powerShellManager.InvokeProfile(); + _powerShellManager.PerformRunspaceLevelInitialization(); _initializedFunctionApp = true; } diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 6269c0ae..78531967 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -171,7 +171,7 @@ public void ProfileShouldWork() AppDomain.CurrentDomain.BaseDirectory, "Unit/PowerShell/TestScripts/ProfileBasic")); - defaultTestManager.InvokeProfile(); + defaultTestManager.PerformRunspaceLevelInitialization(); Assert.Single(defaultTestLogger.FullLog); Assert.Equal("Information: INFORMATION: Hello PROFILE", defaultTestLogger.FullLog[0]); @@ -186,7 +186,7 @@ public void ProfileDoesNotExist() CleanupFunctionLoaderStaticPaths(); FunctionLoader.SetupWellKnownPaths(AppDomain.CurrentDomain.BaseDirectory); - defaultTestManager.InvokeProfile(); + defaultTestManager.PerformRunspaceLevelInitialization(); Assert.Single(defaultTestLogger.FullLog); Assert.Matches("Trace: No 'profile.ps1' is found at the FunctionApp root folder: ", defaultTestLogger.FullLog[0]); @@ -203,7 +203,7 @@ public void ProfileWithTerminatingError() AppDomain.CurrentDomain.BaseDirectory, "Unit/PowerShell/TestScripts/ProfileWithTerminatingError")); - Assert.Throws(() => defaultTestManager.InvokeProfile()); + Assert.Throws(() => defaultTestManager.PerformRunspaceLevelInitialization()); Assert.Single(defaultTestLogger.FullLog); Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", defaultTestLogger.FullLog[0]); } @@ -219,7 +219,7 @@ public void ProfileWithNonTerminatingError() AppDomain.CurrentDomain.BaseDirectory, "Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError")); - defaultTestManager.InvokeProfile(); + defaultTestManager.PerformRunspaceLevelInitialization(); Assert.Equal(2, defaultTestLogger.FullLog.Count); Assert.Equal("Error: ERROR: help me!", defaultTestLogger.FullLog[0]); From c1bc89275876a86291ac7ad342ad5b9e034638ce Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Thu, 13 Dec 2018 14:53:26 -0800 Subject: [PATCH 2/4] Refactor the code to pre-validate at 'FunctionLoadRequest' and officially support entrypoint --- src/FunctionLoader.cs | 234 +++++++-- src/PowerShell/PowerShellManager.cs | 111 ++--- src/Public/FunctionMetadata.cs | 10 +- src/RequestProcessor.cs | 54 +-- src/worker.config.json | 2 +- test/E2E/HttpTrigger.Tests.ps1 | 14 +- .../function.json | 22 + .../TestHttpTriggerWithEntryPoint/module.psm1 | 32 ++ .../function.json | 22 + .../module.psm1 | 15 + .../function.json | 22 + .../module.psm1 | 32 ++ ...ure.Functions.PowerShellWorker.Test.csproj | 23 +- test/Unit/Function/FunctionLoaderTests.cs | 445 +++++++++++++++--- .../Function/TestScripts/BasicFuncScript.ps1 | 10 + .../BasicFuncScriptWithTriggerMetadata.ps1 | 8 + .../Function/TestScripts/FuncHasNoParams.ps1 | 8 + .../TestScripts/FuncWithEntryPoint.psm1 | 19 + .../FuncWithEntryPointAndTriggerMetadata.psm1 | 11 + .../TestScripts/FuncWithMultiEntryPoints.psm1 | 14 + .../TestScripts/FuncWithParseError.ps1 | 8 + .../Unit/PowerShell/PowerShellManagerTests.cs | 254 +++++----- .../testBasicFunctionWithTriggerMetadata.ps1 | 2 +- ...nt.ps1 => testFunctionWithEntryPoint.psm1} | 0 test/Unit/Utility/TypeExtensionsTests.cs | 49 +- 25 files changed, 1011 insertions(+), 410 deletions(-) create mode 100644 test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPoint/function.json create mode 100644 test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPoint/module.psm1 create mode 100644 test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndProfile/function.json create mode 100644 test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndProfile/module.psm1 create mode 100644 test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndTriggerMetadata/function.json create mode 100644 test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndTriggerMetadata/module.psm1 create mode 100644 test/Unit/Function/TestScripts/BasicFuncScript.ps1 create mode 100644 test/Unit/Function/TestScripts/BasicFuncScriptWithTriggerMetadata.ps1 create mode 100644 test/Unit/Function/TestScripts/FuncHasNoParams.ps1 create mode 100644 test/Unit/Function/TestScripts/FuncWithEntryPoint.psm1 create mode 100644 test/Unit/Function/TestScripts/FuncWithEntryPointAndTriggerMetadata.psm1 create mode 100644 test/Unit/Function/TestScripts/FuncWithMultiEntryPoints.psm1 create mode 100644 test/Unit/Function/TestScripts/FuncWithParseError.ps1 rename test/Unit/PowerShell/TestScripts/{testFunctionWithEntryPoint.ps1 => testFunctionWithEntryPoint.psm1} (100%) diff --git a/src/FunctionLoader.cs b/src/FunctionLoader.cs index 4b8e4228..bcde07a8 100644 --- a/src/FunctionLoader.cs +++ b/src/FunctionLoader.cs @@ -5,22 +5,30 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Management.Automation.Language; +using System.Text; -using Google.Protobuf.Collections; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; namespace Microsoft.Azure.Functions.PowerShellWorker { + /// + /// FunctionLoader holds metadata of functions. + /// internal class FunctionLoader { - private readonly MapField _loadedFunctions = new MapField(); + private readonly Dictionary _loadedFunctions = new Dictionary(); - internal static string FunctionAppRootPath { get; set; } - internal static string FunctionAppProfilePath { get; set; } = null; - internal static string FunctionAppModulesPath { get; set; } = null; + internal static string FunctionAppRootPath { get; private set; } + internal static string FunctionAppProfilePath { get; private set; } + internal static string FunctionAppModulesPath { get; private set; } + /// + /// Query for function metadata can happen in parallel. + /// internal AzFunctionInfo GetFunctionInfo(string functionId) { if (_loadedFunctions.TryGetValue(functionId, out AzFunctionInfo funcInfo)) @@ -32,33 +40,26 @@ internal AzFunctionInfo GetFunctionInfo(string functionId) } /// - /// Runs once per Function in a Function App. Loads the Function info into the Function Loader + /// This method runs once per 'FunctionLoadRequest' during the code start of the worker. + /// It will always run synchronously because we process 'FunctionLoadRequest' synchronously. /// internal void LoadFunction(FunctionLoadRequest request) { - // TODO: catch "load" issues at "func start" time. - // ex. Script doesn't exist, entry point doesn't exist _loadedFunctions.Add(request.FunctionId, new AzFunctionInfo(request.Metadata)); } /// - /// Sets up well-known paths like the Function App root, - /// the Function App 'Modules' folder, - /// and the Function App's profile.ps1 + /// Setup the well known paths about the FunctionApp. + /// This method is called only once during the code start. /// - internal static void SetupWellKnownPaths(string functionAppRootLocation) + internal static void SetupWellKnownPaths(FunctionLoadRequest request) { - FunctionLoader.FunctionAppRootPath = functionAppRootLocation; - FunctionLoader.FunctionAppModulesPath = Path.Combine(functionAppRootLocation, "Modules"); - - // Find the profile.ps1 in the Function App root if it exists - List profiles = Directory.EnumerateFiles(functionAppRootLocation, "profile.ps1", new EnumerationOptions { - MatchCasing = MatchCasing.CaseInsensitive - }).ToList(); - if (profiles.Count() > 0) - { - FunctionLoader.FunctionAppProfilePath = profiles[0]; - } + FunctionAppRootPath = Path.GetFullPath(Path.Join(request.Metadata.Directory, "..")); + FunctionAppModulesPath = Path.Join(FunctionAppRootPath, "Modules"); + + var options = new EnumerationOptions { MatchCasing = MatchCasing.CaseInsensitive }; + var profiles = Directory.EnumerateFiles(FunctionAppRootPath, "profile.ps1", options); + FunctionAppProfilePath = profiles.FirstOrDefault(); } } @@ -70,6 +71,31 @@ internal enum AzFunctionType ActivityFunction = 3 } + /// + /// A read-only type that represents a BindingInfo. + /// + public class ReadOnlyBindingInfo + { + internal ReadOnlyBindingInfo(BindingInfo bindingInfo) + { + Type = bindingInfo.Type; + Direction = bindingInfo.Direction; + } + + /// + /// The type of the binding. + /// + public readonly string Type; + + /// + /// The direction of the binding. + /// + public readonly BindingInfo.Types.Direction Direction; + } + + /// + /// This type represents the metadata of an Azure PowerShell Function. + /// internal class AzFunctionInfo { private const string OrchestrationTrigger = "orchestrationTrigger"; @@ -78,54 +104,168 @@ internal class AzFunctionInfo internal const string TriggerMetadata = "TriggerMetadata"; internal const string DollarReturn = "$return"; - internal readonly string Directory; + internal readonly string FuncDirectory; + internal readonly string FuncName; internal readonly string EntryPoint; - internal readonly string FunctionName; internal readonly string ScriptPath; + internal readonly HashSet FuncParameters; internal readonly AzFunctionType Type; - internal readonly MapField AllBindings; - internal readonly MapField OutputBindings; + internal readonly ReadOnlyDictionary AllBindings; + internal readonly ReadOnlyDictionary InputBindings; + internal readonly ReadOnlyDictionary OutputBindings; + /// + /// Construct an object of AzFunctionInfo from the 'RpcFunctionMetadata'. + /// Necessary validations are done on the metadata and script. + /// internal AzFunctionInfo(RpcFunctionMetadata metadata) { - FunctionName = metadata.Name; - Directory = metadata.Directory; + FuncName = metadata.Name; + FuncDirectory = metadata.Directory; EntryPoint = metadata.EntryPoint; ScriptPath = metadata.ScriptFile; - AllBindings = new MapField(); - OutputBindings = new MapField(); + // Support 'EntryPoint' only if 'ScriptFile' is a .psm1 file; + // Support .psm1 'ScriptFile' only if 'EntryPoint' is specified. + bool isScriptFilePsm1 = ScriptPath.EndsWith(".psm1", StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(EntryPoint)) + { + if (isScriptFilePsm1) + { + throw new ArgumentException($"The 'EntryPoint' property needs to be specified when 'ScriptFile' points to a PowerShell module script file (.psm1)."); + } + } + else if (!isScriptFilePsm1) + { + throw new ArgumentException($"The 'EntryPoint' property is supported only if 'ScriptFile' points to a PowerShell module script file (.psm1)."); + } + + // Get the parameter names of the script or function. + FuncParameters = GetParameters(ScriptPath, EntryPoint); + var parametersCopy = new HashSet(FuncParameters, StringComparer.OrdinalIgnoreCase); + parametersCopy.Remove(TriggerMetadata); + + var allBindings = new Dictionary(); + var inputBindings = new Dictionary(); + var outputBindings = new Dictionary(); + var inputsMissingFromParams = new List(); foreach (var binding in metadata.Bindings) { string bindingName = binding.Key; - BindingInfo bindingInfo = binding.Value; + var bindingInfo = new ReadOnlyBindingInfo(binding.Value); - AllBindings.Add(bindingName, bindingInfo); + allBindings.Add(bindingName, bindingInfo); - // PowerShell doesn't support the 'InOut' type binding if (bindingInfo.Direction == BindingInfo.Types.Direction.In) { - switch (bindingInfo.Type) + Type = GetAzFunctionType(bindingInfo); + inputBindings.Add(bindingName, bindingInfo); + + // If the input binding name is in the set, we remove it; + // otherwise, the binding name is missing from the params. + if (!parametersCopy.Remove(bindingName)) { - case OrchestrationTrigger: - Type = AzFunctionType.OrchestrationFunction; - break; - case ActivityTrigger: - Type = AzFunctionType.ActivityFunction; - break; - default: - Type = AzFunctionType.RegularFunction; - break; + inputsMissingFromParams.Add(bindingName); } - continue; } + else if (bindingInfo.Direction == BindingInfo.Types.Direction.Out) + { + outputBindings.Add(bindingName, bindingInfo); + } + else + { + // PowerShell doesn't support the 'InOut' type binding + throw new InvalidOperationException($"The binding '{bindingName}' is declared with 'InOut' direction, which is not supported by PowerShell functions."); + } + } - if (bindingInfo.Direction == BindingInfo.Types.Direction.Out) + if (inputsMissingFromParams.Count != 0 || parametersCopy.Count != 0) + { + StringBuilder stringBuilder = new StringBuilder(); + foreach (string inputBindingName in inputsMissingFromParams) { - OutputBindings.Add(bindingName, bindingInfo); + stringBuilder.AppendLine($"No parameter defined in the script or function for the input binding '{inputBindingName}'."); } + + foreach (string param in parametersCopy) + { + stringBuilder.AppendLine($"No input binding defined for the parameter '{param}' that is declared in the script or function."); + } + + string errorMsg = stringBuilder.ToString(); + throw new InvalidOperationException(errorMsg); + } + + AllBindings = new ReadOnlyDictionary(allBindings); + InputBindings = new ReadOnlyDictionary(inputBindings); + OutputBindings = new ReadOnlyDictionary(outputBindings); + } + + private AzFunctionType GetAzFunctionType(ReadOnlyBindingInfo bindingInfo) + { + switch (bindingInfo.Type) + { + case OrchestrationTrigger: + return AzFunctionType.OrchestrationFunction; + case ActivityTrigger: + return AzFunctionType.ActivityFunction; + default: + // All other triggers are considered regular functions + return AzFunctionType.RegularFunction; + } + } + + private HashSet GetParameters(string scriptFile, string entryPoint) + { + ScriptBlockAst sbAst = Parser.ParseFile(scriptFile, out _, out ParseError[] errors); + if (errors != null && errors.Length > 0) + { + var stringBuilder = new StringBuilder(15); + foreach (var error in errors) + { + stringBuilder.AppendLine(error.Message); + } + + string errorMsg = stringBuilder.ToString(); + throw new ArgumentException($"The script file '{scriptFile}' has parsing errors:\n{errorMsg}"); } + + ReadOnlyCollection paramAsts = null; + if (string.IsNullOrEmpty(entryPoint)) + { + paramAsts = sbAst.ParamBlock?.Parameters; + } + else + { + var asts = sbAst.FindAll( + ast => ast is FunctionDefinitionAst func && entryPoint.Equals(func.Name, StringComparison.OrdinalIgnoreCase), + searchNestedScriptBlocks: false).ToList(); + + if (asts.Count == 1) + { + var funcAst = (FunctionDefinitionAst) asts[0]; + paramAsts = funcAst.Parameters ?? funcAst.Body.ParamBlock?.Parameters; + } + else + { + string errorMsg = asts.Count == 0 + ? $"Cannot find the function '{entryPoint}' defined in '{scriptFile}'" + : $"More than one functions named '{entryPoint}' are found in '{scriptFile}'"; + throw new ArgumentException(errorMsg); + } + } + + HashSet parameters = new HashSet(StringComparer.OrdinalIgnoreCase); + if (paramAsts != null) + { + foreach (var paramAst in paramAsts) + { + parameters.Add(paramAst.Name.VariablePath.UserPath); + } + } + + return parameters; } } } diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 2ca85ae3..7183605a 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -47,50 +47,59 @@ internal PowerShellManager(ILogger logger) _pwsh.Streams.Warning.DataAdding += streamHandler.WarningDataAdding; } - internal void InitializeRunspace() + /// + /// This method performs the one-time initialization at the worker process level. + /// + internal void PerformWorkerLevelInitialization() { - // Add HttpResponseContext namespace so users can reference - // HttpResponseContext without needing to specify the full namespace - _pwsh.AddScript($"using namespace {typeof(HttpResponseContext).Namespace}").InvokeAndClearCommands(); + // Set the type accelerators for 'HttpResponseContext' and 'HttpResponseContext'. + // We probably will expose more public types from the worker in future for the interop between worker and the 'PowerShellWorker' module. + // But it's most likely only 'HttpResponseContext' and 'HttpResponseContext' are supposed to be used directly by users, so we only add + // type accelerators for these two explicitly. + var accelerator = typeof(PSObject).Assembly.GetType("System.Management.Automation.TypeAccelerators"); + var addMethod = accelerator.GetMethod("Add", new Type[] { typeof(string), typeof(Type) }); + addMethod.Invoke(null, new object[] { "HttpResponseContext", typeof(HttpResponseContext) }); + addMethod.Invoke(null, new object[] { "HttpRequestContext", typeof(HttpRequestContext) }); // Set the PSModulePath - Environment.SetEnvironmentVariable("PSModulePath", Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules")); + var workerModulesPath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules"); + Environment.SetEnvironmentVariable("PSModulePath", $"{FunctionLoader.FunctionAppModulesPath}{Path.PathSeparator}{workerModulesPath}"); } + /// + /// This method performs initialization that has to be done for each Runspace, e.g. profile.ps1. + /// internal void PerformRunspaceLevelInitialization() { - string functionAppProfileLocation = FunctionLoader.FunctionAppProfilePath; - if (functionAppProfileLocation == null) + Exception exception = null; + string profilePath = FunctionLoader.FunctionAppProfilePath; + if (profilePath == null) { _logger.Log(LogLevel.Trace, $"No 'profile.ps1' is found at the FunctionApp root folder: {FunctionLoader.FunctionAppRootPath}"); return; } - + try { // Import-Module on a .ps1 file will evaluate the script in the global scope. _pwsh.AddCommand("Microsoft.PowerShell.Core\\Import-Module") - .AddParameter("Name", functionAppProfileLocation).AddParameter("PassThru", true) - .AddCommand("Microsoft.PowerShell.Core\\Remove-Module") - .AddParameter("Force", true).AddParameter("ErrorAction", "SilentlyContinue") - .InvokeAndClearCommands(); + .AddParameter("Name", profilePath).AddParameter("PassThru", true) + .AddCommand("Microsoft.PowerShell.Core\\Remove-Module") + .AddParameter("Force", true).AddParameter("ErrorAction", "SilentlyContinue") + .InvokeAndClearCommands(); } catch (Exception e) { - _logger.Log( - LogLevel.Error, - $"Fail to run profile.ps1. See logs for detailed errors. Profile location: {functionAppProfileLocation}", - e, - isUserLog: true); + exception = e; throw; } - - if (_pwsh.HadErrors) + finally { - _logger.Log( - LogLevel.Error, - $"Fail to run profile.ps1. See logs for detailed errors. Profile location: {functionAppProfileLocation}", - isUserLog: true); + if (_pwsh.HadErrors) + { + string errorMsg = $"Fail to run profile.ps1. See logs for detailed errors. Profile location: {profilePath}"; + _logger.Log(LogLevel.Error, errorMsg, exception, isUserLog: true); + } } } @@ -108,22 +117,28 @@ internal Hashtable InvokeFunction( try { - // If an entry point is defined, we load the script as a module and invoke the function with that name. - // We also need to fetch the ParameterMetadata to know what to pass in as arguments. - var parameterMetadata = RetriveParameterMetadata(functionInfo, out moduleName); - _pwsh.AddCommand(String.IsNullOrEmpty(entryPoint) ? scriptPath : entryPoint); + bool hasEntryPoint = !string.IsNullOrEmpty(entryPoint); + if (hasEntryPoint) + { + // If an entry point is defined, we import the script module. + moduleName = Path.GetFileNameWithoutExtension(scriptPath); + _pwsh.AddCommand("Microsoft.PowerShell.Core\\Import-Module").AddParameter("Name", scriptPath) + .InvokeAndClearCommands(); + } + + _pwsh.AddCommand(hasEntryPoint ? entryPoint : scriptPath); // Set arguments for each input binding parameter foreach (ParameterBinding binding in inputData) { - if (parameterMetadata.ContainsKey(binding.Name)) + if (functionInfo.FuncParameters.Contains(binding.Name)) { _pwsh.AddParameter(binding.Name, binding.Data.ToObject()); } } // Gives access to additional Trigger Metadata if the user specifies TriggerMetadata - if(parameterMetadata.ContainsKey(AzFunctionInfo.TriggerMetadata)) + if(functionInfo.FuncParameters.Contains(AzFunctionInfo.TriggerMetadata)) { _logger.Log(LogLevel.Debug, "Parameter '-TriggerMetadata' found."); _pwsh.AddParameter(AzFunctionInfo.TriggerMetadata, triggerMetadata); @@ -174,22 +189,12 @@ internal string ConvertToJson(object fromObj) .InvokeAndClearCommands()[0]; } - /// - /// Helper method to prepend the FunctionApp module folder to the module path. - /// - internal void PrependToPSModulePath(string directory) - { - // Adds the passed in directory to the front of the PSModulePath using the path separator of the OS. - string psModulePath = Environment.GetEnvironmentVariable("PSModulePath"); - Environment.SetEnvironmentVariable("PSModulePath", $"{directory}{Path.PathSeparator}{psModulePath}"); - } - /// /// Helper method to set the output binding metadata for the function that is about to run. /// internal void RegisterFunctionMetadata(AzFunctionInfo functionInfo) { - var outputBindings = new ReadOnlyDictionary(functionInfo.OutputBindings); + var outputBindings = functionInfo.OutputBindings; FunctionMetadata.OutputBindingCache.AddOrUpdate(_pwsh.Runspace.InstanceId, outputBindings, (key, value) => outputBindings); @@ -203,36 +208,12 @@ internal void UnregisterFunctionMetadata() FunctionMetadata.OutputBindingCache.TryRemove(_pwsh.Runspace.InstanceId, out _); } - private Dictionary RetriveParameterMetadata(AzFunctionInfo functionInfo, out string moduleName) - { - moduleName = null; - string scriptPath = functionInfo.ScriptPath; - string entryPoint = functionInfo.EntryPoint; - - using (ExecutionTimer.Start(_logger, "Parameter metadata retrieved.")) - { - if (String.IsNullOrEmpty(entryPoint)) - { - return _pwsh.AddCommand("Microsoft.PowerShell.Core\\Get-Command").AddParameter("Name", scriptPath) - .InvokeAndClearCommands()[0].Parameters; - } - else - { - moduleName = Path.GetFileNameWithoutExtension(scriptPath); - return _pwsh.AddCommand("Microsoft.PowerShell.Core\\Import-Module").AddParameter("Name", scriptPath) - .AddStatement() - .AddCommand("Microsoft.PowerShell.Core\\Get-Command").AddParameter("Name", entryPoint) - .InvokeAndClearCommands()[0].Parameters; - } - } - } - private void ResetRunspace(string moduleName) { // Reset the runspace to the Initial Session State _pwsh.Runspace.ResetRunspaceState(); - if (!String.IsNullOrEmpty(moduleName)) + if (!string.IsNullOrEmpty(moduleName)) { // If the function had an entry point, this will remove the module that was loaded _pwsh.AddCommand("Microsoft.PowerShell.Core\\Remove-Module") diff --git a/src/Public/FunctionMetadata.cs b/src/Public/FunctionMetadata.cs index 4dd9c20b..442f5f61 100644 --- a/src/Public/FunctionMetadata.cs +++ b/src/Public/FunctionMetadata.cs @@ -6,8 +6,6 @@ using System; using System.Collections.Concurrent; using System.Collections.ObjectModel; -using Microsoft.Azure.WebJobs.Script.Grpc.Messages; -using Google.Protobuf.Collections; namespace Microsoft.Azure.Functions.PowerShellWorker { @@ -16,15 +14,15 @@ namespace Microsoft.Azure.Functions.PowerShellWorker /// public static class FunctionMetadata { - internal static ConcurrentDictionary> OutputBindingCache - = new ConcurrentDictionary>(); + internal static ConcurrentDictionary> OutputBindingCache + = new ConcurrentDictionary>(); /// /// Get the binding metadata for the given Runspace instance id. /// - public static ReadOnlyDictionary GetOutputBindingInfo(Guid runspaceInstanceId) + public static ReadOnlyDictionary GetOutputBindingInfo(Guid runspaceInstanceId) { - ReadOnlyDictionary outputBindings = null; + ReadOnlyDictionary outputBindings = null; OutputBindingCache.TryGetValue(runspaceInstanceId, out outputBindings); return outputBindings; } diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index 7038b60b..4d38309c 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -6,7 +6,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.IO; using System.Threading.Tasks; using Microsoft.Azure.Functions.PowerShellWorker.Messaging; @@ -23,10 +22,8 @@ internal class RequestProcessor private readonly MessagingStream _msgStream; private readonly PowerShellManager _powerShellManager; - // This is somewhat of a workaround for the fact that the WorkerInitialize message does - // not contain the file path of the Function App. Instead, we use this bool during the - // FunctionLoad message to initialize the Function App since we have the path. - private bool _initializedFunctionApp; + // Indicate whether the FunctionApp has been initialized. + private bool _isFunctionAppInitialized; internal RequestProcessor(MessagingStream msgStream) { @@ -52,15 +49,12 @@ internal async Task ProcessRequestLoop() case StreamingMessage.ContentOneofCase.WorkerInitRequest: response = ProcessWorkerInitRequest(request); break; - case StreamingMessage.ContentOneofCase.FunctionLoadRequest: response = ProcessFunctionLoadRequest(request); break; - case StreamingMessage.ContentOneofCase.InvocationRequest: response = ProcessInvocationRequest(request); break; - default: throw new InvalidOperationException($"Not supportted message type: {request.ContentCase}"); } @@ -77,19 +71,15 @@ internal StreamingMessage ProcessWorkerInitRequest(StreamingMessage request) StreamingMessage.ContentOneofCase.WorkerInitResponse, out StatusResult status); - try - { - _powerShellManager.InitializeRunspace(); - } - catch (Exception e) - { - status.Status = StatusResult.Types.Status.Failure; - status.Exception = e.ToRpcException(); - } - return response; } + /// + /// Method to process a FunctionLoadRequest. + /// FunctionLoadRequest should be processed sequentially. There is no point to process FunctionLoadRequest + /// concurrently as a FunctionApp doesn't include a lot functions in general. Having this step sequential + /// will make the Runspace-level initialization easier and more predictable. + /// internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) { FunctionLoadRequest functionLoadRequest = request.FunctionLoadRequest; @@ -102,25 +92,19 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) try { - // This is the first opportunity we have to obtain the location of the Function App on the file system - // so we run some additional setup including: - // * Storing some well-known paths in the Function Loader - // * Prepending the Function App 'Modules' path - // * Invoking the Function App's profile.ps1 - if (!_initializedFunctionApp) + // Ideally, the initialization should happen when processing 'WorkerInitRequest', however, the 'WorkerInitRequest' + // message doesn't provide the file path of the FunctionApp. That information is not available until the first + // 'FunctionLoadRequest' comes in. Therefore, we run initialization here. + if (!_isFunctionAppInitialized) { - // We obtain the Function App root path by navigating up - // one directory from the _Function_ directory we are given - FunctionLoader.SetupWellKnownPaths(Path.GetFullPath(Path.Combine(functionLoadRequest.Metadata.Directory, ".."))); - - _powerShellManager.PrependToPSModulePath(FunctionLoader.FunctionAppModulesPath); - + FunctionLoader.SetupWellKnownPaths(functionLoadRequest); + _powerShellManager.PerformWorkerLevelInitialization(); _powerShellManager.PerformRunspaceLevelInitialization(); - _initializedFunctionApp = true; + _isFunctionAppInitialized = true; } - // Try loading the metadata of the function + // Load the metadata of the function. _functionLoader.LoadFunction(functionLoadRequest); } catch (Exception e) @@ -132,6 +116,10 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) return response; } + /// + /// Method to process a InvocationRequest. + /// InvocationRequest should be processed in parallel eventually. + /// internal StreamingMessage ProcessInvocationRequest(StreamingMessage request) { InvocationRequest invocationRequest = request.InvocationRequest; @@ -237,7 +225,7 @@ private void BindOutputFromResult(InvocationResponse response, AzFunctionInfo fu { case AzFunctionType.RegularFunction: // Set out binding data and return response to be sent back to host - foreach (KeyValuePair binding in functionInfo.OutputBindings) + foreach (KeyValuePair binding in functionInfo.OutputBindings) { // if one of the bindings is '$return' we need to set the ReturnValue string outBindingName = binding.Key; diff --git a/src/worker.config.json b/src/worker.config.json index 74e56413..f003c0c9 100644 --- a/src/worker.config.json +++ b/src/worker.config.json @@ -1,7 +1,7 @@ { "description":{ "language":"powershell", - "extensions":[".ps1"], + "extensions":[".ps1", ".psm1"], "defaultExecutablePath":"dotnet", "defaultWorkerPath":"Microsoft.Azure.Functions.PowerShellWorker.dll" } diff --git a/test/E2E/HttpTrigger.Tests.ps1 b/test/E2E/HttpTrigger.Tests.ps1 index a3098ee8..5fecb5cb 100644 --- a/test/E2E/HttpTrigger.Tests.ps1 +++ b/test/E2E/HttpTrigger.Tests.ps1 @@ -28,7 +28,19 @@ Describe 'HttpTrigger Tests' { @{ FunctionName = 'TestBasicHttpTriggerWithProfile' ExpectedContent = 'PROFILE' - } + }, + @{ + FunctionName = 'TestHttpTriggerWithEntryPoint' + ExpectedContent = 'Hello Atlas' + }, + @{ + FunctionName = 'TestHttpTriggerWithEntryPointAndTriggerMetadata' + ExpectedContent = 'Hello Atlas' + }, + @{ + FunctionName = 'TestHttpTriggerWithEntryPointAndProfile' + ExpectedContent = 'PROFILE' + } ) { param ($FunctionName, $ExpectedContent) diff --git a/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPoint/function.json b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPoint/function.json new file mode 100644 index 00000000..16c0eea2 --- /dev/null +++ b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPoint/function.json @@ -0,0 +1,22 @@ +{ + "disabled": false, + "scriptFile": "module.psm1", + "entryPoint": "Run", + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post" + ] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ] +} diff --git a/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPoint/module.psm1 b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPoint/module.psm1 new file mode 100644 index 00000000..d5dce134 --- /dev/null +++ b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPoint/module.psm1 @@ -0,0 +1,32 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +function Run +{ + # Input bindings are passed in via param block. + param($req) + + # You can write to the Azure Functions log streams as you would in a normal PowerShell script. + Write-Verbose "PowerShell HTTP trigger function processed a request." -Verbose + + # You can interact with query parameters, the body of the request, etc. + $name = $req.Query.Name + if (-not $name) { $name = $req.Body.Name } + + if($name) { + # Cast the value to HttpResponseContext explicitly. + Push-OutputBinding -Name res -Value ([HttpResponseContext]@{ + StatusCode = 202 + Body = "Hello " + $name + }) + } + else { + # Convert value to HttpResponseContext implicitly for 'http' output. + Push-OutputBinding -Name res -Value @{ + StatusCode = "400" + Body = "Please pass a name on the query string or in the request body." + } + } +} diff --git a/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndProfile/function.json b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndProfile/function.json new file mode 100644 index 00000000..16c0eea2 --- /dev/null +++ b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndProfile/function.json @@ -0,0 +1,22 @@ +{ + "disabled": false, + "scriptFile": "module.psm1", + "entryPoint": "Run", + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post" + ] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ] +} diff --git a/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndProfile/module.psm1 b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndProfile/module.psm1 new file mode 100644 index 00000000..fa773564 --- /dev/null +++ b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndProfile/module.psm1 @@ -0,0 +1,15 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +function Run +{ + # Input bindings are passed in via param block. + param($req) + + Push-OutputBinding -Name res -Value @{ + StatusCode = 202 + Body = (Get-ProfileString) + } +} diff --git a/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndTriggerMetadata/function.json b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndTriggerMetadata/function.json new file mode 100644 index 00000000..16c0eea2 --- /dev/null +++ b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndTriggerMetadata/function.json @@ -0,0 +1,22 @@ +{ + "disabled": false, + "scriptFile": "module.psm1", + "entryPoint": "Run", + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post" + ] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ] +} diff --git a/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndTriggerMetadata/module.psm1 b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndTriggerMetadata/module.psm1 new file mode 100644 index 00000000..f1aab13b --- /dev/null +++ b/test/E2E/TestFunctionApp/TestHttpTriggerWithEntryPointAndTriggerMetadata/module.psm1 @@ -0,0 +1,32 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +function Run +{ + # Input bindings are passed in via param block. + param($req, $TriggerMetadata) + + # You can write to the Azure Functions log streams as you would in a normal PowerShell script. + Write-Verbose "PowerShell HTTP trigger function processed a request." -Verbose + + # You can interact with query parameters, the body of the request, etc. + $name = $TriggerMetadata.req.Query.Name + if (-not $name) { $name = $TriggerMetadata.req.Body.Name } + + if($name) { + # Cast the value to HttpResponseContext explicitly. + Push-OutputBinding -Name res -Value @{ + StatusCode = [System.Net.HttpStatusCode]::Accepted + Body = "Hello " + $name + } + } + else { + # Convert value to HttpResponseContext implicitly for 'http' output. + Push-OutputBinding -Name res -Value ([HttpResponseContext]@{ + StatusCode = 400 + Body = "Please pass a name on the query string or in the request body." + }) + } +} diff --git a/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj b/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj index 77281ee6..d43d3298 100644 --- a/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj +++ b/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj @@ -19,26 +19,15 @@ - + + TestScripts\PowerShell\%(RecursiveDir)\%(FileName)%(Extension) PreserveNewest + PreserveNewest - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - - PreserveNewest - - + + TestScripts\Function\%(RecursiveDir)\%(FileName)%(Extension) PreserveNewest + PreserveNewest diff --git a/test/Unit/Function/FunctionLoaderTests.cs b/test/Unit/Function/FunctionLoaderTests.cs index 29c70ed3..1c7b27c2 100644 --- a/test/Unit/Function/FunctionLoaderTests.cs +++ b/test/Unit/Function/FunctionLoaderTests.cs @@ -4,7 +4,7 @@ // using System; -using Microsoft.Azure.Functions.PowerShellWorker; +using System.IO; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; using Xunit; @@ -12,122 +12,413 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.Test { public class FunctionLoaderTests { - [Fact] - public void TestFunctionLoaderGetFunc() + private readonly string _functionDirectory; + private readonly FunctionLoadRequest _functionLoadRequest; + + public FunctionLoaderTests() { + _functionDirectory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TestScripts", "Function"); + var functionId = Guid.NewGuid().ToString(); - var directory = "/Users/azure/PSCoreApp/MyHttpTrigger"; - var scriptPathExpected = $"{directory}/run.ps1"; var metadata = new RpcFunctionMetadata { Name = "MyHttpTrigger", - EntryPoint = "", - Directory = directory, - ScriptFile = scriptPathExpected + Directory = _functionDirectory, + Bindings = + { + { "req", new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "httpTrigger" } }, + { "inputBlob", new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "blobTrigger" } }, + { "res", new BindingInfo { Direction = BindingInfo.Types.Direction.Out, Type = "http" } } + } }; - metadata.Bindings.Add("req", new BindingInfo - { - Direction = BindingInfo.Types.Direction.In, - Type = "httpTrigger" - }); - metadata.Bindings.Add("res", new BindingInfo - { - Direction = BindingInfo.Types.Direction.Out, - Type = "http" - }); - var functionLoadRequest = new FunctionLoadRequest{ + _functionLoadRequest = new FunctionLoadRequest + { FunctionId = functionId, Metadata = metadata }; + } + + private FunctionLoadRequest GetFuncLoadRequest(string scriptFile, string entryPoint) + { + var functionLoadRequest = _functionLoadRequest.Clone(); + functionLoadRequest.Metadata.ScriptFile = scriptFile; + functionLoadRequest.Metadata.EntryPoint = entryPoint; + return functionLoadRequest; + } + + [Fact] + public void TestFunctionLoaderGetFunc() + { + var scriptFileToUse = Path.Join(_functionDirectory, "BasicFuncScript.ps1"); + var entryPointToUse = string.Empty; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); var functionLoader = new FunctionLoader(); functionLoader.LoadFunction(functionLoadRequest); - var funcInfo = functionLoader.GetFunctionInfo(functionId); + var funcInfo = functionLoader.GetFunctionInfo(functionLoadRequest.FunctionId); - Assert.Equal(scriptPathExpected, funcInfo.ScriptPath); - Assert.Equal("", funcInfo.EntryPoint); + Assert.Equal(scriptFileToUse, funcInfo.ScriptPath); + Assert.Equal(string.Empty, funcInfo.EntryPoint); + + Assert.Equal(2, funcInfo.FuncParameters.Count); + Assert.Contains("req", funcInfo.FuncParameters); + Assert.Contains("inputBlob", funcInfo.FuncParameters); + + Assert.Equal(3, funcInfo.AllBindings.Count); + Assert.Equal(2, funcInfo.InputBindings.Count); + Assert.Single(funcInfo.OutputBindings); + } + + [Fact] + public void TestFunctionLoaderGetFuncWithTriggerMetadataParam() + { + var scriptFileToUse = Path.Join(_functionDirectory, "BasicFuncScriptWithTriggerMetadata.ps1"); + var entryPointToUse = string.Empty; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + + var functionLoader = new FunctionLoader(); + functionLoader.LoadFunction(functionLoadRequest); + + var funcInfo = functionLoader.GetFunctionInfo(functionLoadRequest.FunctionId); + + Assert.Equal(scriptFileToUse, funcInfo.ScriptPath); + Assert.Equal(string.Empty, funcInfo.EntryPoint); + + Assert.Equal(3, funcInfo.FuncParameters.Count); + Assert.Contains("req", funcInfo.FuncParameters); + Assert.Contains("inputBlob", funcInfo.FuncParameters); + Assert.Contains("TriggerMetadata", funcInfo.FuncParameters); + + Assert.Equal(3, funcInfo.AllBindings.Count); + Assert.Equal(2, funcInfo.InputBindings.Count); + Assert.Single(funcInfo.OutputBindings); } [Fact] public void TestFunctionLoaderGetFuncWithEntryPoint() { - var functionId = Guid.NewGuid().ToString(); - var directory = "/Users/azure/PSCoreApp/MyHttpTrigger"; - var scriptPathExpected = $"{directory}/run.ps1"; - var entryPointExpected = "Foo"; - var metadata = new RpcFunctionMetadata + var scriptFileToUse = Path.Join(_functionDirectory, "FuncWithEntryPoint.psm1"); + var entryPointToUse = "Run"; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + + var functionLoader = new FunctionLoader(); + functionLoader.LoadFunction(functionLoadRequest); + + var funcInfo = functionLoader.GetFunctionInfo(functionLoadRequest.FunctionId); + + Assert.Equal(scriptFileToUse, funcInfo.ScriptPath); + Assert.Equal(entryPointToUse, funcInfo.EntryPoint); + + Assert.Equal(2, funcInfo.FuncParameters.Count); + Assert.Contains("req", funcInfo.FuncParameters); + Assert.Contains("inputBlob", funcInfo.FuncParameters); + + Assert.Equal(3, funcInfo.AllBindings.Count); + Assert.Equal(2, funcInfo.InputBindings.Count); + Assert.Single(funcInfo.OutputBindings); + } + + [Fact] + public void EntryPointIsSupportedWithPsm1FileOnly() + { + var scriptFileToUse = Path.Join(_functionDirectory, "BasicFuncScript.ps1"); + var entryPointToUse = "Run"; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + + Exception exception = null; + var functionLoader = new FunctionLoader(); + try { - Name = "MyHttpTrigger", - EntryPoint = entryPointExpected, - Directory = directory, - ScriptFile = scriptPathExpected - }; - metadata.Bindings.Add("req", new BindingInfo + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) + { + exception = e; + } + + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("EntryPoint", exception.Message); + Assert.Contains("(.psm1)", exception.Message); + } + + [Fact] + public void Psm1IsSupportedWithEntryPointOnly() + { + var scriptFileToUse = Path.Join(_functionDirectory, "FuncWithEntryPoint.psm1"); + var entryPointToUse = string.Empty; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + + Exception exception = null; + var functionLoader = new FunctionLoader(); + try { - Direction = BindingInfo.Types.Direction.In, - Type = "httpTrigger" - }); - metadata.Bindings.Add("res", new BindingInfo + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) { - Direction = BindingInfo.Types.Direction.Out, - Type = "http" - }); + exception = e; + } - var functionLoadRequest = new FunctionLoadRequest{ - FunctionId = functionId, - Metadata = metadata - }; + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("EntryPoint", exception.Message); + Assert.Contains("(.psm1)", exception.Message); + } + [Fact] + public void ParseErrorInScriptFileShouldBeDetected() + { + var scriptFileToUse = Path.Join(_functionDirectory, "FuncWithParseError.ps1"); + var entryPointToUse = string.Empty; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + + Exception exception = null; var functionLoader = new FunctionLoader(); - functionLoader.LoadFunction(functionLoadRequest); + try + { + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) + { + exception = e; + } + + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("parsing errors", exception.Message); + } - var funcInfo = functionLoader.GetFunctionInfo(functionId); + [Fact] + public void EntryPointFunctionShouldExist() + { + var scriptFileToUse = Path.Join(_functionDirectory, "FuncWithEntryPoint.psm1"); + var entryPointToUse = "CallMe"; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + + Exception exception = null; + var functionLoader = new FunctionLoader(); + try + { + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) + { + exception = e; + } - Assert.Equal(scriptPathExpected, funcInfo.ScriptPath); - Assert.Equal(entryPointExpected, funcInfo.EntryPoint); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("CallMe", exception.Message); + Assert.Contains("FuncWithEntryPoint.psm1", exception.Message); } [Fact] - public void TestFunctionLoaderGetInfo() + public void MultipleEntryPointFunctionsShouldBeDetected() { - var functionId = Guid.NewGuid().ToString(); - var directory = "/Users/azure/PSCoreApp/MyHttpTrigger"; - var scriptPathExpected = $"{directory}/run.ps1"; - var name = "MyHttpTrigger"; - var metadata = new RpcFunctionMetadata + var scriptFileToUse = Path.Join(_functionDirectory, "FuncWithMultiEntryPoints.psm1"); + var entryPointToUse = "Run"; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + + Exception exception = null; + var functionLoader = new FunctionLoader(); + try { - Name = name, - EntryPoint = "", - Directory = directory, - ScriptFile = scriptPathExpected - }; - metadata.Bindings.Add("req", new BindingInfo + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) { - Direction = BindingInfo.Types.Direction.In, - Type = "httpTrigger" - }); - metadata.Bindings.Add("res", new BindingInfo + exception = e; + } + + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("Run", exception.Message); + Assert.Contains("FuncWithMultiEntryPoints.psm1", exception.Message); + } + + [Fact] + public void ParametersShouldMatchInputBinding() + { + var scriptFileToUse = Path.Join(_functionDirectory, "BasicFuncScript.ps1"); + var entryPointToUse = string.Empty; + + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + functionLoadRequest.Metadata.Bindings.Add("inputTable", new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "tableTrigger" }); + functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); + + Exception exception = null; + var functionLoader = new FunctionLoader(); + try { - Direction = BindingInfo.Types.Direction.Out, - Type = "http" - }); + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) + { + exception = e; + } - var functionLoadRequest = new FunctionLoadRequest{ - FunctionId = functionId, - Metadata = metadata - }; + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("inputTable", exception.Message); + Assert.Contains("inputBlob", exception.Message); + } + [Fact] + public void ParametersShouldMatchInputBindingWithTriggerMetadataParam() + { + var scriptFileToUse = Path.Join(_functionDirectory, "BasicFuncScriptWithTriggerMetadata.ps1"); + var entryPointToUse = string.Empty; + + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + functionLoadRequest.Metadata.Bindings.Add("inputTable", new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "tableTrigger" }); + functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); + + Exception exception = null; var functionLoader = new FunctionLoader(); - functionLoader.LoadFunction(functionLoadRequest); + try + { + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) + { + exception = e; + } - var funcInfo = functionLoader.GetFunctionInfo(functionId); + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("inputTable", exception.Message); + Assert.Contains("inputBlob", exception.Message); + } - Assert.Equal(directory, funcInfo.Directory); - Assert.Equal(name, funcInfo.FunctionName); - Assert.Equal(2, funcInfo.AllBindings.Count); - Assert.Single(funcInfo.OutputBindings); + [Fact] + public void EntryPointParametersShouldMatchInputBinding() + { + var scriptFileToUse = Path.Join(_functionDirectory, "FuncWithEntryPoint.psm1"); + var entryPointToUse = "Run"; + + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + functionLoadRequest.Metadata.Bindings.Add("inputTable", new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "tableTrigger" }); + functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); + + Exception exception = null; + var functionLoader = new FunctionLoader(); + try + { + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) + { + exception = e; + } + + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("inputTable", exception.Message); + Assert.Contains("inputBlob", exception.Message); + } + + [Fact] + public void EntryPointParametersShouldMatchInputBindingWithTriggerMetadataParam() + { + var scriptFileToUse = Path.Join(_functionDirectory, "FuncWithEntryPointAndTriggerMetadata.psm1"); + var entryPointToUse = "Run"; + + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + functionLoadRequest.Metadata.Bindings.Add("inputTable", new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "tableTrigger" }); + functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); + + Exception exception = null; + var functionLoader = new FunctionLoader(); + try + { + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) + { + exception = e; + } + + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("inputTable", exception.Message); + Assert.Contains("inputBlob", exception.Message); + } + + [Fact] + public void InOutBindingIsNotSupported() + { + var scriptFileToUse = Path.Join(_functionDirectory, "BasicFuncScript.ps1"); + var entryPointToUse = string.Empty; + + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + functionLoadRequest.Metadata.Bindings.Add("inoutBinding", new BindingInfo { Direction = BindingInfo.Types.Direction.Inout, Type = "queue" }); + + Exception exception = null; + var functionLoader = new FunctionLoader(); + try + { + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) + { + exception = e; + } + + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("inoutBinding", exception.Message); + Assert.Contains("InOut", exception.Message); + } + + [Fact] + public void ScriptNeedToHaveParameters() + { + var scriptFileToUse = Path.Join(_functionDirectory, "FuncHasNoParams.ps1"); + var entryPointToUse = string.Empty; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + + Exception exception = null; + var functionLoader = new FunctionLoader(); + try + { + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) + { + exception = e; + } + + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("req", exception.Message); + Assert.Contains("inputBlob", exception.Message); + } + + [Fact] + public void EntryPointNeedToHaveParameters() + { + var scriptFileToUse = Path.Join(_functionDirectory, "FuncWithEntryPoint.psm1"); + var entryPointToUse = "Zoo"; + var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); + + Exception exception = null; + var functionLoader = new FunctionLoader(); + try + { + functionLoader.LoadFunction(functionLoadRequest); + } + catch (Exception e) + { + exception = e; + } + + Assert.NotNull(exception); + Assert.IsType(exception); + Assert.Contains("req", exception.Message); + Assert.Contains("inputBlob", exception.Message); } } } diff --git a/test/Unit/Function/TestScripts/BasicFuncScript.ps1 b/test/Unit/Function/TestScripts/BasicFuncScript.ps1 new file mode 100644 index 00000000..0464c162 --- /dev/null +++ b/test/Unit/Function/TestScripts/BasicFuncScript.ps1 @@ -0,0 +1,10 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +param($req, $inputBlob) + +function Run($param) { "Run" } + +"DoNothing" diff --git a/test/Unit/Function/TestScripts/BasicFuncScriptWithTriggerMetadata.ps1 b/test/Unit/Function/TestScripts/BasicFuncScriptWithTriggerMetadata.ps1 new file mode 100644 index 00000000..d52615d8 --- /dev/null +++ b/test/Unit/Function/TestScripts/BasicFuncScriptWithTriggerMetadata.ps1 @@ -0,0 +1,8 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +param($req, $inputBlob, $TriggerMetadata) + +"DoNothing" diff --git a/test/Unit/Function/TestScripts/FuncHasNoParams.ps1 b/test/Unit/Function/TestScripts/FuncHasNoParams.ps1 new file mode 100644 index 00000000..16afd632 --- /dev/null +++ b/test/Unit/Function/TestScripts/FuncHasNoParams.ps1 @@ -0,0 +1,8 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +function Run($param) { "Run" } + +"DoNothing" diff --git a/test/Unit/Function/TestScripts/FuncWithEntryPoint.psm1 b/test/Unit/Function/TestScripts/FuncWithEntryPoint.psm1 new file mode 100644 index 00000000..5820b7bd --- /dev/null +++ b/test/Unit/Function/TestScripts/FuncWithEntryPoint.psm1 @@ -0,0 +1,19 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +function Bar($req) +{ + "Bar" +} + +function Run($req, $inputBlob) +{ + "Run" +} + +function Zoo +{ + "Bar" +} diff --git a/test/Unit/Function/TestScripts/FuncWithEntryPointAndTriggerMetadata.psm1 b/test/Unit/Function/TestScripts/FuncWithEntryPointAndTriggerMetadata.psm1 new file mode 100644 index 00000000..0ff1ab86 --- /dev/null +++ b/test/Unit/Function/TestScripts/FuncWithEntryPointAndTriggerMetadata.psm1 @@ -0,0 +1,11 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +function Run +{ + param($req, $inputBlob, $TriggerMetadata) + + "Run" +} diff --git a/test/Unit/Function/TestScripts/FuncWithMultiEntryPoints.psm1 b/test/Unit/Function/TestScripts/FuncWithMultiEntryPoints.psm1 new file mode 100644 index 00000000..ac55690b --- /dev/null +++ b/test/Unit/Function/TestScripts/FuncWithMultiEntryPoints.psm1 @@ -0,0 +1,14 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +function Run($req) +{ + "Run-1" +} + +function Run($req, $inputBlob) +{ + "Run-2" +} diff --git a/test/Unit/Function/TestScripts/FuncWithParseError.ps1 b/test/Unit/Function/TestScripts/FuncWithParseError.ps1 new file mode 100644 index 00000000..903e2274 --- /dev/null +++ b/test/Unit/Function/TestScripts/FuncWithParseError.ps1 @@ -0,0 +1,8 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +param($req, $inputBlob) + +begin {} begin {} diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 78531967..89995a42 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -6,6 +6,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.IO; using System.Management.Automation; using Microsoft.Azure.Functions.PowerShellWorker.PowerShell; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; @@ -15,54 +16,64 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.Test { public class PowerShellManagerTests { - public const string TestInputBindingName = "req"; - public const string TestOutputBindingName = "res"; - public const string TestStringData = "Foo"; - - internal static ConsoleLogger defaultTestLogger = new ConsoleLogger(); - internal static PowerShellManager defaultTestManager = new PowerShellManager(defaultTestLogger); - - public readonly List TestInputData = new List { - new ParameterBinding { - Name = TestInputBindingName, - Data = new TypedData { - String = TestStringData + private const string TestInputBindingName = "req"; + private const string TestOutputBindingName = "res"; + private const string TestStringData = "Foo"; + + private readonly string _functionDirectory; + private readonly ConsoleLogger _testLogger; + private readonly PowerShellManager _testManager; + private readonly List _testInputData; + private readonly RpcFunctionMetadata _rpcFunctionMetadata; + private readonly FunctionLoadRequest _functionLoadRequest; + + public PowerShellManagerTests() + { + _functionDirectory = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "TestScripts", "PowerShell"); + _rpcFunctionMetadata = new RpcFunctionMetadata() + { + Name = "TestFuncApp", + Directory = _functionDirectory, + Bindings = + { + { TestInputBindingName , new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "httpTrigger" } }, + { TestOutputBindingName, new BindingInfo { Direction = BindingInfo.Types.Direction.Out, Type = "http" } } } - } - }; - public readonly RpcFunctionMetadata rpcFunctionMetadata = new RpcFunctionMetadata(); + }; + _functionLoadRequest = new FunctionLoadRequest {FunctionId = "FunctionId", Metadata = _rpcFunctionMetadata}; + FunctionLoader.SetupWellKnownPaths(_functionLoadRequest); - private AzFunctionInfo GetAzFunctionInfo(string scriptFile, string entryPoint) - { - rpcFunctionMetadata.ScriptFile = scriptFile; - rpcFunctionMetadata.EntryPoint = entryPoint; - return new AzFunctionInfo(rpcFunctionMetadata); + _testLogger = new ConsoleLogger(); + _testManager = new PowerShellManager(_testLogger); + _testManager.PerformWorkerLevelInitialization(); + + _testInputData = new List + { + new ParameterBinding + { + Name = TestInputBindingName, + Data = new TypedData + { + String = TestStringData + } + } + }; } - [Fact] - public void InitializeRunspaceSuccess() + private AzFunctionInfo GetAzFunctionInfo(string scriptFile, string entryPoint) { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - manager.InitializeRunspace(); - - Assert.Empty(logger.FullLog); + _rpcFunctionMetadata.ScriptFile = scriptFile; + _rpcFunctionMetadata.EntryPoint = entryPoint; + return new AzFunctionInfo(_rpcFunctionMetadata); } [Fact] public void InvokeBasicFunctionWorks() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - - manager.InitializeRunspace(); - - string path = System.IO.Path.Join( - AppDomain.CurrentDomain.BaseDirectory, - "Unit/PowerShell/TestScripts/testBasicFunction.ps1"); + string path = Path.Join(_functionDirectory, "testBasicFunction.ps1"); var functionInfo = GetAzFunctionInfo(path, string.Empty); - Hashtable result = manager.InvokeFunction(functionInfo, null, TestInputData); + Hashtable result = _testManager.InvokeFunction(functionInfo, null, _testInputData); Assert.Equal(TestStringData, result[TestOutputBindingName]); } @@ -70,22 +81,14 @@ public void InvokeBasicFunctionWorks() [Fact] public void InvokeBasicFunctionWithTriggerMetadataWorks() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - - manager.InitializeRunspace(); - - string path = System.IO.Path.Join( - AppDomain.CurrentDomain.BaseDirectory, - "Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1"); - + string path = Path.Join(_functionDirectory, "testBasicFunctionWithTriggerMetadata.ps1"); Hashtable triggerMetadata = new Hashtable(StringComparer.OrdinalIgnoreCase) { { TestInputBindingName, TestStringData } }; var functionInfo = GetAzFunctionInfo(path, string.Empty); - Hashtable result = manager.InvokeFunction(functionInfo, triggerMetadata, TestInputData); + Hashtable result = _testManager.InvokeFunction(functionInfo, triggerMetadata, _testInputData); Assert.Equal(TestStringData, result[TestOutputBindingName]); } @@ -93,17 +96,9 @@ public void InvokeBasicFunctionWithTriggerMetadataWorks() [Fact] public void InvokeFunctionWithEntryPointWorks() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - - manager.InitializeRunspace(); - - string path = System.IO.Path.Join( - AppDomain.CurrentDomain.BaseDirectory, - "Unit/PowerShell/TestScripts/testFunctionWithEntryPoint.ps1"); - + string path = Path.Join(_functionDirectory, "testFunctionWithEntryPoint.psm1"); var functionInfo = GetAzFunctionInfo(path, "Run"); - Hashtable result = manager.InvokeFunction(functionInfo, null, TestInputData); + Hashtable result = _testManager.InvokeFunction(functionInfo, null, _testInputData); Assert.Equal(TestStringData, result[TestOutputBindingName]); } @@ -111,52 +106,36 @@ public void InvokeFunctionWithEntryPointWorks() [Fact] public void FunctionShouldCleanupVariableTable() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - - manager.InitializeRunspace(); - - string path = System.IO.Path.Join( - AppDomain.CurrentDomain.BaseDirectory, - "Unit/PowerShell/TestScripts/testFunctionCleanup.ps1"); - + string path = Path.Join(_functionDirectory, "testFunctionCleanup.ps1"); var functionInfo = GetAzFunctionInfo(path, string.Empty); - Hashtable result1 = manager.InvokeFunction(functionInfo, null, TestInputData); + + Hashtable result1 = _testManager.InvokeFunction(functionInfo, null, _testInputData); Assert.Equal("is not set", result1[TestOutputBindingName]); // the value shoould not change if the variable table is properly cleaned up. - Hashtable result2 = manager.InvokeFunction(functionInfo, null, TestInputData); + Hashtable result2 = _testManager.InvokeFunction(functionInfo, null, _testInputData); Assert.Equal("is not set", result2[TestOutputBindingName]); } [Fact] - public void PrependingToPSModulePathShouldWork() + public void ModulePathShouldBeSetByWorkerLevelInitialization() { - var data = "/some/unknown/directory"; - - string modulePathBefore = Environment.GetEnvironmentVariable("PSModulePath"); - defaultTestManager.PrependToPSModulePath(data); - try - { - // the data path should be ahead of anything else - Assert.Equal($"{data}{System.IO.Path.PathSeparator}{modulePathBefore}", Environment.GetEnvironmentVariable("PSModulePath")); - } - finally - { - // Set the PSModulePath back to what it was before - Environment.SetEnvironmentVariable("PSModulePath", modulePathBefore); - } + string workerModulePath = Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules"); + string funcAppModulePath = Path.Join(FunctionLoader.FunctionAppRootPath, "Modules"); + string expectedPath = $"{funcAppModulePath}{Path.PathSeparator}{workerModulePath}"; + Assert.Equal(expectedPath, Environment.GetEnvironmentVariable("PSModulePath")); } [Fact] public void RegisterAndUnregisterFunctionMetadataShouldWork() { - var functionInfo = GetAzFunctionInfo("dummy-path", string.Empty); + string path = Path.Join(_functionDirectory, "testBasicFunction.ps1"); + var functionInfo = GetAzFunctionInfo(path, string.Empty); Assert.Empty(FunctionMetadata.OutputBindingCache); - defaultTestManager.RegisterFunctionMetadata(functionInfo); + _testManager.RegisterFunctionMetadata(functionInfo); Assert.Single(FunctionMetadata.OutputBindingCache); - defaultTestManager.UnregisterFunctionMetadata(); + _testManager.UnregisterFunctionMetadata(); Assert.Empty(FunctionMetadata.OutputBindingCache); } @@ -164,74 +143,89 @@ public void RegisterAndUnregisterFunctionMetadataShouldWork() public void ProfileShouldWork() { //initialize fresh log - defaultTestLogger.FullLog.Clear(); + _testLogger.FullLog.Clear(); + var funcLoadReq = _functionLoadRequest.Clone(); + funcLoadReq.Metadata.Directory = Path.Join(_functionDirectory, "ProfileBasic", "Func1"); - CleanupFunctionLoaderStaticPaths(); - FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( - AppDomain.CurrentDomain.BaseDirectory, - "Unit/PowerShell/TestScripts/ProfileBasic")); - - defaultTestManager.PerformRunspaceLevelInitialization(); + try + { + FunctionLoader.SetupWellKnownPaths(funcLoadReq); + _testManager.PerformRunspaceLevelInitialization(); - Assert.Single(defaultTestLogger.FullLog); - Assert.Equal("Information: INFORMATION: Hello PROFILE", defaultTestLogger.FullLog[0]); + Assert.Single(_testLogger.FullLog); + Assert.Equal("Information: INFORMATION: Hello PROFILE", _testLogger.FullLog[0]); + } + finally + { + FunctionLoader.SetupWellKnownPaths(_functionLoadRequest); + } } [Fact] public void ProfileDoesNotExist() { //initialize fresh log - defaultTestLogger.FullLog.Clear(); + _testLogger.FullLog.Clear(); + var funcLoadReq = _functionLoadRequest.Clone(); + funcLoadReq.Metadata.Directory = AppDomain.CurrentDomain.BaseDirectory; - CleanupFunctionLoaderStaticPaths(); - FunctionLoader.SetupWellKnownPaths(AppDomain.CurrentDomain.BaseDirectory); - - defaultTestManager.PerformRunspaceLevelInitialization(); + try + { + FunctionLoader.SetupWellKnownPaths(funcLoadReq); + _testManager.PerformRunspaceLevelInitialization(); - Assert.Single(defaultTestLogger.FullLog); - Assert.Matches("Trace: No 'profile.ps1' is found at the FunctionApp root folder: ", defaultTestLogger.FullLog[0]); + Assert.Single(_testLogger.FullLog); + Assert.Matches("Trace: No 'profile.ps1' is found at the FunctionApp root folder: ", _testLogger.FullLog[0]); + } + finally + { + FunctionLoader.SetupWellKnownPaths(_functionLoadRequest); + } } [Fact] public void ProfileWithTerminatingError() { //initialize fresh log - defaultTestLogger.FullLog.Clear(); - - CleanupFunctionLoaderStaticPaths(); - FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( - AppDomain.CurrentDomain.BaseDirectory, - "Unit/PowerShell/TestScripts/ProfileWithTerminatingError")); - - Assert.Throws(() => defaultTestManager.PerformRunspaceLevelInitialization()); - Assert.Single(defaultTestLogger.FullLog); - Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", defaultTestLogger.FullLog[0]); + _testLogger.FullLog.Clear(); + var funcLoadReq = _functionLoadRequest.Clone(); + funcLoadReq.Metadata.Directory = Path.Join(_functionDirectory, "ProfileWithTerminatingError", "Func1"); + + try + { + FunctionLoader.SetupWellKnownPaths(funcLoadReq); + + Assert.Throws(() => _testManager.PerformRunspaceLevelInitialization()); + Assert.Single(_testLogger.FullLog); + Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", _testLogger.FullLog[0]); + } + finally + { + FunctionLoader.SetupWellKnownPaths(_functionLoadRequest); + } } [Fact] public void ProfileWithNonTerminatingError() { //initialize fresh log - defaultTestLogger.FullLog.Clear(); - - CleanupFunctionLoaderStaticPaths(); - FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( - AppDomain.CurrentDomain.BaseDirectory, - "Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError")); - - defaultTestManager.PerformRunspaceLevelInitialization(); - - Assert.Equal(2, defaultTestLogger.FullLog.Count); - Assert.Equal("Error: ERROR: help me!", defaultTestLogger.FullLog[0]); - Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", defaultTestLogger.FullLog[1]); - } + _testLogger.FullLog.Clear(); + var funcLoadReq = _functionLoadRequest.Clone(); + funcLoadReq.Metadata.Directory = Path.Join(_functionDirectory, "ProfileWithNonTerminatingError", "Func1"); - // Helper function that sets all the well-known paths in the Function Loader back to null. - private void CleanupFunctionLoaderStaticPaths() - { - FunctionLoader.FunctionAppRootPath = null; - FunctionLoader.FunctionAppProfilePath = null; - FunctionLoader.FunctionAppModulesPath = null; + try + { + FunctionLoader.SetupWellKnownPaths(funcLoadReq); + _testManager.PerformRunspaceLevelInitialization(); + + Assert.Equal(2, _testLogger.FullLog.Count); + Assert.Equal("Error: ERROR: help me!", _testLogger.FullLog[0]); + Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", _testLogger.FullLog[1]); + } + finally + { + FunctionLoader.SetupWellKnownPaths(_functionLoadRequest); + } } } } diff --git a/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 b/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 index 914d0e9e..eddd1167 100644 --- a/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 +++ b/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 @@ -8,4 +8,4 @@ param ($Req, $TriggerMetadata) # Used for logging tests Write-Verbose "a log" -Push-OutputBinding -Name res -Value $TriggerMetadata.Req +Push-OutputBinding -Name res -Value $Req diff --git a/test/Unit/PowerShell/TestScripts/testFunctionWithEntryPoint.ps1 b/test/Unit/PowerShell/TestScripts/testFunctionWithEntryPoint.psm1 similarity index 100% rename from test/Unit/PowerShell/TestScripts/testFunctionWithEntryPoint.ps1 rename to test/Unit/PowerShell/TestScripts/testFunctionWithEntryPoint.psm1 diff --git a/test/Unit/Utility/TypeExtensionsTests.cs b/test/Unit/Utility/TypeExtensionsTests.cs index d559ff95..ebf1f9a7 100644 --- a/test/Unit/Utility/TypeExtensionsTests.cs +++ b/test/Unit/Utility/TypeExtensionsTests.cs @@ -10,8 +10,6 @@ using System.Management.Automation; using Google.Protobuf; -using Google.Protobuf.Collections; -using Microsoft.Azure.Functions.PowerShellWorker; using Microsoft.Azure.Functions.PowerShellWorker.PowerShell; using Microsoft.Azure.Functions.PowerShellWorker.Utility; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; @@ -22,6 +20,15 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.Test { public class TypeExtensionsTests { + private readonly ConsoleLogger _testLogger; + private readonly PowerShellManager _testManager; + + public TypeExtensionsTests() + { + _testLogger = new ConsoleLogger(); + _testManager = new PowerShellManager(_testLogger); + } + #region TypedDataToObject [Fact] public void TestTypedDataToObjectHttpRequestContextBasic() @@ -282,6 +289,7 @@ public void TestTypedDataToObjectStream() Assert.Equal(expected, (byte[])input.ToObject()); } #endregion + #region ExceptionToRpcException [Fact] public void TestExceptionToRpcExceptionBasic() @@ -314,6 +322,7 @@ public void TestExceptionToRpcExceptionExtraData() Assert.Equal(expected, input.ToRpcException()); } #endregion + #region ObjectToTypedData [Fact] public void TestObjectToTypedDataRpcHttpBasic() @@ -495,10 +504,6 @@ public void TestObjectToTypedDataJsonString() [Fact] public void TestObjectToTypedDataJsonHashtable() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - manager.InitializeRunspace(); - var data = new Hashtable { { "foo", "bar" } }; var input = (object)data; @@ -507,16 +512,12 @@ public void TestObjectToTypedDataJsonHashtable() Json = "{\"foo\":\"bar\"}" }; - Assert.Equal(expected, input.ToTypedData(manager)); + Assert.Equal(expected, input.ToTypedData(_testManager)); } [Fact] public void TestObjectToTypedData_PSObjectToJson_1() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - manager.InitializeRunspace(); - var data = new Hashtable { { "foo", "bar" } }; object input = PSObject.AsPSObject(data); @@ -525,16 +526,12 @@ public void TestObjectToTypedData_PSObjectToJson_1() Json = "{\"foo\":\"bar\"}" }; - Assert.Equal(expected, input.ToTypedData(manager)); + Assert.Equal(expected, input.ToTypedData(_testManager)); } [Fact] public void TestObjectToTypedData_PSObjectToJson_2() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - manager.InitializeRunspace(); - using (var ps = System.Management.Automation.PowerShell.Create()) { object input = ps.AddScript("[pscustomobject]@{foo = 'bar'}").Invoke()[0]; @@ -544,21 +541,17 @@ public void TestObjectToTypedData_PSObjectToJson_2() Json = "{\"foo\":\"bar\"}" }; - Assert.Equal(expected, input.ToTypedData(manager)); + Assert.Equal(expected, input.ToTypedData(_testManager)); } } [Fact] public void TestObjectToTypedData_PSObjectToBytes() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - manager.InitializeRunspace(); - var data = new byte[] { 12,23,34 }; object input = PSObject.AsPSObject(data); - TypedData output = input.ToTypedData(manager); + TypedData output = input.ToTypedData(_testManager); Assert.Equal(TypedData.DataOneofCase.Bytes, output.DataCase); Assert.Equal(3, output.Bytes.Length); @@ -567,14 +560,10 @@ public void TestObjectToTypedData_PSObjectToBytes() [Fact] public void TestObjectToTypedData_PSObjectToStream() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - manager.InitializeRunspace(); - using (var data = new MemoryStream(new byte[] { 12,23,34 })) { object input = PSObject.AsPSObject(data); - TypedData output = input.ToTypedData(manager); + TypedData output = input.ToTypedData(_testManager); Assert.Equal(TypedData.DataOneofCase.Stream, output.DataCase); Assert.Equal(3, output.Stream.Length); @@ -584,12 +573,8 @@ public void TestObjectToTypedData_PSObjectToStream() [Fact] public void TestObjectToTypedData_PSObjectToString() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); - manager.InitializeRunspace(); - object input = PSObject.AsPSObject("Hello World"); - TypedData output = input.ToTypedData(manager); + TypedData output = input.ToTypedData(_testManager); Assert.Equal(TypedData.DataOneofCase.String, output.DataCase); Assert.Equal("Hello World", output.String); From f6c16701be8daba108273a54586a1d4988c1dd8d Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Thu, 13 Dec 2018 22:06:04 -0800 Subject: [PATCH 3/4] Address comments --- src/FunctionInfo.cs | 225 ++++++++++++++++++ src/FunctionLoader.cs | 209 ---------------- src/PowerShell/PowerShellManager.cs | 38 +-- test/E2E/HttpTrigger.Tests.ps1 | 24 +- test/Unit/Function/FunctionLoaderTests.cs | 180 ++------------ .../testBasicFunctionWithTriggerMetadata.ps1 | 2 +- 6 files changed, 284 insertions(+), 394 deletions(-) create mode 100644 src/FunctionInfo.cs diff --git a/src/FunctionInfo.cs b/src/FunctionInfo.cs new file mode 100644 index 00000000..3846c624 --- /dev/null +++ b/src/FunctionInfo.cs @@ -0,0 +1,225 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Management.Automation.Language; +using System.Text; + +using Microsoft.Azure.WebJobs.Script.Grpc.Messages; + +namespace Microsoft.Azure.Functions.PowerShellWorker +{ + /// + /// This type represents the metadata of an Azure PowerShell Function. + /// + internal class AzFunctionInfo + { + private const string OrchestrationTrigger = "orchestrationTrigger"; + private const string ActivityTrigger = "activityTrigger"; + + internal const string TriggerMetadata = "TriggerMetadata"; + internal const string DollarReturn = "$return"; + + internal readonly string FuncDirectory; + internal readonly string FuncName; + internal readonly string EntryPoint; + internal readonly string ScriptPath; + internal readonly HashSet FuncParameters; + internal readonly AzFunctionType Type; + internal readonly ReadOnlyDictionary AllBindings; + internal readonly ReadOnlyDictionary InputBindings; + internal readonly ReadOnlyDictionary OutputBindings; + + /// + /// Construct an object of AzFunctionInfo from the 'RpcFunctionMetadata'. + /// Necessary validations are done on the metadata and script. + /// + internal AzFunctionInfo(RpcFunctionMetadata metadata) + { + FuncName = metadata.Name; + FuncDirectory = metadata.Directory; + EntryPoint = metadata.EntryPoint; + ScriptPath = metadata.ScriptFile; + + // Support 'entryPoint' only if 'scriptFile' is a .psm1 file; + // Support .psm1 'scriptFile' only if 'entryPoint' is specified. + bool isScriptFilePsm1 = ScriptPath.EndsWith(".psm1", StringComparison.OrdinalIgnoreCase); + if (string.IsNullOrEmpty(EntryPoint)) + { + if (isScriptFilePsm1) + { + throw new ArgumentException($"The 'entryPoint' property needs to be specified when 'scriptFile' points to a PowerShell module script file (.psm1)."); + } + } + else if (!isScriptFilePsm1) + { + throw new ArgumentException($"The 'entryPoint' property is supported only if 'scriptFile' points to a PowerShell module script file (.psm1)."); + } + + // Get the parameter names of the script or function. + FuncParameters = GetParameters(ScriptPath, EntryPoint); + var parametersCopy = new HashSet(FuncParameters, StringComparer.OrdinalIgnoreCase); + parametersCopy.Remove(TriggerMetadata); + + var allBindings = new Dictionary(); + var inputBindings = new Dictionary(); + var outputBindings = new Dictionary(); + + var inputsMissingFromParams = new List(); + foreach (var binding in metadata.Bindings) + { + string bindingName = binding.Key; + var bindingInfo = new ReadOnlyBindingInfo(binding.Value); + + allBindings.Add(bindingName, bindingInfo); + + if (bindingInfo.Direction == BindingInfo.Types.Direction.In) + { + Type = GetAzFunctionType(bindingInfo); + inputBindings.Add(bindingName, bindingInfo); + + // If the input binding name is in the set, we remove it; + // otherwise, the binding name is missing from the params. + if (!parametersCopy.Remove(bindingName)) + { + inputsMissingFromParams.Add(bindingName); + } + } + else if (bindingInfo.Direction == BindingInfo.Types.Direction.Out) + { + outputBindings.Add(bindingName, bindingInfo); + } + else + { + // PowerShell doesn't support the 'InOut' type binding + throw new InvalidOperationException($"The binding '{bindingName}' is declared with 'InOut' direction, which is not supported by PowerShell functions."); + } + } + + if (inputsMissingFromParams.Count != 0 || parametersCopy.Count != 0) + { + StringBuilder stringBuilder = new StringBuilder(); + foreach (string inputBindingName in inputsMissingFromParams) + { + stringBuilder.AppendLine($"No parameter defined in the script or function for the input binding '{inputBindingName}'."); + } + + foreach (string param in parametersCopy) + { + stringBuilder.AppendLine($"No input binding defined for the parameter '{param}' that is declared in the script or function."); + } + + string errorMsg = stringBuilder.ToString(); + throw new InvalidOperationException(errorMsg); + } + + AllBindings = new ReadOnlyDictionary(allBindings); + InputBindings = new ReadOnlyDictionary(inputBindings); + OutputBindings = new ReadOnlyDictionary(outputBindings); + } + + private AzFunctionType GetAzFunctionType(ReadOnlyBindingInfo bindingInfo) + { + switch (bindingInfo.Type) + { + case OrchestrationTrigger: + return AzFunctionType.OrchestrationFunction; + case ActivityTrigger: + return AzFunctionType.ActivityFunction; + default: + // All other triggers are considered regular functions + return AzFunctionType.RegularFunction; + } + } + + private HashSet GetParameters(string scriptFile, string entryPoint) + { + ScriptBlockAst sbAst = Parser.ParseFile(scriptFile, out _, out ParseError[] errors); + if (errors != null && errors.Length > 0) + { + var stringBuilder = new StringBuilder(15); + foreach (var error in errors) + { + stringBuilder.AppendLine(error.Message); + } + + string errorMsg = stringBuilder.ToString(); + throw new ArgumentException($"The script file '{scriptFile}' has parsing errors:\n{errorMsg}"); + } + + ReadOnlyCollection paramAsts = null; + if (string.IsNullOrEmpty(entryPoint)) + { + paramAsts = sbAst.ParamBlock?.Parameters; + } + else + { + var asts = sbAst.FindAll( + ast => ast is FunctionDefinitionAst func && entryPoint.Equals(func.Name, StringComparison.OrdinalIgnoreCase), + searchNestedScriptBlocks: false).ToList(); + + if (asts.Count == 1) + { + var funcAst = (FunctionDefinitionAst) asts[0]; + paramAsts = funcAst.Parameters ?? funcAst.Body.ParamBlock?.Parameters; + } + else + { + string errorMsg = asts.Count == 0 + ? $"Cannot find the function '{entryPoint}' defined in '{scriptFile}'" + : $"More than one functions named '{entryPoint}' are found in '{scriptFile}'"; + throw new ArgumentException(errorMsg); + } + } + + HashSet parameters = new HashSet(StringComparer.OrdinalIgnoreCase); + if (paramAsts != null) + { + foreach (var paramAst in paramAsts) + { + parameters.Add(paramAst.Name.VariablePath.UserPath); + } + } + + return parameters; + } + } + + /// + /// Type of the Azure Function. + /// + internal enum AzFunctionType + { + None = 0, + RegularFunction = 1, + OrchestrationFunction = 2, + ActivityFunction = 3 + } + + /// + /// A read-only type that represents a BindingInfo. + /// + public class ReadOnlyBindingInfo + { + internal ReadOnlyBindingInfo(BindingInfo bindingInfo) + { + Type = bindingInfo.Type; + Direction = bindingInfo.Direction; + } + + /// + /// The type of the binding. + /// + public readonly string Type; + + /// + /// The direction of the binding. + /// + public readonly BindingInfo.Types.Direction Direction; + } +} diff --git a/src/FunctionLoader.cs b/src/FunctionLoader.cs index bcde07a8..3d666cce 100644 --- a/src/FunctionLoader.cs +++ b/src/FunctionLoader.cs @@ -5,11 +5,8 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.IO; using System.Linq; -using System.Management.Automation.Language; -using System.Text; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; @@ -62,210 +59,4 @@ internal static void SetupWellKnownPaths(FunctionLoadRequest request) FunctionAppProfilePath = profiles.FirstOrDefault(); } } - - internal enum AzFunctionType - { - None = 0, - RegularFunction = 1, - OrchestrationFunction = 2, - ActivityFunction = 3 - } - - /// - /// A read-only type that represents a BindingInfo. - /// - public class ReadOnlyBindingInfo - { - internal ReadOnlyBindingInfo(BindingInfo bindingInfo) - { - Type = bindingInfo.Type; - Direction = bindingInfo.Direction; - } - - /// - /// The type of the binding. - /// - public readonly string Type; - - /// - /// The direction of the binding. - /// - public readonly BindingInfo.Types.Direction Direction; - } - - /// - /// This type represents the metadata of an Azure PowerShell Function. - /// - internal class AzFunctionInfo - { - private const string OrchestrationTrigger = "orchestrationTrigger"; - private const string ActivityTrigger = "activityTrigger"; - - internal const string TriggerMetadata = "TriggerMetadata"; - internal const string DollarReturn = "$return"; - - internal readonly string FuncDirectory; - internal readonly string FuncName; - internal readonly string EntryPoint; - internal readonly string ScriptPath; - internal readonly HashSet FuncParameters; - internal readonly AzFunctionType Type; - internal readonly ReadOnlyDictionary AllBindings; - internal readonly ReadOnlyDictionary InputBindings; - internal readonly ReadOnlyDictionary OutputBindings; - - /// - /// Construct an object of AzFunctionInfo from the 'RpcFunctionMetadata'. - /// Necessary validations are done on the metadata and script. - /// - internal AzFunctionInfo(RpcFunctionMetadata metadata) - { - FuncName = metadata.Name; - FuncDirectory = metadata.Directory; - EntryPoint = metadata.EntryPoint; - ScriptPath = metadata.ScriptFile; - - // Support 'EntryPoint' only if 'ScriptFile' is a .psm1 file; - // Support .psm1 'ScriptFile' only if 'EntryPoint' is specified. - bool isScriptFilePsm1 = ScriptPath.EndsWith(".psm1", StringComparison.OrdinalIgnoreCase); - if (string.IsNullOrEmpty(EntryPoint)) - { - if (isScriptFilePsm1) - { - throw new ArgumentException($"The 'EntryPoint' property needs to be specified when 'ScriptFile' points to a PowerShell module script file (.psm1)."); - } - } - else if (!isScriptFilePsm1) - { - throw new ArgumentException($"The 'EntryPoint' property is supported only if 'ScriptFile' points to a PowerShell module script file (.psm1)."); - } - - // Get the parameter names of the script or function. - FuncParameters = GetParameters(ScriptPath, EntryPoint); - var parametersCopy = new HashSet(FuncParameters, StringComparer.OrdinalIgnoreCase); - parametersCopy.Remove(TriggerMetadata); - - var allBindings = new Dictionary(); - var inputBindings = new Dictionary(); - var outputBindings = new Dictionary(); - - var inputsMissingFromParams = new List(); - foreach (var binding in metadata.Bindings) - { - string bindingName = binding.Key; - var bindingInfo = new ReadOnlyBindingInfo(binding.Value); - - allBindings.Add(bindingName, bindingInfo); - - if (bindingInfo.Direction == BindingInfo.Types.Direction.In) - { - Type = GetAzFunctionType(bindingInfo); - inputBindings.Add(bindingName, bindingInfo); - - // If the input binding name is in the set, we remove it; - // otherwise, the binding name is missing from the params. - if (!parametersCopy.Remove(bindingName)) - { - inputsMissingFromParams.Add(bindingName); - } - } - else if (bindingInfo.Direction == BindingInfo.Types.Direction.Out) - { - outputBindings.Add(bindingName, bindingInfo); - } - else - { - // PowerShell doesn't support the 'InOut' type binding - throw new InvalidOperationException($"The binding '{bindingName}' is declared with 'InOut' direction, which is not supported by PowerShell functions."); - } - } - - if (inputsMissingFromParams.Count != 0 || parametersCopy.Count != 0) - { - StringBuilder stringBuilder = new StringBuilder(); - foreach (string inputBindingName in inputsMissingFromParams) - { - stringBuilder.AppendLine($"No parameter defined in the script or function for the input binding '{inputBindingName}'."); - } - - foreach (string param in parametersCopy) - { - stringBuilder.AppendLine($"No input binding defined for the parameter '{param}' that is declared in the script or function."); - } - - string errorMsg = stringBuilder.ToString(); - throw new InvalidOperationException(errorMsg); - } - - AllBindings = new ReadOnlyDictionary(allBindings); - InputBindings = new ReadOnlyDictionary(inputBindings); - OutputBindings = new ReadOnlyDictionary(outputBindings); - } - - private AzFunctionType GetAzFunctionType(ReadOnlyBindingInfo bindingInfo) - { - switch (bindingInfo.Type) - { - case OrchestrationTrigger: - return AzFunctionType.OrchestrationFunction; - case ActivityTrigger: - return AzFunctionType.ActivityFunction; - default: - // All other triggers are considered regular functions - return AzFunctionType.RegularFunction; - } - } - - private HashSet GetParameters(string scriptFile, string entryPoint) - { - ScriptBlockAst sbAst = Parser.ParseFile(scriptFile, out _, out ParseError[] errors); - if (errors != null && errors.Length > 0) - { - var stringBuilder = new StringBuilder(15); - foreach (var error in errors) - { - stringBuilder.AppendLine(error.Message); - } - - string errorMsg = stringBuilder.ToString(); - throw new ArgumentException($"The script file '{scriptFile}' has parsing errors:\n{errorMsg}"); - } - - ReadOnlyCollection paramAsts = null; - if (string.IsNullOrEmpty(entryPoint)) - { - paramAsts = sbAst.ParamBlock?.Parameters; - } - else - { - var asts = sbAst.FindAll( - ast => ast is FunctionDefinitionAst func && entryPoint.Equals(func.Name, StringComparison.OrdinalIgnoreCase), - searchNestedScriptBlocks: false).ToList(); - - if (asts.Count == 1) - { - var funcAst = (FunctionDefinitionAst) asts[0]; - paramAsts = funcAst.Parameters ?? funcAst.Body.ParamBlock?.Parameters; - } - else - { - string errorMsg = asts.Count == 0 - ? $"Cannot find the function '{entryPoint}' defined in '{scriptFile}'" - : $"More than one functions named '{entryPoint}' are found in '{scriptFile}'"; - throw new ArgumentException(errorMsg); - } - } - - HashSet parameters = new HashSet(StringComparer.OrdinalIgnoreCase); - if (paramAsts != null) - { - foreach (var paramAst in paramAsts) - { - parameters.Add(paramAst.Name.VariablePath.UserPath); - } - } - - return parameters; - } - } } diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 7183605a..a61ebda3 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -67,7 +67,7 @@ internal void PerformWorkerLevelInitialization() } /// - /// This method performs initialization that has to be done for each Runspace, e.g. profile.ps1. + /// This method performs initialization that has to be done for each Runspace, e.g. running the Function App's profile.ps1. /// internal void PerformRunspaceLevelInitialization() { @@ -83,9 +83,11 @@ internal void PerformRunspaceLevelInitialization() { // Import-Module on a .ps1 file will evaluate the script in the global scope. _pwsh.AddCommand("Microsoft.PowerShell.Core\\Import-Module") - .AddParameter("Name", profilePath).AddParameter("PassThru", true) + .AddParameter("Name", profilePath) + .AddParameter("PassThru", true) .AddCommand("Microsoft.PowerShell.Core\\Remove-Module") - .AddParameter("Force", true).AddParameter("ErrorAction", "SilentlyContinue") + .AddParameter("Force", true) + .AddParameter("ErrorAction", "SilentlyContinue") .InvokeAndClearCommands(); } catch (Exception e) @@ -117,16 +119,20 @@ internal Hashtable InvokeFunction( try { - bool hasEntryPoint = !string.IsNullOrEmpty(entryPoint); - if (hasEntryPoint) + if (string.IsNullOrEmpty(entryPoint)) + { + _pwsh.AddCommand(scriptPath); + } + else { // If an entry point is defined, we import the script module. moduleName = Path.GetFileNameWithoutExtension(scriptPath); - _pwsh.AddCommand("Microsoft.PowerShell.Core\\Import-Module").AddParameter("Name", scriptPath) + _pwsh.AddCommand("Microsoft.PowerShell.Core\\Import-Module") + .AddParameter("Name", scriptPath) .InvokeAndClearCommands(); - } - _pwsh.AddCommand(hasEntryPoint ? entryPoint : scriptPath); + _pwsh.AddCommand(entryPoint); + } // Set arguments for each input binding parameter foreach (ParameterBinding binding in inputData) @@ -152,7 +158,7 @@ internal Hashtable InvokeFunction( } var result = _pwsh.AddCommand("Microsoft.Azure.Functions.PowerShellWorker\\Get-OutputBinding") - .AddParameter("Purge", true) + .AddParameter("Purge", true) .InvokeAndClearCommands()[0]; /* @@ -183,9 +189,9 @@ internal Hashtable InvokeFunction( internal string ConvertToJson(object fromObj) { return _pwsh.AddCommand("Microsoft.PowerShell.Utility\\ConvertTo-Json") - .AddParameter("InputObject", fromObj) - .AddParameter("Depth", 3) - .AddParameter("Compress", true) + .AddParameter("InputObject", fromObj) + .AddParameter("Depth", 3) + .AddParameter("Compress", true) .InvokeAndClearCommands()[0]; } @@ -217,10 +223,10 @@ private void ResetRunspace(string moduleName) { // If the function had an entry point, this will remove the module that was loaded _pwsh.AddCommand("Microsoft.PowerShell.Core\\Remove-Module") - .AddParameter("Name", moduleName) - .AddParameter("Force", true) - .AddParameter("ErrorAction", "SilentlyContinue") - .InvokeAndClearCommands(); + .AddParameter("Name", moduleName) + .AddParameter("Force", true) + .AddParameter("ErrorAction", "SilentlyContinue") + .InvokeAndClearCommands(); } } } diff --git a/test/E2E/HttpTrigger.Tests.ps1 b/test/E2E/HttpTrigger.Tests.ps1 index 5fecb5cb..a9e03560 100644 --- a/test/E2E/HttpTrigger.Tests.ps1 +++ b/test/E2E/HttpTrigger.Tests.ps1 @@ -29,18 +29,18 @@ Describe 'HttpTrigger Tests' { FunctionName = 'TestBasicHttpTriggerWithProfile' ExpectedContent = 'PROFILE' }, - @{ - FunctionName = 'TestHttpTriggerWithEntryPoint' - ExpectedContent = 'Hello Atlas' - }, - @{ - FunctionName = 'TestHttpTriggerWithEntryPointAndTriggerMetadata' - ExpectedContent = 'Hello Atlas' - }, - @{ - FunctionName = 'TestHttpTriggerWithEntryPointAndProfile' - ExpectedContent = 'PROFILE' - } + @{ + FunctionName = 'TestHttpTriggerWithEntryPoint' + ExpectedContent = 'Hello Atlas' + }, + @{ + FunctionName = 'TestHttpTriggerWithEntryPointAndTriggerMetadata' + ExpectedContent = 'Hello Atlas' + }, + @{ + FunctionName = 'TestHttpTriggerWithEntryPointAndProfile' + ExpectedContent = 'PROFILE' + } ) { param ($FunctionName, $ExpectedContent) diff --git a/test/Unit/Function/FunctionLoaderTests.cs b/test/Unit/Function/FunctionLoaderTests.cs index 1c7b27c2..4e2a7e34 100644 --- a/test/Unit/Function/FunctionLoaderTests.cs +++ b/test/Unit/Function/FunctionLoaderTests.cs @@ -127,19 +127,8 @@ public void EntryPointIsSupportedWithPsm1FileOnly() var entryPointToUse = "Run"; var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("EntryPoint", exception.Message); Assert.Contains("(.psm1)", exception.Message); } @@ -151,19 +140,8 @@ public void Psm1IsSupportedWithEntryPointOnly() var entryPointToUse = string.Empty; var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("EntryPoint", exception.Message); Assert.Contains("(.psm1)", exception.Message); } @@ -175,19 +153,8 @@ public void ParseErrorInScriptFileShouldBeDetected() var entryPointToUse = string.Empty; var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("parsing errors", exception.Message); } @@ -198,19 +165,8 @@ public void EntryPointFunctionShouldExist() var entryPointToUse = "CallMe"; var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("CallMe", exception.Message); Assert.Contains("FuncWithEntryPoint.psm1", exception.Message); } @@ -222,19 +178,8 @@ public void MultipleEntryPointFunctionsShouldBeDetected() var entryPointToUse = "Run"; var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("Run", exception.Message); Assert.Contains("FuncWithMultiEntryPoints.psm1", exception.Message); } @@ -249,19 +194,8 @@ public void ParametersShouldMatchInputBinding() functionLoadRequest.Metadata.Bindings.Add("inputTable", new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "tableTrigger" }); functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("inputTable", exception.Message); Assert.Contains("inputBlob", exception.Message); } @@ -276,19 +210,8 @@ public void ParametersShouldMatchInputBindingWithTriggerMetadataParam() functionLoadRequest.Metadata.Bindings.Add("inputTable", new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "tableTrigger" }); functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("inputTable", exception.Message); Assert.Contains("inputBlob", exception.Message); } @@ -303,19 +226,8 @@ public void EntryPointParametersShouldMatchInputBinding() functionLoadRequest.Metadata.Bindings.Add("inputTable", new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "tableTrigger" }); functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("inputTable", exception.Message); Assert.Contains("inputBlob", exception.Message); } @@ -330,19 +242,8 @@ public void EntryPointParametersShouldMatchInputBindingWithTriggerMetadataParam( functionLoadRequest.Metadata.Bindings.Add("inputTable", new BindingInfo { Direction = BindingInfo.Types.Direction.In, Type = "tableTrigger" }); functionLoadRequest.Metadata.Bindings.Remove("inputBlob"); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("inputTable", exception.Message); Assert.Contains("inputBlob", exception.Message); } @@ -356,19 +257,8 @@ public void InOutBindingIsNotSupported() var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); functionLoadRequest.Metadata.Bindings.Add("inoutBinding", new BindingInfo { Direction = BindingInfo.Types.Direction.Inout, Type = "queue" }); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("inoutBinding", exception.Message); Assert.Contains("InOut", exception.Message); } @@ -380,19 +270,8 @@ public void ScriptNeedToHaveParameters() var entryPointToUse = string.Empty; var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("req", exception.Message); Assert.Contains("inputBlob", exception.Message); } @@ -404,19 +283,8 @@ public void EntryPointNeedToHaveParameters() var entryPointToUse = "Zoo"; var functionLoadRequest = GetFuncLoadRequest(scriptFileToUse, entryPointToUse); - Exception exception = null; - var functionLoader = new FunctionLoader(); - try - { - functionLoader.LoadFunction(functionLoadRequest); - } - catch (Exception e) - { - exception = e; - } - - Assert.NotNull(exception); - Assert.IsType(exception); + var exception = Assert.Throws( + () => new FunctionLoader().LoadFunction(functionLoadRequest)); Assert.Contains("req", exception.Message); Assert.Contains("inputBlob", exception.Message); } diff --git a/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 b/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 index eddd1167..993a48a6 100644 --- a/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 +++ b/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 @@ -8,4 +8,4 @@ param ($Req, $TriggerMetadata) # Used for logging tests Write-Verbose "a log" -Push-OutputBinding -Name res -Value $Req +Push-OutputBinding -Name res -Value $TriggerMetadata From eb3bbac59fbdc547fa89ff7bf6e2f8d957a25efc Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Thu, 13 Dec 2018 22:17:44 -0800 Subject: [PATCH 4/4] Minor fix to tests --- test/Unit/Function/FunctionLoaderTests.cs | 4 ++-- .../TestScripts/testBasicFunctionWithTriggerMetadata.ps1 | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/Unit/Function/FunctionLoaderTests.cs b/test/Unit/Function/FunctionLoaderTests.cs index 4e2a7e34..5e82d421 100644 --- a/test/Unit/Function/FunctionLoaderTests.cs +++ b/test/Unit/Function/FunctionLoaderTests.cs @@ -129,7 +129,7 @@ public void EntryPointIsSupportedWithPsm1FileOnly() var exception = Assert.Throws( () => new FunctionLoader().LoadFunction(functionLoadRequest)); - Assert.Contains("EntryPoint", exception.Message); + Assert.Contains("entryPoint", exception.Message); Assert.Contains("(.psm1)", exception.Message); } @@ -142,7 +142,7 @@ public void Psm1IsSupportedWithEntryPointOnly() var exception = Assert.Throws( () => new FunctionLoader().LoadFunction(functionLoadRequest)); - Assert.Contains("EntryPoint", exception.Message); + Assert.Contains("entryPoint", exception.Message); Assert.Contains("(.psm1)", exception.Message); } diff --git a/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 b/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 index 993a48a6..914d0e9e 100644 --- a/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 +++ b/test/Unit/PowerShell/TestScripts/testBasicFunctionWithTriggerMetadata.ps1 @@ -8,4 +8,4 @@ param ($Req, $TriggerMetadata) # Used for logging tests Write-Verbose "a log" -Push-OutputBinding -Name res -Value $TriggerMetadata +Push-OutputBinding -Name res -Value $TriggerMetadata.Req