Skip to content

Commit eca0b1e

Browse files
TylerLeonhardtdaxian-dbw
authored andcommitted
Support profile.ps1 and remove auto-auth to azure (#114)
1 parent 357c3c0 commit eca0b1e

File tree

16 files changed

+270
-102
lines changed

16 files changed

+270
-102
lines changed

.vscode/tasks.json

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,32 @@
33
"tasks": [
44
{
55
"label": "build",
6-
"command": "dotnet",
6+
"command": "pwsh",
77
"type": "process",
88
"args": [
9-
"build",
10-
"${workspaceFolder}/psworker.csproj"
9+
"-c",
10+
"${workspaceFolder}/build.ps1"
1111
],
12-
"problemMatcher": "$msCompile"
12+
"problemMatcher": "$msCompile",
13+
"group": {
14+
"kind": "build",
15+
"isDefault": true
16+
}
17+
},
18+
{
19+
"label": "test",
20+
"command": "pwsh",
21+
"type": "process",
22+
"args": [
23+
"-c",
24+
"${workspaceFolder}/build.ps1",
25+
"-Test"
26+
],
27+
"problemMatcher": "$msCompile",
28+
"group": {
29+
"kind": "test",
30+
"isDefault": true
31+
}
1332
}
1433
]
1534
}

examples/PSCoreApp/Profile.ps1

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# This script will be run on every COLD START of the Function App
2+
# You can define helper functions, run commands, or specify environment variables
3+
# NOTE: any variables defined that are not environment variables will get reset after the first execution
4+
5+
# Example usecases for a profile.ps1:
6+
7+
<#
8+
# Authenticate with Azure PowerShell using MSI.
9+
$tokenAuthURI = $env:MSI_ENDPOINT + "?resource=https://management.azure.com&api-version=2017-09-01"
10+
$tokenResponse = Invoke-RestMethod -Method Get -Headers @{"Secret"="$env:MSI_SECRET"} -Uri $tokenAuthURI
11+
Connect-AzAccount -AccessToken $tokenResponse.access_token -AccountId $env:WEBSITE_SITE_NAME
12+
13+
# Enable legacy AzureRm alias in Azure PowerShell.
14+
Enable-AzureRmAlias
15+
#>
16+
17+
# You can also define functions or aliases that can be referenced in any of your PowerShell functions.

src/FunctionLoader.cs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
//
55

66
using System;
7+
using System.Collections.Generic;
8+
using System.IO;
9+
using System.Linq;
10+
711
using Google.Protobuf.Collections;
812
using Microsoft.Azure.WebJobs.Script.Grpc.Messages;
913

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

20+
internal static string FunctionAppRootPath { get; set; }
21+
internal static string FunctionAppProfilePath { get; set; } = null;
22+
internal static string FunctionAppModulesPath { get; set; } = null;
23+
1624
internal AzFunctionInfo GetFunctionInfo(string functionId)
1725
{
1826
if (_loadedFunctions.TryGetValue(functionId, out AzFunctionInfo funcInfo))
@@ -23,12 +31,35 @@ internal AzFunctionInfo GetFunctionInfo(string functionId)
2331
throw new InvalidOperationException($"Function with the ID '{functionId}' was not loaded.");
2432
}
2533

26-
internal void Load(FunctionLoadRequest request)
34+
/// <summary>
35+
/// Runs once per Function in a Function App. Loads the Function info into the Function Loader
36+
/// </summary>
37+
internal void LoadFunction(FunctionLoadRequest request)
2738
{
2839
// TODO: catch "load" issues at "func start" time.
2940
// ex. Script doesn't exist, entry point doesn't exist
3041
_loadedFunctions.Add(request.FunctionId, new AzFunctionInfo(request.Metadata));
3142
}
43+
44+
/// <summary>
45+
/// Sets up well-known paths like the Function App root,
46+
/// the Function App 'Modules' folder,
47+
/// and the Function App's profile.ps1
48+
/// </summary>
49+
internal static void SetupWellKnownPaths(string functionAppRootLocation)
50+
{
51+
FunctionLoader.FunctionAppRootPath = functionAppRootLocation;
52+
FunctionLoader.FunctionAppModulesPath = Path.Combine(functionAppRootLocation, "Modules");
53+
54+
// Find the profile.ps1 in the Function App root if it exists
55+
List<string> profiles = Directory.EnumerateFiles(functionAppRootLocation, "profile.ps1", new EnumerationOptions {
56+
MatchCasing = MatchCasing.CaseInsensitive
57+
}).ToList();
58+
if (profiles.Count() > 0)
59+
{
60+
FunctionLoader.FunctionAppProfilePath = profiles[0];
61+
}
62+
}
3263
}
3364

3465
internal enum AzFunctionType

src/PowerShell/PowerShellManager.cs

Lines changed: 36 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
using System.Collections.Generic;
99
using System.Collections.ObjectModel;
1010
using System.IO;
11-
using System.Security;
1211

1312
using Microsoft.Azure.Functions.PowerShellWorker.Utility;
1413
using Microsoft.Azure.WebJobs.Script.Grpc.Messages;
@@ -48,79 +47,51 @@ internal PowerShellManager(ILogger logger)
4847
_pwsh.Streams.Warning.DataAdding += streamHandler.WarningDataAdding;
4948
}
5049

51-
internal void AuthenticateToAzure()
50+
internal void InitializeRunspace()
5251
{
53-
// Check if Az.Profile is available
54-
Collection<PSModuleInfo> azProfile = _pwsh.AddCommand("Get-Module")
55-
.AddParameter("ListAvailable")
56-
.AddParameter("Name", "Az.Profile")
57-
.InvokeAndClearCommands<PSModuleInfo>();
52+
// Add HttpResponseContext namespace so users can reference
53+
// HttpResponseContext without needing to specify the full namespace
54+
_pwsh.AddScript($"using namespace {typeof(HttpResponseContext).Namespace}").InvokeAndClearCommands();
5855

59-
if (azProfile.Count == 0)
56+
// Set the PSModulePath
57+
Environment.SetEnvironmentVariable("PSModulePath", Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules"));
58+
}
59+
60+
internal void InvokeProfile()
61+
{
62+
string functionAppProfileLocation = FunctionLoader.FunctionAppProfilePath;
63+
if (functionAppProfileLocation == null)
6064
{
61-
_logger.Log(LogLevel.Trace, "Required module to automatically authenticate with Azure `Az.Profile` was not found in the PSModulePath.");
65+
_logger.Log(LogLevel.Trace, $"No 'profile.ps1' is found at the FunctionApp root folder: {FunctionLoader.FunctionAppRootPath}");
6266
return;
6367
}
64-
65-
// Try to authenticate to Azure using MSI
66-
string msiSecret = Environment.GetEnvironmentVariable("MSI_SECRET");
67-
string msiEndpoint = Environment.GetEnvironmentVariable("MSI_ENDPOINT");
68-
string accountId = Environment.GetEnvironmentVariable("WEBSITE_SITE_NAME");
69-
70-
if (!string.IsNullOrEmpty(msiSecret) &&
71-
!string.IsNullOrEmpty(msiEndpoint) &&
72-
!string.IsNullOrEmpty(accountId))
68+
69+
try
7370
{
74-
// NOTE: There is a limitation in Azure PowerShell that prevents us from using the parameter set:
75-
// Connect-AzAccount -MSI or Connect-AzAccount -Identity
76-
// see this GitHub issue https://github.com/Azure/azure-powershell/issues/7876
77-
// As a workaround, we can all an API endpoint on the MSI_ENDPOINT to get an AccessToken and use that to authenticate
78-
Collection<PSObject> response = _pwsh.AddCommand("Microsoft.PowerShell.Utility\\Invoke-RestMethod")
79-
.AddParameter("Method", "Get")
80-
.AddParameter("Headers", new Hashtable {{ "Secret", msiSecret }})
81-
.AddParameter("Uri", $"{msiEndpoint}?resource=https://management.azure.com&api-version=2017-09-01")
82-
.InvokeAndClearCommands<PSObject>();
83-
84-
if(_pwsh.HadErrors)
85-
{
86-
_logger.Log(LogLevel.Warning, "Failed to Authenticate to Azure via MSI. Check the logs for the errors generated.");
87-
}
88-
else
89-
{
90-
// We have successfully authenticated to Azure so we can return out.
91-
using (ExecutionTimer.Start(_logger, "Authentication to Azure"))
92-
{
93-
_pwsh.AddCommand("Az.Profile\\Connect-AzAccount")
94-
.AddParameter("AccessToken", response[0].Properties["access_token"].Value)
95-
.AddParameter("AccountId", accountId)
96-
.InvokeAndClearCommands();
97-
98-
if(_pwsh.HadErrors)
99-
{
100-
_logger.Log(LogLevel.Warning, "Failed to Authenticate to Azure. Check the logs for the errors generated.");
101-
}
102-
else
103-
{
104-
// We've successfully authenticated to Azure so we can return
105-
return;
106-
}
107-
}
108-
}
71+
// Import-Module on a .ps1 file will evaluate the script in the global scope.
72+
_pwsh.AddCommand("Microsoft.PowerShell.Core\\Import-Module")
73+
.AddParameter("Name", functionAppProfileLocation).AddParameter("PassThru", true)
74+
.AddCommand("Microsoft.PowerShell.Core\\Remove-Module")
75+
.AddParameter("Force", true).AddParameter("ErrorAction", "SilentlyContinue")
76+
.InvokeAndClearCommands();
10977
}
110-
else
78+
catch (Exception e)
11179
{
112-
_logger.Log(LogLevel.Trace, "Skip authentication to Azure via MSI. Environment variables for authenticating to Azure are not present.");
80+
_logger.Log(
81+
LogLevel.Error,
82+
$"Fail to run profile.ps1. See logs for detailed errors. Profile location: {functionAppProfileLocation}",
83+
e,
84+
isUserLog: true);
85+
throw;
86+
}
87+
88+
if (_pwsh.HadErrors)
89+
{
90+
_logger.Log(
91+
LogLevel.Error,
92+
$"Fail to run profile.ps1. See logs for detailed errors. Profile location: {functionAppProfileLocation}",
93+
isUserLog: true);
11394
}
114-
}
115-
116-
internal void InitializeRunspace()
117-
{
118-
// Add HttpResponseContext namespace so users can reference
119-
// HttpResponseContext without needing to specify the full namespace
120-
_pwsh.AddScript($"using namespace {typeof(HttpResponseContext).Namespace}").InvokeAndClearCommands();
121-
122-
// Set the PSModulePath
123-
Environment.SetEnvironmentVariable("PSModulePath", Path.Join(AppDomain.CurrentDomain.BaseDirectory, "Modules"));
12495
}
12596

12697
/// <summary>

src/RequestProcessor.cs

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ internal class RequestProcessor
2323
private readonly MessagingStream _msgStream;
2424
private readonly PowerShellManager _powerShellManager;
2525

26-
// used to determine if we have already added the Function App's 'Modules'
27-
// folder to the PSModulePath
28-
private bool _prependedPath;
26+
// This is somewhat of a workaround for the fact that the WorkerInitialize message does
27+
// not contain the file path of the Function App. Instead, we use this bool during the
28+
// FunctionLoad message to initialize the Function App since we have the path.
29+
private bool _initializedFunctionApp;
2930

3031
internal RequestProcessor(MessagingStream msgStream)
3132
{
@@ -101,23 +102,26 @@ internal StreamingMessage ProcessFunctionLoadRequest(StreamingMessage request)
101102

102103
try
103104
{
104-
// Try loading the metadata of the function
105-
_functionLoader.Load(functionLoadRequest);
106-
107-
// if we haven't yet, add the well-known Function App module path to the PSModulePath
108-
// The location of this module path is in a folder called "Modules" in the root of the Function App.
109-
if (!_prependedPath)
105+
// This is the first opportunity we have to obtain the location of the Function App on the file system
106+
// so we run some additional setup including:
107+
// * Storing some well-known paths in the Function Loader
108+
// * Prepending the Function App 'Modules' path
109+
// * Invoking the Function App's profile.ps1
110+
if (!_initializedFunctionApp)
110111
{
111-
string functionAppModulesPath = Path.GetFullPath(
112-
Path.Combine(functionLoadRequest.Metadata.Directory, "..", "Modules"));
113-
_powerShellManager.PrependToPSModulePath(functionAppModulesPath);
112+
// We obtain the Function App root path by navigating up
113+
// one directory from the _Function_ directory we are given
114+
FunctionLoader.SetupWellKnownPaths(Path.GetFullPath(Path.Combine(functionLoadRequest.Metadata.Directory, "..")));
115+
116+
_powerShellManager.PrependToPSModulePath(FunctionLoader.FunctionAppModulesPath);
114117

115-
// Since this is the first time we know where the location of the FunctionApp is,
116-
// we can attempt to authenticate to Azure at this time.
117-
_powerShellManager.AuthenticateToAzure();
118+
_powerShellManager.InvokeProfile();
118119

119-
_prependedPath = true;
120+
_initializedFunctionApp = true;
120121
}
122+
123+
// Try loading the metadata of the function
124+
_functionLoader.LoadFunction(functionLoadRequest);
121125
}
122126
catch (Exception e)
123127
{

test/E2E/HttpTrigger.Tests.ps1

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ Describe 'HttpTrigger Tests' {
2424
@{
2525
FunctionName = 'TestBasicHttpTriggerWithTriggerMetadata'
2626
ExpectedContent = 'Hello Atlas'
27+
},
28+
@{
29+
FunctionName = 'TestBasicHttpTriggerWithProfile'
30+
ExpectedContent = 'PROFILE'
2731
}
2832
) {
29-
param ($ExpectedContent)
33+
param ($FunctionName, $ExpectedContent)
3034

31-
$res = Invoke-WebRequest "$FUNCTIONS_BASE_URL/api/TestBasicHttpTrigger?Name=Atlas"
35+
$res = Invoke-WebRequest "$FUNCTIONS_BASE_URL/api/$($FunctionName)?Name=Atlas"
3236

3337
$res.StatusCode | Should -Be ([HttpStatusCode]::Accepted)
3438
$res.Content | Should -Be $ExpectedContent
@@ -50,12 +54,12 @@ Describe 'HttpTrigger Tests' {
5054
FunctionName = 'TestBasicHttpTriggerWithTriggerMetadata'
5155
}
5256
) {
53-
param ($InputNameData)
57+
param ($FunctionName, $InputNameData)
5458

5559
if (Test-Path 'variable:InputNameData') {
56-
$url = "$FUNCTIONS_BASE_URL/api/TestBasicHttpTrigger?Name=$InputNameData"
60+
$url = "$FUNCTIONS_BASE_URL/api/$($FunctionName)?Name=$InputNameData"
5761
} else {
58-
$url = "$FUNCTIONS_BASE_URL/api/TestBasicHttpTrigger"
62+
$url = "$FUNCTIONS_BASE_URL/api/$($FunctionName)"
5963
}
6064

6165
$res = { invoke-webrequest $url } |

test/E2E/TestFunctionApp/Profile.ps1

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
function Get-ProfileString {
2+
"PROFILE"
3+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"disabled": false,
3+
"bindings": [
4+
{
5+
"authLevel": "function",
6+
"type": "httpTrigger",
7+
"direction": "in",
8+
"name": "req",
9+
"methods": [
10+
"get",
11+
"post"
12+
]
13+
},
14+
{
15+
"type": "http",
16+
"direction": "out",
17+
"name": "res"
18+
}
19+
]
20+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#
2+
# Copyright (c) Microsoft. All rights reserved.
3+
# Licensed under the MIT license. See LICENSE file in the project root for full license information.
4+
#
5+
6+
# Input bindings are passed in via param block.
7+
param($req)
8+
9+
Push-OutputBinding -Name res -Value ([HttpResponseContext]@{
10+
StatusCode = 202
11+
Body = (Get-ProfileString)
12+
})

test/E2E/setupE2Etests.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Expand-Archive $output -DestinationPath $FUNC_CLI_DIRECTORY
3535
Write-Host "Copying azure-functions-powershell-worker to Functions Host workers directory..."
3636

3737
$configuration = if ($env:CONFIGURATION) { $env:CONFIGURATION } else { 'Debug' }
38+
Remove-Item -Recurse -Force -Path "$FUNC_CLI_DIRECTORY/workers/powershell"
3839
Copy-Item -Recurse -Force "$PSScriptRoot/../../src/bin/$configuration/netcoreapp2.1/publish/" "$FUNC_CLI_DIRECTORY/workers/powershell"
3940

4041
Write-Host "Staring Functions Host..."

test/Microsoft.Azure.Functions.PowerShellWorker.Test.csproj

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,5 +31,14 @@
3131
<Content Include="Unit\PowerShell\TestScripts\testFunctionCleanup.ps1">
3232
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
3333
</Content>
34+
<Content Include="Unit\PowerShell\TestScripts\ProfileBasic\profile.ps1">
35+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
36+
</Content>
37+
<Content Include="Unit\PowerShell\TestScripts\ProfileWithTerminatingError\profile.ps1">
38+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
39+
</Content>
40+
<Content Include="Unit\PowerShell\TestScripts\ProfileWithNonTerminatingError\Profile.ps1">
41+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
42+
</Content>
3443
</ItemGroup>
3544
</Project>

0 commit comments

Comments
 (0)