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/examples/PSCoreApp/Profile.ps1 b/examples/PSCoreApp/Profile.ps1 new file mode 100644 index 00000000..f7a21ccf --- /dev/null +++ b/examples/PSCoreApp/Profile.ps1 @@ -0,0 +1,17 @@ +# 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: + +<# +# 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 + +# Enable legacy AzureRm alias in Azure PowerShell. +Enable-AzureRmAlias +#> + +# You can also define functions or aliases that can be referenced in any of your PowerShell functions. diff --git a/src/FunctionLoader.cs b/src/FunctionLoader.cs index 795b5d26..4b8e4228 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; @@ -13,6 +17,10 @@ internal class FunctionLoader { private readonly MapField _loadedFunctions = new MapField(); + 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) { if (_loadedFunctions.TryGetValue(functionId, out AzFunctionInfo funcInfo)) @@ -23,12 +31,35 @@ 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.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]; + } + } } internal enum AzFunctionType diff --git a/src/PowerShell/PowerShellManager.cs b/src/PowerShell/PowerShellManager.cs index 454108a2..0cec7359 100644 --- a/src/PowerShell/PowerShellManager.cs +++ b/src/PowerShell/PowerShellManager.cs @@ -8,7 +8,6 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; -using System.Security; using Microsoft.Azure.Functions.PowerShellWorker.Utility; using Microsoft.Azure.WebJobs.Script.Grpc.Messages; @@ -48,79 +47,51 @@ internal PowerShellManager(ILogger logger) _pwsh.Streams.Warning.DataAdding += streamHandler.WarningDataAdding; } - internal void AuthenticateToAzure() + internal void InitializeRunspace() { - // Check if Az.Profile is available - Collection azProfile = _pwsh.AddCommand("Get-Module") - .AddParameter("ListAvailable") - .AddParameter("Name", "Az.Profile") - .InvokeAndClearCommands(); + // Add HttpResponseContext namespace so users can reference + // HttpResponseContext without needing to specify the full namespace + _pwsh.AddScript($"using namespace {typeof(HttpResponseContext).Namespace}").InvokeAndClearCommands(); - if (azProfile.Count == 0) + // Set the PSModulePath + Environment.SetEnvironmentVariable("PSModulePath", Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules")); + } + + internal void InvokeProfile() + { + string functionAppProfileLocation = FunctionLoader.FunctionAppProfilePath; + if (functionAppProfileLocation == null) { - _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' is found at the FunctionApp root folder: {FunctionLoader.FunctionAppRootPath}"); 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)) + + try { - // 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(); - - if(_pwsh.HadErrors) - { - _logger.Log(LogLevel.Warning, "Failed to Authenticate to Azure via MSI. Check the logs for the errors generated."); - } - else - { - // We have successfully authenticated to Azure so we can return out. - using (ExecutionTimer.Start(_logger, "Authentication to Azure")) - { - _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; - } - } - } + // 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(); } - else + catch (Exception e) { - _logger.Log(LogLevel.Trace, "Skip authentication to Azure via MSI. Environment variables for authenticating to Azure are not present."); + _logger.Log( + LogLevel.Error, + $"Fail to run profile.ps1. See logs for detailed errors. Profile location: {functionAppProfileLocation}", + e, + isUserLog: true); + throw; + } + + if (_pwsh.HadErrors) + { + _logger.Log( + LogLevel.Error, + $"Fail to run profile.ps1. See logs for detailed errors. Profile location: {functionAppProfileLocation}", + isUserLog: true); } - } - - 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")); } /// diff --git a/src/RequestProcessor.cs b/src/RequestProcessor.cs index 0ac46b33..aef33862 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) { @@ -101,23 +102,26 @@ 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. - if (!_prependedPath) + // 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) { - string functionAppModulesPath = Path.GetFullPath( - Path.Combine(functionLoadRequest.Metadata.Directory, "..", "Modules")); - _powerShellManager.PrependToPSModulePath(functionAppModulesPath); + // 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); - // 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(); + _powerShellManager.InvokeProfile(); - _prependedPath = true; + _initializedFunctionApp = true; } + + // Try loading the metadata of the function + _functionLoader.LoadFunction(functionLoadRequest); } catch (Exception e) { 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..." diff --git a/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj b/test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj index 95ead50b..77281ee6 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/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 7a5676fc..6269c0ae 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; @@ -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,15 +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() + { + //initialize fresh log + defaultTestLogger.FullLog.Clear(); + + CleanupFunctionLoaderStaticPaths(); + FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( + AppDomain.CurrentDomain.BaseDirectory, + "Unit/PowerShell/TestScripts/ProfileBasic")); + + defaultTestManager.InvokeProfile(); + + Assert.Single(defaultTestLogger.FullLog); + Assert.Equal("Information: INFORMATION: Hello PROFILE", defaultTestLogger.FullLog[0]); + } + + [Fact] + public void ProfileDoesNotExist() + { + //initialize fresh log + defaultTestLogger.FullLog.Clear(); + + CleanupFunctionLoaderStaticPaths(); + FunctionLoader.SetupWellKnownPaths(AppDomain.CurrentDomain.BaseDirectory); + + defaultTestManager.InvokeProfile(); + + Assert.Single(defaultTestLogger.FullLog); + Assert.Matches("Trace: No 'profile.ps1' is found at the FunctionApp root folder: ", defaultTestLogger.FullLog[0]); + } + + [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.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() + { + //initialize fresh log + defaultTestLogger.FullLog.Clear(); + + CleanupFunctionLoaderStaticPaths(); + FunctionLoader.SetupWellKnownPaths(System.IO.Path.Join( + AppDomain.CurrentDomain.BaseDirectory, + "Unit/PowerShell/TestScripts/ProfileWithNonTerminatingError")); + + defaultTestManager.InvokeProfile(); + + 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.FunctionAppRootPath = null; + FunctionLoader.FunctionAppProfilePath = null; + FunctionLoader.FunctionAppModulesPath = null; + } } } 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!"