Skip to content

Commit 7a8548d

Browse files
authored
Allow non-Az modules in requirements.psd1 (#287)
Allow non-Az modules in requirements.psd1
1 parent d5e1eef commit 7a8548d

File tree

7 files changed

+223
-32
lines changed

7 files changed

+223
-32
lines changed

docs/designs/PowerShell-AzF-Overall-Design.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -466,13 +466,16 @@ Note that, checking out a PowerShell Manager instance from the pool is a blockin
466466

467467
The goal is to let the user declare the dependencies required by functions, and rely on the service automatically locating and installing the dependencies from the PowerShell Gallery or other sources, taking care of selecting the proper versions, and automatically upgrading the dependencies to the latest versions (if allowed by the version specifications provided by the user).
468468

469-
Dependencies are declared in the _requirements.psd1_ file (_manifest_) as a collection of pairs (<_name_>, <_version specification_>). Currently, only _Az_ module is allowed, and the version specification should strictly match the following pattern: `<major version>.*`, so a typical manifest looks like this:
469+
Dependencies are declared in the _requirements.psd1_ file (_manifest_) as a collection of pairs (<_name_>, <_version specification_>). Currently, the version specification should strictly match the following pattern: `<major version>.*`, so a typical manifest looks like this:
470470

471471
``` PowerShell
472-
@{ 'Az' = '2.*' }
472+
@{
473+
'Az' = '2.*'
474+
'PSDepend' = '0.*'
475+
}
473476
```
474477

475-
However, the design should accommodate multiple module entries and allow specifying exact versions.
478+
The number of entries in the _requirements.psd1_ file should not exceed **10**. This limit is not user-configurable.
476479

477480
Installing and upgrading dependencies should be performed automatically, without requiring any interaction with the user, and without interfering with the currently running functions. This represents an important design challenge. In a different context, dependencies could be stored on a single location on the file system, managed by regular PowerShell tools (`Install-Module`/`Save-Module`, `PSDepend`, etc.), while having the same file system location added to _PSModulePath_ to make all the modules available to scripts running on this machine. This is what PowerShell users normally do, and this approach looks attractive because it is simple and conventional. However, in the contexts where multiple independent workers load modules and execute scripts concurrently, and at the same time some module versions are being added, upgraded, or removed, this simple approach causes many known problems. The root causes of these problems are in the fundamentals of PowerShell and PowerShell modules design. The managed dependencies design in Azure Functions must take this into account. The problems will be solved if we satisfy the following conditions:
478481

src/DependencyManagement/DependencyManagerStorage.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ public IEnumerable<string> GetInstalledAndInstallingSnapshots()
5858
public IEnumerable<string> GetInstalledModuleVersions(string snapshotPath, string moduleName, string majorVersion)
5959
{
6060
var modulePath = Path.Join(snapshotPath, moduleName);
61+
if (!Directory.Exists(modulePath))
62+
{
63+
return Enumerable.Empty<string>();
64+
}
65+
6166
return Directory.EnumerateDirectories(modulePath, $"{majorVersion}.*");
6267
}
6368

src/DependencyManagement/DependencyManifest.cs

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement
99
using System.Collections;
1010
using System.Collections.Generic;
1111
using System.IO;
12-
using System.Linq;
1312
using System.Management.Automation.Language;
1413
using System.Text;
1514
using System.Text.RegularExpressions;
@@ -18,21 +17,19 @@ internal class DependencyManifest
1817
{
1918
private const string RequirementsPsd1FileName = "requirements.psd1";
2019

21-
private const string AzModuleName = "Az";
22-
23-
// The list of managed dependencies supported in Azure Functions.
24-
private static readonly List<string> SupportedManagedDependencies = new List<string> { AzModuleName };
25-
2620
private readonly string _functionAppRootPath;
2721

28-
public DependencyManifest(string functionAppRootPath)
22+
private readonly int _maxDependencyEntries;
23+
24+
public DependencyManifest(string functionAppRootPath, int maxDependencyEntries = 10)
2925
{
3026
if (string.IsNullOrWhiteSpace(functionAppRootPath))
3127
{
3228
throw new ArgumentException("Argument is null or empty", nameof(functionAppRootPath));
3329
}
3430

3531
_functionAppRootPath = functionAppRootPath;
32+
_maxDependencyEntries = maxDependencyEntries;
3633
}
3734

3835
public IEnumerable<DependencyManifestEntry> GetEntries()
@@ -90,27 +87,23 @@ private Hashtable ParsePowerShellDataFile()
9087
throw new ArgumentException(errorMsg);
9188
}
9289

90+
if (hashtable.Count > _maxDependencyEntries)
91+
{
92+
var message = string.Format(PowerShellWorkerStrings.TooManyDependencies, RequirementsPsd1FileName, hashtable.Count, _maxDependencyEntries);
93+
throw new ArgumentException(message);
94+
}
95+
9396
return hashtable;
9497
}
9598

9699
/// <summary>
97-
/// Validate that the module name is not null or empty,
98-
/// and ensure that the module is a supported dependency.
100+
/// Validate that the module name is not null or empty.
99101
/// </summary>
100102
private static void ValidateModuleName(string name)
101103
{
102-
// Validate the name property.
103-
if (string.IsNullOrEmpty(name))
104+
if (string.IsNullOrWhiteSpace(name))
104105
{
105-
var errorMessage = string.Format(PowerShellWorkerStrings.DependencyPropertyIsNullOrEmpty, "name");
106-
throw new ArgumentException(errorMessage);
107-
}
108-
109-
// If this is not a supported module, error out.
110-
if (!SupportedManagedDependencies.Contains(name, StringComparer.OrdinalIgnoreCase))
111-
{
112-
var errorMessage = string.Format(PowerShellWorkerStrings.ManagedDependencyNotSupported, name);
113-
throw new ArgumentException(errorMessage);
106+
throw new ArgumentException(PowerShellWorkerStrings.DependencyNameIsNullOrEmpty);
114107
}
115108
}
116109

@@ -126,10 +119,9 @@ private static string GetMajorVersion(string version)
126119

127120
private static void ValidateVersionFormat(string version)
128121
{
129-
if (string.IsNullOrEmpty(version))
122+
if (version == null)
130123
{
131-
var errorMessage = string.Format(PowerShellWorkerStrings.DependencyPropertyIsNullOrEmpty, "version");
132-
throw new ArgumentException(errorMessage);
124+
throw new ArgumentNullException(version);
133125
}
134126

135127
if (!IsValidVersionFormat(version))
@@ -144,7 +136,7 @@ private static void ValidateVersionFormat(string version)
144136
/// </summary>
145137
private static bool IsValidVersionFormat(string version)
146138
{
147-
var pattern = @"^(\d){1,2}(\.)(\*)";
139+
var pattern = @"^(\d)+(\.)(\*)";
148140
return Regex.IsMatch(version, pattern);
149141
}
150142
}

src/DependencyManagement/DependencySnapshotInstaller.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ public void InstallSnapshot(
4848
string moduleName = module.Name;
4949
string latestVersion = module.LatestVersion;
5050

51+
logger.Log(isUserOnlyLog: false, LogLevel.Trace, string.Format(PowerShellWorkerStrings.StartedInstallingModule, moduleName, latestVersion));
52+
5153
int tries = 1;
5254

5355
while (true)

src/resources/PowerShellWorkerStrings.resx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,18 +172,18 @@
172172
<data name="FunctionAppDoesNotHaveDependentModulesToInstall" xml:space="preserve">
173173
<value>FunctionApp does have dependent modules to install.</value>
174174
</data>
175-
<data name="DependencyPropertyIsNullOrEmpty" xml:space="preserve">
176-
<value>Dependency '{0}' is null or empty.</value>
175+
<data name="DependencyNameIsNullOrEmpty" xml:space="preserve">
176+
<value>Dependency name is null or empty.</value>
177+
</data>
178+
<data name="StartedInstallingModule" xml:space="preserve">
179+
<value>Started installing module '{0}' version '{1}'.</value>
177180
</data>
178181
<data name="ModuleHasBeenInstalled" xml:space="preserve">
179182
<value>Module name '{0}' version '{1}' has been installed.</value>
180183
</data>
181184
<data name="AcceptableFunctionAppDependenciesAlreadyInstalled" xml:space="preserve">
182185
<value>The function app has existing dependencies installed. Updating the dependencies to the latest versions will be performed in the background. New function app instances will pick up any new dependencies.</value>
183186
</data>
184-
<data name="ManagedDependencyNotSupported" xml:space="preserve">
185-
<value>Managed dependency name '{0}' is not supported.</value>
186-
</data>
187187
<data name="InvalidPowerShellDataFile" xml:space="preserve">
188188
<value>The PowerShell data file '{0}' is invalid since it cannot be evaluated into a Hashtable object.</value>
189189
</data>
@@ -259,4 +259,7 @@
259259
<data name="FailedToInstallDependenciesSnapshot" xml:space="preserve">
260260
<value>Failed to install dependencies into '{0}', removing the folder.</value>
261261
</data>
262+
<data name="TooManyDependencies" xml:space="preserve">
263+
<value>The number of entries in the '{0}' file is {1}, which exceeds the maximum supported number of entries ({2}).</value>
264+
</data>
262265
</root>
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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+
namespace Microsoft.Azure.Functions.PowerShellWorker.Test.DependencyManagement
7+
{
8+
using System;
9+
using System.IO;
10+
using System.Linq;
11+
using Xunit;
12+
13+
using PowerShellWorker.DependencyManagement;
14+
15+
public class DependencyManifestTests : IDisposable
16+
{
17+
private readonly string _appRootPath;
18+
19+
public DependencyManifestTests()
20+
{
21+
_appRootPath = Path.Join(Path.GetTempPath(), Guid.NewGuid().ToString());
22+
Directory.CreateDirectory(_appRootPath);
23+
}
24+
25+
public void Dispose()
26+
{
27+
Directory.Delete(_appRootPath, recursive: true);
28+
}
29+
30+
[Fact]
31+
public void CanBeConstructedWithAnyAppRootPath()
32+
{
33+
new DependencyManifest("This path does not have to exist");
34+
}
35+
36+
[Fact]
37+
public void GetEntriesThrowsWhenRequirementsFileDoesNotExist()
38+
{
39+
var manifest = new DependencyManifest(_appRootPath);
40+
var exception = Assert.Throws<ArgumentException>(() => manifest.GetEntries().ToList());
41+
Assert.Contains("No 'requirements.psd1' is found at the FunctionApp root folder", exception.Message);
42+
Assert.Contains(_appRootPath, exception.Message);
43+
}
44+
45+
[Fact]
46+
public void GetEntriesThrowsWhenRequirementsFileIsEmpty()
47+
{
48+
CreateRequirementsFile(string.Empty);
49+
50+
var manifest = new DependencyManifest(_appRootPath);
51+
var exception = Assert.Throws<ArgumentException>(() => manifest.GetEntries().ToList());
52+
Assert.Equal(
53+
"The PowerShell data file 'requirements.psd1' is invalid since it cannot be evaluated into a Hashtable object.",
54+
exception.Message);
55+
}
56+
57+
[Fact]
58+
public void GetEntriesParsesRequirementsFileWithNoEntries()
59+
{
60+
CreateRequirementsFile("@{ }");
61+
62+
var manifest = new DependencyManifest(_appRootPath);
63+
Assert.Empty(manifest.GetEntries());
64+
}
65+
66+
[Theory]
67+
[InlineData("@{ MyModule = '0.*' }", "MyModule", "0")]
68+
[InlineData("@{ MyModule = '1.*' }", "MyModule", "1")]
69+
[InlineData("@{ MyModule = '23.*' }", "MyModule", "23")]
70+
[InlineData("@{ MyModule = '456.*' }", "MyModule", "456")]
71+
public void GetEntriesParsesRequirementsFileWithSingleEntry(string content, string moduleName, string majorVersion)
72+
{
73+
CreateRequirementsFile(content);
74+
75+
var manifest = new DependencyManifest(_appRootPath);
76+
var entries = manifest.GetEntries().ToList();
77+
78+
Assert.Single(entries);
79+
Assert.Equal(moduleName, entries.Single().Name);
80+
Assert.Equal(majorVersion, entries.Single().MajorVersion);
81+
}
82+
83+
[Fact]
84+
public void GetEntriesParsesRequirementsFileWithMultipleEntries()
85+
{
86+
CreateRequirementsFile("@{ A = '3.*'; B = '7.*'; C = '0.*' }");
87+
88+
var manifest = new DependencyManifest(_appRootPath, maxDependencyEntries: 3);
89+
var entries = manifest.GetEntries().ToList();
90+
91+
Assert.Equal(3, entries.Count);
92+
Assert.Equal("3", entries.Single(entry => entry.Name == "A").MajorVersion);
93+
Assert.Equal("7", entries.Single(entry => entry.Name == "B").MajorVersion);
94+
Assert.Equal("0", entries.Single(entry => entry.Name == "C").MajorVersion);
95+
}
96+
97+
[Theory]
98+
[InlineData("@{ MyModule = '' }")]
99+
[InlineData("@{ MyModule = 'a' }")]
100+
[InlineData("@{ MyModule = '.' }")]
101+
[InlineData("@{ MyModule = '1' }")]
102+
[InlineData("@{ MyModule = '1.' }")]
103+
[InlineData("@{ MyModule = '1.0' }")]
104+
[InlineData("@{ MyModule = '1.2' }")]
105+
[InlineData("@{ MyModule = '2.3.4' }")]
106+
public void GetEntriesThrowsOnInvalidVersionSpecification(string content)
107+
{
108+
CreateRequirementsFile(content);
109+
110+
var manifest = new DependencyManifest(_appRootPath);
111+
112+
var exception = Assert.Throws<ArgumentException>(() => manifest.GetEntries().ToList());
113+
Assert.Contains("Version is not in the correct format", exception.Message);
114+
}
115+
116+
[Theory]
117+
[InlineData("@{ '' = '1.*' }")]
118+
[InlineData("@{ ' ' = '1.*' }")]
119+
[InlineData("@{ ' ' = '' }")]
120+
public void GetEntriesThrowsOnInvalidModuleName(string content)
121+
{
122+
CreateRequirementsFile(content);
123+
124+
var manifest = new DependencyManifest(_appRootPath);
125+
126+
var exception = Assert.Throws<ArgumentException>(() => manifest.GetEntries().ToList());
127+
Assert.Contains("Dependency name is null or empty", exception.Message);
128+
}
129+
130+
[Fact]
131+
public void GetEntriesThrowsOnNullModuleName()
132+
{
133+
CreateRequirementsFile("@{ $null = '1.0' }");
134+
135+
var manifest = new DependencyManifest(_appRootPath);
136+
137+
Assert.ThrowsAny<Exception>(() => manifest.GetEntries().ToList());
138+
}
139+
140+
[Fact]
141+
public void GetEntriesThrowsWhenTooManyEntries()
142+
{
143+
CreateRequirementsFile("@{ A = '3.*'; B = '7.*'; C = '0.*' }");
144+
145+
var manifest = new DependencyManifest(_appRootPath, maxDependencyEntries: 2);
146+
147+
var exception = Assert.Throws<ArgumentException>(() => manifest.GetEntries().ToList());
148+
149+
const string ExpectedMessage = "The number of entries in the 'requirements.psd1' file is 3,"
150+
+ " which exceeds the maximum supported number of entries (2).";
151+
Assert.Equal(ExpectedMessage, exception.Message);
152+
}
153+
154+
private void CreateRequirementsFile(string content)
155+
{
156+
using (var writer = new StreamWriter(Path.Join(_appRootPath, "requirements.psd1")))
157+
{
158+
writer.Write(content);
159+
}
160+
}
161+
}
162+
}

test/Unit/DependencyManagement/DependencySnapshotInstallerTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.Test.DependencyManagement
1515
using Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement;
1616
using Microsoft.Azure.Functions.PowerShellWorker.Utility;
1717

18+
using LogLevel = Microsoft.Azure.WebJobs.Script.Grpc.Messages.RpcLog.Types.Level;
19+
1820
public class DependencySnapshotInstallerTests
1921
{
2022
private readonly Mock<IModuleProvider> _mockModuleProvider = new Mock<IModuleProvider>(MockBehavior.Strict);
@@ -85,9 +87,31 @@ public void InstallsDependencySnapshots()
8587

8688
foreach (var entry in _testDependencyManifestEntries)
8789
{
90+
_mockLogger.Verify(
91+
_ => _.Log(
92+
false,
93+
LogLevel.Trace,
94+
It.Is<string>(
95+
message => message.Contains("Started installing")
96+
&& message.Contains(entry.Name)
97+
&& message.Contains(_testLatestPublishedModuleVersions[entry.Name])),
98+
null),
99+
Times.Once);
100+
88101
_mockModuleProvider.Verify(
89102
_ => _.SaveModule(dummyPowerShell, entry.Name, _testLatestPublishedModuleVersions[entry.Name], _targetPathInstalling),
90103
Times.Once);
104+
105+
_mockLogger.Verify(
106+
_ => _.Log(
107+
false,
108+
LogLevel.Trace,
109+
It.Is<string>(
110+
message => message.Contains("has been installed")
111+
&& message.Contains(entry.Name)
112+
&& message.Contains(_testLatestPublishedModuleVersions[entry.Name])),
113+
null),
114+
Times.Once);
91115
}
92116

93117
_mockStorage.Verify(_ => _.PromoteInstallingSnapshotToInstalledAtomically(_targetPathInstalled), Times.Once);

0 commit comments

Comments
 (0)