Skip to content

Profile.ps1 support and removal of auto-auth to azure #114

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Dec 13, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
]
}
17 changes: 17 additions & 0 deletions examples/PSCoreApp/Profile.ps1
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 32 additions & 1 deletion src/FunctionLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -13,6 +17,10 @@ internal class FunctionLoader
{
private readonly MapField<string, AzFunctionInfo> _loadedFunctions = new MapField<string, AzFunctionInfo>();

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))
Expand All @@ -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)
/// <summary>
/// Runs once per Function in a Function App. Loads the Function info into the Function Loader
/// </summary>
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));
}

/// <summary>
/// Sets up well-known paths like the Function App root,
/// the Function App 'Modules' folder,
/// and the Function App's profile.ps1
/// </summary>
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<string> profiles = Directory.EnumerateFiles(functionAppRootLocation, "profile.ps1", new EnumerationOptions {
MatchCasing = MatchCasing.CaseInsensitive
}).ToList();
if (profiles.Count() > 0)
{
FunctionLoader.FunctionAppProfilePath = profiles[0];
}
}
}

internal enum AzFunctionType
Expand Down
101 changes: 36 additions & 65 deletions src/PowerShell/PowerShellManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PSModuleInfo> azProfile = _pwsh.AddCommand("Get-Module")
.AddParameter("ListAvailable")
.AddParameter("Name", "Az.Profile")
.InvokeAndClearCommands<PSModuleInfo>();
// 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<PSObject> 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<PSObject>();

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"));
}

/// <summary>
Expand Down
36 changes: 20 additions & 16 deletions src/RequestProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down Expand Up @@ -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)
{
Expand Down
14 changes: 9 additions & 5 deletions test/E2E/HttpTrigger.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 } |
Expand Down
3 changes: 3 additions & 0 deletions test/E2E/TestFunctionApp/Profile.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
function Get-ProfileString {
"PROFILE"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"disabled": false,
"bindings": [
{
"authLevel": "function",
"type": "httpTrigger",
"direction": "in",
"name": "req",
"methods": [
"get",
"post"
]
},
{
"type": "http",
"direction": "out",
"name": "res"
}
]
}
12 changes: 12 additions & 0 deletions test/E2E/TestFunctionApp/TestBasicHttpTriggerWithProfile/run.ps1
Original file line number Diff line number Diff line change
@@ -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)
})
1 change: 1 addition & 0 deletions test/E2E/setupE2Etests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down
9 changes: 9 additions & 0 deletions test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,14 @@
<Content Include="Unit\PowerShell\TestScripts\testFunctionCleanup.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Unit\PowerShell\TestScripts\ProfileBasic\profile.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Unit\PowerShell\TestScripts\ProfileWithTerminatingError\profile.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Unit\PowerShell\TestScripts\ProfileWithNonTerminatingError\Profile.ps1">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
Loading