From 5fdca958e1df82a1023c79c9044a5df1848d8330 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Tue, 11 Dec 2018 16:52:48 -0800 Subject: [PATCH 01/13] profile support and remove auto-auth to azure --- examples/PSCoreApp/Profile.ps1 | 20 ++++++ src/PowerShell/PowerShellManager.cs | 94 +++++++++++++---------------- src/RequestProcessor.cs | 23 ++++--- 3 files changed, 76 insertions(+), 61 deletions(-) create mode 100644 examples/PSCoreApp/Profile.ps1 diff --git a/examples/PSCoreApp/Profile.ps1 b/examples/PSCoreApp/Profile.ps1 new file mode 100644 index 00000000..b21d1fa8 --- /dev/null +++ b/examples/PSCoreApp/Profile.ps1 @@ -0,0 +1,20 @@ +# This script will be run on every COLD START of the Function App +# You can define helper functions, run commands, or specify environment variables +# NOTE: any variables defined that are not environment variables will get reset after the first execution + +# Example usecases for a Profile.ps1: + +# Authenticating with Azure PowerShell using MSI +# $tokenAuthURI = $env:MSI_ENDPOINT + "?resource=https://management.azure.com&api-version=2017-09-01" +# $tokenResponse = Invoke-RestMethod -Method Get -Headers @{"Secret"="$env:MSI_SECRET"} -Uri $tokenAuthURI +# Connect-AzAccount -AccessToken $tokenResponse.access_token -AccountId $env:WEBSITE_SITE_NAME + +# Enabling legacy AzureRm alias in Azure PowerShell +# Enable-AzureRmAlias + +# Defining a function that you can refernce in any of your PowerShell functions: +# function Get-MyData { +# @{ +# Foo = 5 +# } +# } diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 454108a2..1e736f48 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -17,12 +17,19 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.PowerShell { + using System.Linq; using System.Management.Automation; + using System.Text; internal class PowerShellManager { private readonly ILogger _logger; private readonly PowerShell _pwsh; + private const string PROFILE_FILENAME = "Profile.ps1"; + + // The path to the FunctionApp root. This is set at the first FunctionLoad message + //and used for determining the path to the 'Profile.ps1' and 'Modules' folder. + internal string FunctionAppRootLocation { get; set; } internal PowerShellManager(ILogger logger) { @@ -48,69 +55,54 @@ internal PowerShellManager(ILogger logger) _pwsh.Streams.Warning.DataAdding += streamHandler.WarningDataAdding; } - internal void AuthenticateToAzure() + internal void InvokeProfile() { - // Check if Az.Profile is available - Collection azProfile = _pwsh.AddCommand("Get-Module") - .AddParameter("ListAvailable") - .AddParameter("Name", "Az.Profile") - .InvokeAndClearCommands(); - - if (azProfile.Count == 0) + IEnumerable profiles = Directory.EnumerateFiles(FunctionAppRootLocation, PROFILE_FILENAME); + if (profiles.Count() == 0) { - _logger.Log(LogLevel.Trace, "Required module to automatically authenticate with Azure `Az.Profile` was not found in the PSModulePath."); + _logger.Log(LogLevel.Trace, $"No 'Profile.ps1' found at: {FunctionAppRootLocation}"); return; } - // Try to authenticate to Azure using MSI - string msiSecret = Environment.GetEnvironmentVariable("MSI_SECRET"); - string msiEndpoint = Environment.GetEnvironmentVariable("MSI_ENDPOINT"); - string accountId = Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME"); - - if (!string.IsNullOrEmpty(msiSecret) && - !string.IsNullOrEmpty(msiEndpoint) && - !string.IsNullOrEmpty(accountId)) + var dotSourced = new StringBuilder(". ").Append(QuoteEscapeString(profiles.First())); + _pwsh.AddScript(dotSourced.ToString()).InvokeAndClearCommands(); + + if (_pwsh.HadErrors) { - // NOTE: There is a limitation in Azure PowerShell that prevents us from using the parameter set: - // Connect-AzAccount -MSI or Connect-AzAccount -Identity - // see this GitHub issue https://github.com/Azure/azure-powershell/issues/7876 - // As a workaround, we can all an API endpoint on the MSI_ENDPOINT to get an AccessToken and use that to authenticate - Collection response = _pwsh.AddCommand("Microsoft.PowerShell.Utility\\Invoke-RestMethod") - .AddParameter("Method", "Get") - .AddParameter("Headers", new Hashtable {{ "Secret", msiSecret }}) - .AddParameter("Uri", $"{msiEndpoint}?resource=https://management.azure.com&api-version=2017-09-01") - .InvokeAndClearCommands(); + var logMessage = $"Invoking the Profile had errors. See logs for details. Profile location: {FunctionAppRootLocation}"; + _logger.Log(LogLevel.Error, logMessage, isUserLog: true); + throw new InvalidOperationException(logMessage); + } + } - if(_pwsh.HadErrors) - { - _logger.Log(LogLevel.Warning, "Failed to Authenticate to Azure via MSI. Check the logs for the errors generated."); - } - else + /// + /// Wrap a string in quotes to make it safe to use in scripts. + /// + /// The path to wrap in quotes. + /// The given path wrapped in quotes appropriately. + private static StringBuilder QuoteEscapeString(string path) + { + var sb = new StringBuilder(path.Length + 2); // Length of string plus two quotes + sb.Append('\''); + if (!path.Contains('\'')) + { + sb.Append(path); + } + else + { + foreach (char c in path) { - // We have successfully authenticated to Azure so we can return out. - using (ExecutionTimer.Start(_logger, "Authentication to Azure")) + if (c == '\'') { - _pwsh.AddCommand("Az.Profile\\Connect-AzAccount") - .AddParameter("AccessToken", response[0].Properties["access_token"].Value) - .AddParameter("AccountId", accountId) - .InvokeAndClearCommands(); - - if(_pwsh.HadErrors) - { - _logger.Log(LogLevel.Warning, "Failed to Authenticate to Azure. Check the logs for the errors generated."); - } - else - { - // We've successfully authenticated to Azure so we can return - return; - } + sb.Append("''"); + continue; } + + sb.Append(c); } } - else - { - _logger.Log(LogLevel.Trace, "Skip authentication to Azure via MSI. Environment variables for authenticating to Azure are not present."); - } + sb.Append('\''); + return sb; } internal void InitializeRunspace() diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index 0ac46b33..2f2fbb4a 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -23,9 +23,10 @@ internal class RequestProcessor private readonly MessagingStream _msgStream; private readonly PowerShellManager _powerShellManager; - // used to determine if we have already added the Function App's 'Modules' - // folder to the PSModulePath - private bool _prependedPath; + // 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; internal RequestProcessor(MessagingStream msgStream) { @@ -106,17 +107,19 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) // if we haven't yet, add the well-known Function App module path to the PSModulePath // The location of this module path is in a folder called "Modules" in the root of the Function App. - if (!_prependedPath) + if (!_initializedFunctionApp) { - string functionAppModulesPath = Path.GetFullPath( - Path.Combine(functionLoadRequest.Metadata.Directory, "..", "Modules")); - _powerShellManager.PrependToPSModulePath(functionAppModulesPath); + string functionAppRoot = Path.GetFullPath(Path.Combine(functionLoadRequest.Metadata.Directory, "..")); + _powerShellManager.FunctionAppRootLocation = functionAppRoot; + + // Prepend the Function App's 'Modules' folder to the PSModulePath + _powerShellManager.PrependToPSModulePath(Path.Combine(functionAppRoot, "Modules")); // Since this is the first time we know where the location of the FunctionApp is, - // we can attempt to authenticate to Azure at this time. - _powerShellManager.AuthenticateToAzure(); + // we can attempt to execute the Profile. + _powerShellManager.InvokeProfile(); - _prependedPath = true; + _initializedFunctionApp = true; } } catch (Exception e) From eeded0d79d350b063b71966acb9fb1371c43c9bc Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Tue, 11 Dec 2018 16:59:47 -0800 Subject: [PATCH 02/13] profile support and remove auto-auth to azure --- src/PowerShell/PowerShellManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 1e736f48..35706669 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -8,7 +8,9 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using System.Linq; using System.Security; +using System.Text; using Microsoft.Azure.Functions.PowerShellWorker.Utility; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; @@ -17,9 +19,7 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.PowerShell { - using System.Linq; using System.Management.Automation; - using System.Text; internal class PowerShellManager { From bfa8bb05ea41b1a42c9b9372ed2927475b64bc5a Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Tue, 11 Dec 2018 18:19:38 -0800 Subject: [PATCH 03/13] add unit tests --- src/PowerShell/PowerShellManager.cs | 44 +++++++++----- ...ure.Functions.PowerShellWorker.Test.csproj | 9 +++ .../Unit/PowerShell/PowerShellManagerTests.cs | 60 ++++++++++++++++++- .../TestScripts/ProfileBasic/profile.ps1 | 1 + .../Profile.ps1 | 1 + .../ProfileWithTerminatingError/profile.ps1 | 1 + 6 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 test/Unit/PowerShell/TestScripts/ProfileBasic/profile.ps1 create mode 100644 test/Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError/Profile.ps1 create mode 100644 test/Unit/PowerShell/TestScripts/ProfileWithTerminatingError/profile.ps1 diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 35706669..3271bcac 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -29,7 +29,7 @@ internal class PowerShellManager // The path to the FunctionApp root. This is set at the first FunctionLoad message //and used for determining the path to the 'Profile.ps1' and 'Modules' folder. - internal string FunctionAppRootLocation { get; set; } + internal string FunctionAppRootLocation { get; set; } = AppDomain.CurrentDomain.BaseDirectory; internal PowerShellManager(ILogger logger) { @@ -55,6 +55,16 @@ internal PowerShellManager(ILogger logger) _pwsh.Streams.Warning.DataAdding += streamHandler.WarningDataAdding; } + internal void InitializeRunspace() + { + // 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 PSModulePath + Environment.SetEnvironmentVariable("PSModulePath", Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules")); + } + internal void InvokeProfile() { IEnumerable profiles = Directory.EnumerateFiles(FunctionAppRootLocation, PROFILE_FILENAME); @@ -65,13 +75,27 @@ internal void InvokeProfile() } var dotSourced = new StringBuilder(". ").Append(QuoteEscapeString(profiles.First())); - _pwsh.AddScript(dotSourced.ToString()).InvokeAndClearCommands(); + + try + { + _pwsh.AddScript(dotSourced.ToString()).InvokeAndClearCommands(); + } + catch (RuntimeException e) + { + _logger.Log( + LogLevel.Error, + $"Invoking the Profile had errors. See logs for details. Profile location: {FunctionAppRootLocation}", + e, + isUserLog: true); + throw; + } if (_pwsh.HadErrors) { - var logMessage = $"Invoking the Profile had errors. See logs for details. Profile location: {FunctionAppRootLocation}"; - _logger.Log(LogLevel.Error, logMessage, isUserLog: true); - throw new InvalidOperationException(logMessage); + _logger.Log( + LogLevel.Error, + $"Invoking the Profile had errors. See logs for details. Profile location: {FunctionAppRootLocation}", + isUserLog: true); } } @@ -105,16 +129,6 @@ private static StringBuilder QuoteEscapeString(string path) return sb; } - internal void InitializeRunspace() - { - // 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 PSModulePath - Environment.SetEnvironmentVariable("PSModulePath", Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules")); - } - /// /// Execution a function fired by a trigger or an activity function scheduled by an orchestration. /// diff --git a/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj b/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj index 95ead50b..7c158d1c 100644 --- a/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj +++ b/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj @@ -31,5 +31,14 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 7a5676fc..588ef05a 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -6,7 +6,7 @@ using System; using System.Collections; using System.Collections.Generic; - +using System.Management.Automation; using Microsoft.Azure.Functions.PowerShellWorker.PowerShell; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; using Xunit; @@ -159,5 +159,63 @@ public void RegisterAndUnregisterFunctionMetadataShouldWork() manager.UnregisterFunctionMetadata(); Assert.Empty(FunctionMetadata.OutputBindingCache); } + + [Fact] + public void ProfileShouldWork() + { + var logger = new ConsoleLogger(); + var manager = new PowerShellManager(logger); + manager.FunctionAppRootLocation = System.IO.Path.Join( + AppDomain.CurrentDomain.BaseDirectory, + "Unit/PowerShell/TestScripts/ProfileBasic"); + + manager.InvokeProfile(); + + Assert.Single(logger.FullLog); + Assert.Equal("Information: INFORMATION: Hello PROFILE", logger.FullLog[0]); + } + + [Fact] + public void ProfileDoesNotExist() + { + var logger = new ConsoleLogger(); + var manager = new PowerShellManager(logger); + manager.FunctionAppRootLocation = AppDomain.CurrentDomain.BaseDirectory; + + manager.InvokeProfile(); + + Assert.Single(logger.FullLog); + Assert.Matches("Trace: No 'Profile.ps1' found at: ", logger.FullLog[0]); + } + + [Fact] + public void ProfileWithTerminatingError() + { + var logger = new ConsoleLogger(); + var manager = new PowerShellManager(logger); + manager.FunctionAppRootLocation = System.IO.Path.Join( + AppDomain.CurrentDomain.BaseDirectory, + "Unit/PowerShell/TestScripts/ProfileWithTerminatingError"); + + Assert.Throws(typeof(RuntimeException), () => manager.InvokeProfile()); + Assert.Single(logger.FullLog); + Assert.Matches("Error: Invoking the Profile had errors. See logs for details. Profile location: ", logger.FullLog[0]); + } + + [Fact] + public void ProfileWithNonTerminatingError() + { + var logger = new ConsoleLogger(); + var manager = new PowerShellManager(logger); + manager.FunctionAppRootLocation = System.IO.Path.Join( + AppDomain.CurrentDomain.BaseDirectory, + "Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError"); + + manager.InvokeProfile(); + + Assert.Equal(2, logger.FullLog.Count); + Assert.Equal("Error: ERROR: help me!", logger.FullLog[0]); + Assert.Matches("Error: Invoking the Profile had errors. See logs for details. Profile location: ", logger.FullLog[1]); + } } } diff --git a/test/Unit/PowerShell/TestScripts/ProfileBasic/profile.ps1 b/test/Unit/PowerShell/TestScripts/ProfileBasic/profile.ps1 new file mode 100644 index 00000000..668f0e62 --- /dev/null +++ b/test/Unit/PowerShell/TestScripts/ProfileBasic/profile.ps1 @@ -0,0 +1 @@ +Write-Host "Hello PROFILE" diff --git a/test/Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError/Profile.ps1 b/test/Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError/Profile.ps1 new file mode 100644 index 00000000..e28dac73 --- /dev/null +++ b/test/Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError/Profile.ps1 @@ -0,0 +1 @@ +Write-Error "help me!" diff --git a/test/Unit/PowerShell/TestScripts/ProfileWithTerminatingError/profile.ps1 b/test/Unit/PowerShell/TestScripts/ProfileWithTerminatingError/profile.ps1 new file mode 100644 index 00000000..c8a54dfe --- /dev/null +++ b/test/Unit/PowerShell/TestScripts/ProfileWithTerminatingError/profile.ps1 @@ -0,0 +1 @@ +throw "help me!" From 834a118263d15fa849920f862241073ab9b19a0f Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Tue, 11 Dec 2018 18:59:27 -0800 Subject: [PATCH 04/13] added E2E test with fix of E2E tests --- test/E2E/HttpTrigger.Tests.ps1 | 14 ++++++++----- test/E2E/TestFunctionApp/Profile.ps1 | 3 +++ .../function.json | 20 +++++++++++++++++++ .../TestBasicHttpTriggerWithProfile/run.ps1 | 12 +++++++++++ test/E2E/setupE2Etests.ps1 | 1 + 5 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 test/E2E/TestFunctionApp/Profile.ps1 create mode 100644 test/E2E/TestFunctionApp/TestBasicHttpTriggerWithProfile/function.json create mode 100644 test/E2E/TestFunctionApp/TestBasicHttpTriggerWithProfile/run.ps1 diff --git a/test/E2E/HttpTrigger.Tests.ps1 b/test/E2E/HttpTrigger.Tests.ps1 index 2f01c5e2..a3098ee8 100644 --- a/test/E2E/HttpTrigger.Tests.ps1 +++ b/test/E2E/HttpTrigger.Tests.ps1 @@ -24,11 +24,15 @@ Describe 'HttpTrigger Tests' { @{ FunctionName = 'TestBasicHttpTriggerWithTriggerMetadata' ExpectedContent = 'Hello Atlas' + }, + @{ + FunctionName = 'TestBasicHttpTriggerWithProfile' + ExpectedContent = 'PROFILE' } ) { - param ($ExpectedContent) + param ($FunctionName, $ExpectedContent) - $res = Invoke-WebRequest "$FUNCTIONS_BASE_URL/api/TestBasicHttpTrigger?Name=Atlas" + $res = Invoke-WebRequest "$FUNCTIONS_BASE_URL/api/$($FunctionName)?Name=Atlas" $res.StatusCode | Should -Be ([HttpStatusCode]::Accepted) $res.Content | Should -Be $ExpectedContent @@ -50,12 +54,12 @@ Describe 'HttpTrigger Tests' { FunctionName = 'TestBasicHttpTriggerWithTriggerMetadata' } ) { - param ($InputNameData) + param ($FunctionName, $InputNameData) if (Test-Path 'variable:InputNameData') { - $url = "$FUNCTIONS_BASE_URL/api/TestBasicHttpTrigger?Name=$InputNameData" + $url = "$FUNCTIONS_BASE_URL/api/$($FunctionName)?Name=$InputNameData" } else { - $url = "$FUNCTIONS_BASE_URL/api/TestBasicHttpTrigger" + $url = "$FUNCTIONS_BASE_URL/api/$($FunctionName)" } $res = { invoke-webrequest $url } | diff --git a/test/E2E/TestFunctionApp/Profile.ps1 b/test/E2E/TestFunctionApp/Profile.ps1 new file mode 100644 index 00000000..954724f1 --- /dev/null +++ b/test/E2E/TestFunctionApp/Profile.ps1 @@ -0,0 +1,3 @@ +function Get-ProfileString { + "PROFILE" +} diff --git a/test/E2E/TestFunctionApp/TestBasicHttpTriggerWithProfile/function.json b/test/E2E/TestFunctionApp/TestBasicHttpTriggerWithProfile/function.json new file mode 100644 index 00000000..f7c36dd7 --- /dev/null +++ b/test/E2E/TestFunctionApp/TestBasicHttpTriggerWithProfile/function.json @@ -0,0 +1,20 @@ +{ + "disabled": false, + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get", + "post" + ] + }, + { + "type": "http", + "direction": "out", + "name": "res" + } + ] +} diff --git a/test/E2E/TestFunctionApp/TestBasicHttpTriggerWithProfile/run.ps1 b/test/E2E/TestFunctionApp/TestBasicHttpTriggerWithProfile/run.ps1 new file mode 100644 index 00000000..7ef9e39e --- /dev/null +++ b/test/E2E/TestFunctionApp/TestBasicHttpTriggerWithProfile/run.ps1 @@ -0,0 +1,12 @@ +# +# Copyright (c) Microsoft. All rights reserved. +# Licensed under the MIT license. See LICENSE file in the project root for full license information. +# + +# Input bindings are passed in via param block. +param($req) + +Push-OutputBinding -Name res -Value ([HttpResponseContext]@{ + StatusCode = 202 + Body = (Get-ProfileString) +}) diff --git a/test/E2E/setupE2Etests.ps1 b/test/E2E/setupE2Etests.ps1 index 8b0ea9ef..848a616b 100644 --- a/test/E2E/setupE2Etests.ps1 +++ b/test/E2E/setupE2Etests.ps1 @@ -35,6 +35,7 @@ Expand-Archive $output -DestinationPath $FUNC_CLI_DIRECTORY Write-Host "Copying azure-functions-powershell-worker to Functions Host workers directory..." $configuration = if ($env:CONFIGURATION) { $env:CONFIGURATION } else { 'Debug' } +Remove-Item -Recurse -Force -Path "$FUNC_CLI_DIRECTORY/workers/powershell" Copy-Item -Recurse -Force "$PSScriptRoot/../../src/bin/$configuration/netcoreapp2.1/publish/" "$FUNC_CLI_DIRECTORY/workers/powershell" Write-Host "Staring Functions Host..." From afe0c12d70e285918742d2ce665e42cab4e82e2b Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Tue, 11 Dec 2018 19:32:43 -0800 Subject: [PATCH 05/13] case sensitive fix for linux --- test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj b/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj index 7c158d1c..77281ee6 100644 --- a/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj +++ b/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj @@ -31,10 +31,10 @@ PreserveNewest - + PreserveNewest - + PreserveNewest From 9899b5cc23383d37db52514a41ca1a3670136f98 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Tue, 11 Dec 2018 20:07:58 -0800 Subject: [PATCH 06/13] use EnumerationOptions for case-insensitive --- src/PowerShell/PowerShellManager.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 3271bcac..39d1353c 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -26,6 +26,9 @@ internal class PowerShellManager private readonly ILogger _logger; private readonly PowerShell _pwsh; private const string PROFILE_FILENAME = "Profile.ps1"; + private readonly EnumerationOptions ENUMERATION_OPTIONS = new EnumerationOptions { + MatchCasing = MatchCasing.CaseInsensitive + }; // The path to the FunctionApp root. This is set at the first FunctionLoad message //and used for determining the path to the 'Profile.ps1' and 'Modules' folder. @@ -67,7 +70,7 @@ internal void InitializeRunspace() internal void InvokeProfile() { - IEnumerable profiles = Directory.EnumerateFiles(FunctionAppRootLocation, PROFILE_FILENAME); + IEnumerable profiles = Directory.EnumerateFiles(FunctionAppRootLocation, PROFILE_FILENAME, ENUMERATION_OPTIONS); if (profiles.Count() == 0) { _logger.Log(LogLevel.Trace, $"No 'Profile.ps1' found at: {FunctionAppRootLocation}"); From 712de52f92e1dccaca52ade5eb79e553a2be781f Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Wed, 12 Dec 2018 14:12:12 -0800 Subject: [PATCH 07/13] use correct usage of Assert.Throws and update tasks.json to run tests --- .vscode/tasks.json | 27 ++++++++++++++++--- .../Unit/PowerShell/PowerShellManagerTests.cs | 2 +- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e9bf3dde..dda414ba 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,13 +3,32 @@ "tasks": [ { "label": "build", - "command": "dotnet", + "command": "pwsh", "type": "process", "args": [ - "build", - "${workspaceFolder}/psworker.csproj" + "-c", + "${workspaceFolder}/build.ps1" ], - "problemMatcher": "$msCompile" + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "test", + "command": "pwsh", + "type": "process", + "args": [ + "-c", + "${workspaceFolder}/build.ps1", + "-Test" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "test", + "isDefault": true + } } ] } diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 588ef05a..39dd6e53 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -197,7 +197,7 @@ public void ProfileWithTerminatingError() AppDomain.CurrentDomain.BaseDirectory, "Unit/PowerShell/TestScripts/ProfileWithTerminatingError"); - Assert.Throws(typeof(RuntimeException), () => manager.InvokeProfile()); + Assert.Throws(() => manager.InvokeProfile()); Assert.Single(logger.FullLog); Assert.Matches("Error: Invoking the Profile had errors. See logs for details. Profile location: ", logger.FullLog[0]); } From 7175d13d073a438c2e7fbcace8100e02a0e4bad3 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Wed, 12 Dec 2018 15:45:44 -0800 Subject: [PATCH 08/13] address feedback --- examples/PSCoreApp/Profile.ps1 | 2 +- src/FunctionLoader.cs | 3 ++ src/PowerShell/PowerShellManager.cs | 34 ++++++++++--------- src/RequestProcessor.cs | 5 ++- .../Unit/PowerShell/PowerShellManagerTests.cs | 10 +++--- 5 files changed, 29 insertions(+), 25 deletions(-) diff --git a/examples/PSCoreApp/Profile.ps1 b/examples/PSCoreApp/Profile.ps1 index b21d1fa8..de017991 100644 --- a/examples/PSCoreApp/Profile.ps1 +++ b/examples/PSCoreApp/Profile.ps1 @@ -2,7 +2,7 @@ # You can define helper functions, run commands, or specify environment variables # NOTE: any variables defined that are not environment variables will get reset after the first execution -# Example usecases for a Profile.ps1: +# Example usecases for a profile.ps1: # Authenticating with Azure PowerShell using MSI # $tokenAuthURI = $env:MSI_ENDPOINT + "?resource=https://management.azure.com&api-version=2017-09-01" diff --git a/src/FunctionLoader.cs b/src/FunctionLoader.cs index 795b5d26..c9a819ba 100644 --- a/src/FunctionLoader.cs +++ b/src/FunctionLoader.cs @@ -13,6 +13,8 @@ internal class FunctionLoader { private readonly MapField _loadedFunctions = new MapField(); + internal static string FunctionAppRootLocation { get; set; } + internal AzFunctionInfo GetFunctionInfo(string functionId) { if (_loadedFunctions.TryGetValue(functionId, out AzFunctionInfo funcInfo)) @@ -28,6 +30,7 @@ internal void Load(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)); + } } diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 39d1353c..53ee49f3 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -25,14 +25,6 @@ internal class PowerShellManager { private readonly ILogger _logger; private readonly PowerShell _pwsh; - private const string PROFILE_FILENAME = "Profile.ps1"; - private readonly EnumerationOptions ENUMERATION_OPTIONS = new EnumerationOptions { - MatchCasing = MatchCasing.CaseInsensitive - }; - - // The path to the FunctionApp root. This is set at the first FunctionLoad message - //and used for determining the path to the 'Profile.ps1' and 'Modules' folder. - internal string FunctionAppRootLocation { get; set; } = AppDomain.CurrentDomain.BaseDirectory; internal PowerShellManager(ILogger logger) { @@ -70,24 +62,34 @@ internal void InitializeRunspace() internal void InvokeProfile() { - IEnumerable profiles = Directory.EnumerateFiles(FunctionAppRootLocation, PROFILE_FILENAME, ENUMERATION_OPTIONS); + string functionAppRootLocation = FunctionLoader.FunctionAppRootLocation; + List profiles = Directory.EnumerateFiles( + functionAppRootLocation, + "profile.ps1", + new EnumerationOptions { + MatchCasing = MatchCasing.CaseInsensitive + }) + .ToList(); + if (profiles.Count() == 0) { - _logger.Log(LogLevel.Trace, $"No 'Profile.ps1' found at: {FunctionAppRootLocation}"); + _logger.Log(LogLevel.Trace, $"No 'Profile.ps1' found at: {functionAppRootLocation}"); return; } - - var dotSourced = new StringBuilder(". ").Append(QuoteEscapeString(profiles.First())); try { - _pwsh.AddScript(dotSourced.ToString()).InvokeAndClearCommands(); + // Import-Module on a .ps1 file will evaluate the script in the global scope. + _pwsh.AddCommand("Microsoft.PowerShell.Core\\Import-Module") + .AddParameter("Name", profiles[0]).AddParameter("PassThru", true) + .AddCommand("Remove-Module") + .InvokeAndClearCommands(); } - catch (RuntimeException e) + catch (CmdletInvocationException e) { _logger.Log( LogLevel.Error, - $"Invoking the Profile had errors. See logs for details. Profile location: {FunctionAppRootLocation}", + $"Invoking the Profile had errors. See logs for details. Profile location: {functionAppRootLocation}", e, isUserLog: true); throw; @@ -97,7 +99,7 @@ internal void InvokeProfile() { _logger.Log( LogLevel.Error, - $"Invoking the Profile had errors. See logs for details. Profile location: {FunctionAppRootLocation}", + $"Invoking the Profile had errors. See logs for details. Profile location: {functionAppRootLocation}", isUserLog: true); } } diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index 2f2fbb4a..7fc0472c 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -109,11 +109,10 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) // The location of this module path is in a folder called "Modules" in the root of the Function App. if (!_initializedFunctionApp) { - string functionAppRoot = Path.GetFullPath(Path.Combine(functionLoadRequest.Metadata.Directory, "..")); - _powerShellManager.FunctionAppRootLocation = functionAppRoot; + FunctionLoader.FunctionAppRootLocation = Path.GetFullPath(Path.Combine(functionLoadRequest.Metadata.Directory, "..")); // Prepend the Function App's 'Modules' folder to the PSModulePath - _powerShellManager.PrependToPSModulePath(Path.Combine(functionAppRoot, "Modules")); + _powerShellManager.PrependToPSModulePath(Path.Combine(FunctionLoader.FunctionAppRootLocation, "Modules")); // Since this is the first time we know where the location of the FunctionApp is, // we can attempt to execute the Profile. diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 39dd6e53..90a113c1 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -165,7 +165,7 @@ public void ProfileShouldWork() { var logger = new ConsoleLogger(); var manager = new PowerShellManager(logger); - manager.FunctionAppRootLocation = System.IO.Path.Join( + FunctionLoader.FunctionAppRootLocation = System.IO.Path.Join( AppDomain.CurrentDomain.BaseDirectory, "Unit/PowerShell/TestScripts/ProfileBasic"); @@ -180,7 +180,7 @@ public void ProfileDoesNotExist() { var logger = new ConsoleLogger(); var manager = new PowerShellManager(logger); - manager.FunctionAppRootLocation = AppDomain.CurrentDomain.BaseDirectory; + FunctionLoader.FunctionAppRootLocation = AppDomain.CurrentDomain.BaseDirectory; manager.InvokeProfile(); @@ -193,11 +193,11 @@ public void ProfileWithTerminatingError() { var logger = new ConsoleLogger(); var manager = new PowerShellManager(logger); - manager.FunctionAppRootLocation = System.IO.Path.Join( + FunctionLoader.FunctionAppRootLocation = System.IO.Path.Join( AppDomain.CurrentDomain.BaseDirectory, "Unit/PowerShell/TestScripts/ProfileWithTerminatingError"); - Assert.Throws(() => manager.InvokeProfile()); + Assert.Throws(() => manager.InvokeProfile()); Assert.Single(logger.FullLog); Assert.Matches("Error: Invoking the Profile had errors. See logs for details. Profile location: ", logger.FullLog[0]); } @@ -207,7 +207,7 @@ public void ProfileWithNonTerminatingError() { var logger = new ConsoleLogger(); var manager = new PowerShellManager(logger); - manager.FunctionAppRootLocation = System.IO.Path.Join( + FunctionLoader.FunctionAppRootLocation = System.IO.Path.Join( AppDomain.CurrentDomain.BaseDirectory, "Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError"); From cf31290175290c1a70d29e2aa9c1145751b4eb3d Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Wed, 12 Dec 2018 15:47:05 -0800 Subject: [PATCH 09/13] remove quote function --- src/PowerShell/PowerShellManager.cs | 30 ----------------------------- 1 file changed, 30 deletions(-) diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 53ee49f3..5430cb06 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -104,36 +104,6 @@ internal void InvokeProfile() } } - /// - /// Wrap a string in quotes to make it safe to use in scripts. - /// - /// The path to wrap in quotes. - /// The given path wrapped in quotes appropriately. - private static StringBuilder QuoteEscapeString(string path) - { - var sb = new StringBuilder(path.Length + 2); // Length of string plus two quotes - sb.Append('\''); - if (!path.Contains('\'')) - { - sb.Append(path); - } - else - { - foreach (char c in path) - { - if (c == '\'') - { - sb.Append("''"); - continue; - } - - sb.Append(c); - } - } - sb.Append('\''); - return sb; - } - /// /// Execution a function fired by a trigger or an activity function scheduled by an orchestration. /// From 41e3910cd5669843bf74ec1fc5b093fc35245877 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Wed, 12 Dec 2018 17:35:06 -0800 Subject: [PATCH 10/13] refactor to Function Loader --- src/FunctionLoader.cs | 37 ++++++++++++++++++- src/PowerShell/PowerShellManager.cs | 23 +++--------- src/RequestProcessor.cs | 26 ++++++++----- test/Unit/Function/FunctionLoaderTests.cs | 6 +-- .../Unit/PowerShell/PowerShellManagerTests.cs | 30 +++++++++++---- 5 files changed, 84 insertions(+), 38 deletions(-) diff --git a/src/FunctionLoader.cs b/src/FunctionLoader.cs index c9a819ba..fafca3ca 100644 --- a/src/FunctionLoader.cs +++ b/src/FunctionLoader.cs @@ -4,6 +4,10 @@ // using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + using Google.Protobuf.Collections; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; @@ -14,6 +18,8 @@ internal class FunctionLoader private readonly MapField _loadedFunctions = new MapField(); internal static string FunctionAppRootLocation { get; set; } + internal static string FunctionAppProfileLocation { get; set; } = null; + internal static string FunctionAppModulesLocation { get; set; } = null; internal AzFunctionInfo GetFunctionInfo(string functionId) { @@ -25,12 +31,41 @@ internal AzFunctionInfo GetFunctionInfo(string functionId) throw new InvalidOperationException($"Function with the ID '{functionId}' was not loaded."); } - internal void Load(FunctionLoadRequest request) + /// + /// Runs once per Function in a Function App. Loads the Function info into the Function Loader + /// + 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 + /// + internal static void SetupWellKnownPaths(string functionAppRootLocation) + { + FunctionLoader.FunctionAppRootLocation = functionAppRootLocation; + + var enumerationOptions = new EnumerationOptions { + MatchCasing = MatchCasing.CaseInsensitive + }; + // Find the profile.ps1 in the Function App root if it exists + List profiles = Directory.EnumerateFiles(functionAppRootLocation, "profile.ps1", enumerationOptions).ToList(); + if (profiles.Count() > 0) + { + FunctionLoader.FunctionAppProfileLocation = profiles[0]; + } + // Find the Modules directory in the Function App root if it exists + List modulePaths = Directory.EnumerateFiles(functionAppRootLocation, "Modules", enumerationOptions).ToList(); + if (modulePaths.Count() > 0) + { + FunctionLoader.FunctionAppModulesLocation = modulePaths[0]; + } } } diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 5430cb06..f5a3527d 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -8,9 +8,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; -using System.Linq; -using System.Security; -using System.Text; using Microsoft.Azure.Functions.PowerShellWorker.Utility; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; @@ -62,18 +59,10 @@ internal void InitializeRunspace() internal void InvokeProfile() { - string functionAppRootLocation = FunctionLoader.FunctionAppRootLocation; - List profiles = Directory.EnumerateFiles( - functionAppRootLocation, - "profile.ps1", - new EnumerationOptions { - MatchCasing = MatchCasing.CaseInsensitive - }) - .ToList(); - - if (profiles.Count() == 0) + string functionAppProfileLocation = FunctionLoader.FunctionAppProfileLocation; + if (functionAppProfileLocation == null) { - _logger.Log(LogLevel.Trace, $"No 'Profile.ps1' found at: {functionAppRootLocation}"); + _logger.Log(LogLevel.Trace, $"No 'Profile.ps1' found at: {FunctionLoader.FunctionAppRootLocation}"); return; } @@ -81,7 +70,7 @@ internal void InvokeProfile() { // Import-Module on a .ps1 file will evaluate the script in the global scope. _pwsh.AddCommand("Microsoft.PowerShell.Core\\Import-Module") - .AddParameter("Name", profiles[0]).AddParameter("PassThru", true) + .AddParameter("Name", functionAppProfileLocation).AddParameter("PassThru", true) .AddCommand("Remove-Module") .InvokeAndClearCommands(); } @@ -89,7 +78,7 @@ internal void InvokeProfile() { _logger.Log( LogLevel.Error, - $"Invoking the Profile had errors. See logs for details. Profile location: {functionAppRootLocation}", + $"Invoking the Profile had errors. See logs for details. Profile location: {functionAppProfileLocation}", e, isUserLog: true); throw; @@ -99,7 +88,7 @@ internal void InvokeProfile() { _logger.Log( LogLevel.Error, - $"Invoking the Profile had errors. See logs for details. Profile location: {functionAppRootLocation}", + $"Invoking the Profile had errors. See logs for details. Profile location: {functionAppProfileLocation}", isUserLog: true); } } diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index 7fc0472c..bf1cd616 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -102,24 +102,30 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) try { - // Try loading the metadata of the function - _functionLoader.Load(functionLoadRequest); - - // if we haven't yet, add the well-known Function App module path to the PSModulePath - // The location of this module path is in a folder called "Modules" in the root of the Function App. + // 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) { - FunctionLoader.FunctionAppRootLocation = Path.GetFullPath(Path.Combine(functionLoadRequest.Metadata.Directory, "..")); + // 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, ".."))); - // Prepend the Function App's 'Modules' folder to the PSModulePath - _powerShellManager.PrependToPSModulePath(Path.Combine(FunctionLoader.FunctionAppRootLocation, "Modules")); + if (FunctionLoader.FunctionAppModulesLocation != null) + { + // Prepend the Function App's 'Modules' folder to the PSModulePath + _powerShellManager.PrependToPSModulePath(FunctionLoader.FunctionAppModulesLocation); + } - // Since this is the first time we know where the location of the FunctionApp is, - // we can attempt to execute the Profile. _powerShellManager.InvokeProfile(); _initializedFunctionApp = true; } + + // Try loading the metadata of the function + _functionLoader.LoadFunction(functionLoadRequest); } catch (Exception e) { diff --git a/test/Unit/Function/FunctionLoaderTests.cs b/test/Unit/Function/FunctionLoaderTests.cs index b0e3a4c5..29c70ed3 100644 --- a/test/Unit/Function/FunctionLoaderTests.cs +++ b/test/Unit/Function/FunctionLoaderTests.cs @@ -42,7 +42,7 @@ public void TestFunctionLoaderGetFunc() }; var functionLoader = new FunctionLoader(); - functionLoader.Load(functionLoadRequest); + functionLoader.LoadFunction(functionLoadRequest); var funcInfo = functionLoader.GetFunctionInfo(functionId); @@ -81,7 +81,7 @@ public void TestFunctionLoaderGetFuncWithEntryPoint() }; var functionLoader = new FunctionLoader(); - functionLoader.Load(functionLoadRequest); + functionLoader.LoadFunction(functionLoadRequest); var funcInfo = functionLoader.GetFunctionInfo(functionId); @@ -120,7 +120,7 @@ public void TestFunctionLoaderGetInfo() }; var functionLoader = new FunctionLoader(); - functionLoader.Load(functionLoadRequest); + functionLoader.LoadFunction(functionLoadRequest); var funcInfo = functionLoader.GetFunctionInfo(functionId); diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 90a113c1..20ed9b0b 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -165,9 +165,11 @@ public void ProfileShouldWork() { var logger = new ConsoleLogger(); var manager = new PowerShellManager(logger); - FunctionLoader.FunctionAppRootLocation = System.IO.Path.Join( + + CleanupFunctionLoaderStaticPaths(); + FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( AppDomain.CurrentDomain.BaseDirectory, - "Unit/PowerShell/TestScripts/ProfileBasic"); + "Unit/PowerShell/TestScripts/ProfileBasic")); manager.InvokeProfile(); @@ -180,7 +182,9 @@ public void ProfileDoesNotExist() { var logger = new ConsoleLogger(); var manager = new PowerShellManager(logger); - FunctionLoader.FunctionAppRootLocation = AppDomain.CurrentDomain.BaseDirectory; + + CleanupFunctionLoaderStaticPaths(); + FunctionLoader.SetupWellKnownPaths(AppDomain.CurrentDomain.BaseDirectory); manager.InvokeProfile(); @@ -193,9 +197,11 @@ public void ProfileWithTerminatingError() { var logger = new ConsoleLogger(); var manager = new PowerShellManager(logger); - FunctionLoader.FunctionAppRootLocation = System.IO.Path.Join( + + CleanupFunctionLoaderStaticPaths(); + FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( AppDomain.CurrentDomain.BaseDirectory, - "Unit/PowerShell/TestScripts/ProfileWithTerminatingError"); + "Unit/PowerShell/TestScripts/ProfileWithTerminatingError")); Assert.Throws(() => manager.InvokeProfile()); Assert.Single(logger.FullLog); @@ -207,9 +213,11 @@ public void ProfileWithNonTerminatingError() { var logger = new ConsoleLogger(); var manager = new PowerShellManager(logger); - FunctionLoader.FunctionAppRootLocation = System.IO.Path.Join( + + CleanupFunctionLoaderStaticPaths(); + FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( AppDomain.CurrentDomain.BaseDirectory, - "Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError"); + "Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError")); manager.InvokeProfile(); @@ -217,5 +225,13 @@ public void ProfileWithNonTerminatingError() Assert.Equal("Error: ERROR: help me!", logger.FullLog[0]); Assert.Matches("Error: Invoking the Profile had errors. See logs for details. Profile location: ", logger.FullLog[1]); } + + // Helper function that sets all the well-known paths in the Function Loader back to null. + private void CleanupFunctionLoaderStaticPaths() + { + FunctionLoader.FunctionAppRootLocation = null; + FunctionLoader.FunctionAppProfileLocation = null; + FunctionLoader.FunctionAppModulesLocation = null; + } } } From d8534cd601c355634d75ccfedf1b2b1a86745741 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Wed, 12 Dec 2018 18:00:40 -0800 Subject: [PATCH 11/13] Modules path just use Path.Join --- src/FunctionLoader.cs | 15 ++++----------- src/RequestProcessor.cs | 6 +----- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/src/FunctionLoader.cs b/src/FunctionLoader.cs index fafca3ca..2503235a 100644 --- a/src/FunctionLoader.cs +++ b/src/FunctionLoader.cs @@ -49,23 +49,16 @@ internal void LoadFunction(FunctionLoadRequest request) internal static void SetupWellKnownPaths(string functionAppRootLocation) { FunctionLoader.FunctionAppRootLocation = functionAppRootLocation; + FunctionLoader.FunctionAppModulesLocation = Path.Combine(functionAppRootLocation, "Modules"); - var enumerationOptions = new EnumerationOptions { - MatchCasing = MatchCasing.CaseInsensitive - }; // Find the profile.ps1 in the Function App root if it exists - List profiles = Directory.EnumerateFiles(functionAppRootLocation, "profile.ps1", enumerationOptions).ToList(); + List profiles = Directory.EnumerateFiles(functionAppRootLocation, "profile.ps1", new EnumerationOptions { + MatchCasing = MatchCasing.CaseInsensitive + }).ToList(); if (profiles.Count() > 0) { FunctionLoader.FunctionAppProfileLocation = profiles[0]; } - - // Find the Modules directory in the Function App root if it exists - List modulePaths = Directory.EnumerateFiles(functionAppRootLocation, "Modules", enumerationOptions).ToList(); - if (modulePaths.Count() > 0) - { - FunctionLoader.FunctionAppModulesLocation = modulePaths[0]; - } } } diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index bf1cd616..289c283e 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -113,11 +113,7 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) // one directory from the _Function_ directory we are given FunctionLoader.SetupWellKnownPaths(Path.GetFullPath(Path.Combine(functionLoadRequest.Metadata.Directory, ".."))); - if (FunctionLoader.FunctionAppModulesLocation != null) - { - // Prepend the Function App's 'Modules' folder to the PSModulePath - _powerShellManager.PrependToPSModulePath(FunctionLoader.FunctionAppModulesLocation); - } + _powerShellManager.PrependToPSModulePath(FunctionLoader.FunctionAppModulesLocation); _powerShellManager.InvokeProfile(); From 20783bbd5eee58ac0d9d18df4bce930c829ab8f4 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Wed, 12 Dec 2018 19:33:27 -0800 Subject: [PATCH 12/13] address more feedback --- src/FunctionLoader.cs | 12 ++-- src/PowerShell/PowerShellManager.cs | 13 ++-- src/RequestProcessor.cs | 2 +- .../Unit/PowerShell/PowerShellManagerTests.cs | 62 +++++++++---------- 4 files changed, 45 insertions(+), 44 deletions(-) diff --git a/src/FunctionLoader.cs b/src/FunctionLoader.cs index 2503235a..4b8e4228 100644 --- a/src/FunctionLoader.cs +++ b/src/FunctionLoader.cs @@ -17,9 +17,9 @@ internal class FunctionLoader { private readonly MapField _loadedFunctions = new MapField(); - internal static string FunctionAppRootLocation { get; set; } - internal static string FunctionAppProfileLocation { get; set; } = null; - internal static string FunctionAppModulesLocation { get; set; } = null; + internal static string FunctionAppRootPath { get; set; } + internal static string FunctionAppProfilePath { get; set; } = null; + internal static string FunctionAppModulesPath { get; set; } = null; internal AzFunctionInfo GetFunctionInfo(string functionId) { @@ -48,8 +48,8 @@ internal void LoadFunction(FunctionLoadRequest request) /// internal static void SetupWellKnownPaths(string functionAppRootLocation) { - FunctionLoader.FunctionAppRootLocation = functionAppRootLocation; - FunctionLoader.FunctionAppModulesLocation = Path.Combine(functionAppRootLocation, "Modules"); + 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 { @@ -57,7 +57,7 @@ internal static void SetupWellKnownPaths(string functionAppRootLocation) }).ToList(); if (profiles.Count() > 0) { - FunctionLoader.FunctionAppProfileLocation = profiles[0]; + FunctionLoader.FunctionAppProfilePath = profiles[0]; } } } diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index f5a3527d..0cec7359 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -59,10 +59,10 @@ internal void InitializeRunspace() internal void InvokeProfile() { - string functionAppProfileLocation = FunctionLoader.FunctionAppProfileLocation; + string functionAppProfileLocation = FunctionLoader.FunctionAppProfilePath; if (functionAppProfileLocation == null) { - _logger.Log(LogLevel.Trace, $"No 'Profile.ps1' found at: {FunctionLoader.FunctionAppRootLocation}"); + _logger.Log(LogLevel.Trace, $"No 'profile.ps1' is found at the FunctionApp root folder: {FunctionLoader.FunctionAppRootPath}"); return; } @@ -71,14 +71,15 @@ internal void InvokeProfile() // 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("Remove-Module") + .AddCommand("Microsoft.PowerShell.Core\\Remove-Module") + .AddParameter("Force", true).AddParameter("ErrorAction", "SilentlyContinue") .InvokeAndClearCommands(); } - catch (CmdletInvocationException e) + catch (Exception e) { _logger.Log( LogLevel.Error, - $"Invoking the Profile had errors. See logs for details. Profile location: {functionAppProfileLocation}", + $"Fail to run profile.ps1. See logs for detailed errors. Profile location: {functionAppProfileLocation}", e, isUserLog: true); throw; @@ -88,7 +89,7 @@ internal void InvokeProfile() { _logger.Log( LogLevel.Error, - $"Invoking the Profile had errors. See logs for details. Profile location: {functionAppProfileLocation}", + $"Fail to run profile.ps1. See logs for detailed errors. Profile location: {functionAppProfileLocation}", isUserLog: true); } } diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index 289c283e..aef33862 100644 --- a/src/RequestProcessor.cs +++ b/src/RequestProcessor.cs @@ -113,7 +113,7 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request) // one directory from the _Function_ directory we are given FunctionLoader.SetupWellKnownPaths(Path.GetFullPath(Path.Combine(functionLoadRequest.Metadata.Directory, ".."))); - _powerShellManager.PrependToPSModulePath(FunctionLoader.FunctionAppModulesLocation); + _powerShellManager.PrependToPSModulePath(FunctionLoader.FunctionAppModulesPath); _powerShellManager.InvokeProfile(); diff --git a/test/Unit/PowerShell/PowerShellManagerTests.cs b/test/Unit/PowerShell/PowerShellManagerTests.cs index 20ed9b0b..6269c0ae 100644 --- a/test/Unit/PowerShell/PowerShellManagerTests.cs +++ b/test/Unit/PowerShell/PowerShellManagerTests.cs @@ -18,6 +18,10 @@ 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, @@ -128,12 +132,10 @@ public void FunctionShouldCleanupVariableTable() [Fact] public void PrependingToPSModulePathShouldWork() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); var data = "/some/unknown/directory"; string modulePathBefore = Environment.GetEnvironmentVariable("PSModulePath"); - manager.PrependToPSModulePath(data); + defaultTestManager.PrependToPSModulePath(data); try { // the data path should be ahead of anything else @@ -149,89 +151,87 @@ public void PrependingToPSModulePathShouldWork() [Fact] public void RegisterAndUnregisterFunctionMetadataShouldWork() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); var functionInfo = GetAzFunctionInfo("dummy-path", string.Empty); Assert.Empty(FunctionMetadata.OutputBindingCache); - manager.RegisterFunctionMetadata(functionInfo); + defaultTestManager.RegisterFunctionMetadata(functionInfo); Assert.Single(FunctionMetadata.OutputBindingCache); - manager.UnregisterFunctionMetadata(); + defaultTestManager.UnregisterFunctionMetadata(); Assert.Empty(FunctionMetadata.OutputBindingCache); } [Fact] public void ProfileShouldWork() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); + //initialize fresh log + defaultTestLogger.FullLog.Clear(); CleanupFunctionLoaderStaticPaths(); FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( AppDomain.CurrentDomain.BaseDirectory, "Unit/PowerShell/TestScripts/ProfileBasic")); - manager.InvokeProfile(); + defaultTestManager.InvokeProfile(); - Assert.Single(logger.FullLog); - Assert.Equal("Information: INFORMATION: Hello PROFILE", logger.FullLog[0]); + Assert.Single(defaultTestLogger.FullLog); + Assert.Equal("Information: INFORMATION: Hello PROFILE", defaultTestLogger.FullLog[0]); } [Fact] public void ProfileDoesNotExist() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); + //initialize fresh log + defaultTestLogger.FullLog.Clear(); CleanupFunctionLoaderStaticPaths(); FunctionLoader.SetupWellKnownPaths(AppDomain.CurrentDomain.BaseDirectory); - manager.InvokeProfile(); + defaultTestManager.InvokeProfile(); - Assert.Single(logger.FullLog); - Assert.Matches("Trace: No 'Profile.ps1' found at: ", logger.FullLog[0]); + Assert.Single(defaultTestLogger.FullLog); + Assert.Matches("Trace: No 'profile.ps1' is found at the FunctionApp root folder: ", defaultTestLogger.FullLog[0]); } [Fact] public void ProfileWithTerminatingError() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); + //initialize fresh log + defaultTestLogger.FullLog.Clear(); CleanupFunctionLoaderStaticPaths(); FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( AppDomain.CurrentDomain.BaseDirectory, "Unit/PowerShell/TestScripts/ProfileWithTerminatingError")); - Assert.Throws(() => manager.InvokeProfile()); - Assert.Single(logger.FullLog); - Assert.Matches("Error: Invoking the Profile had errors. See logs for details. Profile location: ", logger.FullLog[0]); + Assert.Throws(() => defaultTestManager.InvokeProfile()); + Assert.Single(defaultTestLogger.FullLog); + Assert.Matches("Error: Fail to run profile.ps1. See logs for detailed errors. Profile location: ", defaultTestLogger.FullLog[0]); } [Fact] public void ProfileWithNonTerminatingError() { - var logger = new ConsoleLogger(); - var manager = new PowerShellManager(logger); + //initialize fresh log + defaultTestLogger.FullLog.Clear(); CleanupFunctionLoaderStaticPaths(); FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( AppDomain.CurrentDomain.BaseDirectory, "Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError")); - manager.InvokeProfile(); + defaultTestManager.InvokeProfile(); - Assert.Equal(2, logger.FullLog.Count); - Assert.Equal("Error: ERROR: help me!", logger.FullLog[0]); - Assert.Matches("Error: Invoking the Profile had errors. See logs for details. Profile location: ", logger.FullLog[1]); + 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]); } // Helper function that sets all the well-known paths in the Function Loader back to null. private void CleanupFunctionLoaderStaticPaths() { - FunctionLoader.FunctionAppRootLocation = null; - FunctionLoader.FunctionAppProfileLocation = null; - FunctionLoader.FunctionAppModulesLocation = null; + FunctionLoader.FunctionAppRootPath = null; + FunctionLoader.FunctionAppProfilePath = null; + FunctionLoader.FunctionAppModulesPath = null; } } } From 488c57551dd0d89fa11f3e184392f2e745a1e1c2 Mon Sep 17 00:00:00 2001 From: Tyler Leonhardt Date: Wed, 12 Dec 2018 19:34:48 -0800 Subject: [PATCH 13/13] new example profile --- examples/PSCoreApp/Profile.ps1 | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/examples/PSCoreApp/Profile.ps1 b/examples/PSCoreApp/Profile.ps1 index de017991..f7a21ccf 100644 --- a/examples/PSCoreApp/Profile.ps1 +++ b/examples/PSCoreApp/Profile.ps1 @@ -4,17 +4,14 @@ # Example usecases for a profile.ps1: -# Authenticating with Azure PowerShell using MSI -# $tokenAuthURI = $env:MSI_ENDPOINT + "?resource=https://management.azure.com&api-version=2017-09-01" -# $tokenResponse = Invoke-RestMethod -Method Get -Headers @{"Secret"="$env:MSI_SECRET"} -Uri $tokenAuthURI -# Connect-AzAccount -AccessToken $tokenResponse.access_token -AccountId $env:WEBSITE_SITE_NAME +<# +# Authenticate with Azure PowerShell using MSI. +$tokenAuthURI = $env:MSI_ENDPOINT + "?resource=https://management.azure.com&api-version=2017-09-01" +$tokenResponse = Invoke-RestMethod -Method Get -Headers @{"Secret"="$env:MSI_SECRET"} -Uri $tokenAuthURI +Connect-AzAccount -AccessToken $tokenResponse.access_token -AccountId $env:WEBSITE_SITE_NAME -# Enabling legacy AzureRm alias in Azure PowerShell -# Enable-AzureRmAlias +# Enable legacy AzureRm alias in Azure PowerShell. +Enable-AzureRmAlias +#> -# Defining a function that you can refernce in any of your PowerShell functions: -# function Get-MyData { -# @{ -# Foo = 5 -# } -# } +# You can also define functions or aliases that can be referenced in any of your PowerShell functions.