Skip to content

Commit 76a2b12

Browse files
authored
Allow exact module versions in requirements.psd1 (#299)
Allow exact module versions in requirements.psd1
1 parent 7a8548d commit 76a2b12

16 files changed

+294
-226
lines changed

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -466,15 +466,20 @@ 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, 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 either be an exact and complete version, or strictly match the following pattern: `<major version>.*`. So, a typical manifest may look like this:
470470

471471
``` PowerShell
472472
@{
473473
'Az' = '2.*'
474474
'PSDepend' = '0.*'
475+
'Pester' = '5.0.0-alpha3'
475476
}
476477
```
477478

479+
When the `<major version>.*` format is used, the worker will retrieve the latest available module version (within the specified major version) from the PowerShell Gallery, ignoring prerelease versions.
480+
481+
When the exact version is specified, the worker will retrieve the specified version only, ignoring any other version. Prerelease versions are allowed in this case.
482+
478483
The number of entries in the _requirements.psd1_ file should not exceed **10**. This limit is not user-configurable.
479484

480485
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:

src/DependencyManagement/DependencyInfo.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement
88
internal class DependencyInfo
99
{
1010
internal readonly string Name;
11-
internal readonly string LatestVersion;
11+
internal readonly string ExactVersion;
1212

13-
internal DependencyInfo(string name, string latestVersion)
13+
internal DependencyInfo(string name, string exactVersion)
1414
{
1515
Name = name;
16-
LatestVersion = latestVersion;
16+
ExactVersion = exactVersion;
1717
}
1818
}
1919
}

src/DependencyManagement/DependencyManagerStorage.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ public IEnumerable<string> GetInstalledModuleVersions(string snapshotPath, strin
6666
return Directory.EnumerateDirectories(modulePath, $"{majorVersion}.*");
6767
}
6868

69+
public bool IsModuleVersionInstalled(string snapshotPath, string moduleName, string version)
70+
{
71+
var moduleVersionPath = Path.Join(snapshotPath, moduleName, version);
72+
return Directory.Exists(moduleVersionPath);
73+
}
74+
6975
public string CreateNewSnapshotPath()
7076
{
7177
return Path.Join(

src/DependencyManagement/DependencyManifest.cs

Lines changed: 43 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,52 @@ public IEnumerable<DependencyManifestEntry> GetEntries()
3838

3939
foreach (DictionaryEntry entry in hashtable)
4040
{
41-
// A valid entry is of the form: 'ModuleName'='MajorVersion.*"
42-
var name = (string)entry.Key;
43-
var version = (string)entry.Value;
41+
// A valid entry is of the form:
42+
// 'ModuleName'='MajorVersion.*'
43+
// or
44+
// 'ModuleName'='ExactVersion'
45+
46+
yield return CreateDependencyManifestEntry(
47+
name: (string)entry.Key,
48+
version: (string)entry.Value);
49+
}
50+
}
51+
52+
private static DependencyManifestEntry CreateDependencyManifestEntry(string name, string version)
53+
{
54+
ValidateModuleName(name);
4455

45-
ValidateModuleName(name);
56+
var match = Regex.Match(version, @"^(\d+)(.*)");
57+
if (match.Success)
58+
{
59+
// Look for the 'MajorVersion.*' pattern first.
60+
var majorVersion = match.Groups[1].Value;
61+
var afterMajorVersion = match.Groups[2].Value;
62+
if (afterMajorVersion == ".*")
63+
{
64+
return new DependencyManifestEntry(
65+
name,
66+
VersionSpecificationType.MajorVersion,
67+
majorVersion);
68+
}
4669

47-
yield return new DependencyManifestEntry(name, GetMajorVersion(version));
70+
// At this point, we know this is not the 'MajorVersion.*' pattern.
71+
// We want to perform a very basic sanity check of the format to detect some
72+
// obviously wrong cases: make sure afterMajorVersion starts with a dot,
73+
// does not contain * anywhere, and ends with a word character.
74+
// Not even trying to match the actual version format rules,
75+
// as they are quite complex and controlled by the server side anyway.
76+
if (Regex.IsMatch(afterMajorVersion, @"^(\.[^\*]*?\w)?$"))
77+
{
78+
return new DependencyManifestEntry(
79+
name,
80+
VersionSpecificationType.ExactVersion,
81+
version);
82+
}
4883
}
84+
85+
var errorMessage = string.Format(PowerShellWorkerStrings.InvalidVersionFormat, version, "MajorVersion.*");
86+
throw new ArgumentException(errorMessage);
4987
}
5088

5189
/// <summary>
@@ -106,38 +144,5 @@ private static void ValidateModuleName(string name)
106144
throw new ArgumentException(PowerShellWorkerStrings.DependencyNameIsNullOrEmpty);
107145
}
108146
}
109-
110-
/// <summary>
111-
/// Parses the given string version and extracts the major version.
112-
/// Please note that the only version we currently support is of the form '1.*'.
113-
/// </summary>
114-
private static string GetMajorVersion(string version)
115-
{
116-
ValidateVersionFormat(version);
117-
return version.Split(".")[0];
118-
}
119-
120-
private static void ValidateVersionFormat(string version)
121-
{
122-
if (version == null)
123-
{
124-
throw new ArgumentNullException(version);
125-
}
126-
127-
if (!IsValidVersionFormat(version))
128-
{
129-
var errorMessage = string.Format(PowerShellWorkerStrings.InvalidVersionFormat, "MajorVersion.*");
130-
throw new ArgumentException(errorMessage);
131-
}
132-
}
133-
134-
/// <summary>
135-
/// Validates the given version format. Currently, we only support 'Number.*'.
136-
/// </summary>
137-
private static bool IsValidVersionFormat(string version)
138-
{
139-
var pattern = @"^(\d)+(\.)(\*)";
140-
return Regex.IsMatch(version, pattern);
141-
}
142147
}
143148
}

src/DependencyManagement/DependencyManifestEntry.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@ internal class DependencyManifestEntry
99
{
1010
public string Name { get; }
1111

12-
public string MajorVersion { get; }
12+
public VersionSpecificationType VersionSpecificationType { get; }
1313

14-
public DependencyManifestEntry(string name, string majorVersion)
14+
public string VersionSpecification { get; }
15+
16+
public DependencyManifestEntry(
17+
string name,
18+
VersionSpecificationType versionSpecificationType,
19+
string versionSpecification)
1520
{
1621
Name = name;
17-
MajorVersion = majorVersion;
22+
VersionSpecificationType = versionSpecificationType;
23+
VersionSpecification = versionSpecification;
1824
}
1925
}
2026
}

src/DependencyManagement/DependencySnapshotInstaller.cs

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,30 +43,27 @@ public void InstallSnapshot(
4343

4444
try
4545
{
46-
foreach (DependencyInfo module in GetLatestPublishedVersionsOfDependencies(dependencies))
46+
foreach (DependencyInfo module in GetExactVersionsOfDependencies(dependencies))
4747
{
48-
string moduleName = module.Name;
49-
string latestVersion = module.LatestVersion;
50-
51-
logger.Log(isUserOnlyLog: false, LogLevel.Trace, string.Format(PowerShellWorkerStrings.StartedInstallingModule, moduleName, latestVersion));
48+
logger.Log(isUserOnlyLog: false, LogLevel.Trace, string.Format(PowerShellWorkerStrings.StartedInstallingModule, module.Name, module.ExactVersion));
5249

5350
int tries = 1;
5451

5552
while (true)
5653
{
5754
try
5855
{
59-
_moduleProvider.SaveModule(pwsh, moduleName, latestVersion, installingPath);
56+
_moduleProvider.SaveModule(pwsh, module.Name, module.ExactVersion, installingPath);
6057

61-
var message = string.Format(PowerShellWorkerStrings.ModuleHasBeenInstalled, moduleName, latestVersion);
58+
var message = string.Format(PowerShellWorkerStrings.ModuleHasBeenInstalled, module.Name, module.ExactVersion);
6259
logger.Log(isUserOnlyLog: false, LogLevel.Trace, message);
6360

6461
break;
6562
}
6663
catch (Exception e)
6764
{
6865
string currentAttempt = GetCurrentAttemptMessage(tries);
69-
var errorMsg = string.Format(PowerShellWorkerStrings.FailToInstallModule, moduleName, latestVersion, currentAttempt, e.Message);
66+
var errorMsg = string.Format(PowerShellWorkerStrings.FailToInstallModule, module.Name, module.ExactVersion, currentAttempt, e.Message);
7067
logger.Log(isUserOnlyLog: false, LogLevel.Error, errorMsg);
7168

7269
if (tries >= MaxNumberOfTries)
@@ -127,22 +124,35 @@ internal static string GetCurrentAttemptMessage(int attempt)
127124
}
128125
}
129126

130-
private List<DependencyInfo> GetLatestPublishedVersionsOfDependencies(
127+
private List<DependencyInfo> GetExactVersionsOfDependencies(
131128
IEnumerable<DependencyManifestEntry> dependencies)
132129
{
133130
var result = new List<DependencyInfo>();
134131

135132
foreach (var entry in dependencies)
136133
{
137-
var latestVersion = GetModuleLatestPublishedVersion(entry.Name, entry.MajorVersion);
138-
139-
var dependencyInfo = new DependencyInfo(entry.Name, latestVersion);
134+
var dependencyInfo = new DependencyInfo(entry.Name, GetExactVersion(entry));
140135
result.Add(dependencyInfo);
141136
}
142137

143138
return result;
144139
}
145140

141+
private string GetExactVersion(DependencyManifestEntry entry)
142+
{
143+
switch (entry.VersionSpecificationType)
144+
{
145+
case VersionSpecificationType.ExactVersion:
146+
return entry.VersionSpecification;
147+
148+
case VersionSpecificationType.MajorVersion:
149+
return GetModuleLatestPublishedVersion(entry.Name, entry.VersionSpecification);
150+
151+
default:
152+
throw new ArgumentException($"Unknown version specification type: {entry.VersionSpecificationType}");
153+
}
154+
}
155+
146156
/// <summary>
147157
/// Gets the latest published module version for the given module name and major version.
148158
/// </summary>

src/DependencyManagement/IDependencyManagerStorage.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ internal interface IDependencyManagerStorage
2020

2121
IEnumerable<string> GetInstalledModuleVersions(string snapshotPath, string moduleName, string majorVersion);
2222

23+
bool IsModuleVersionInstalled(string snapshotPath, string moduleName, string version);
24+
2325
string CreateNewSnapshotPath();
2426

2527
string CreateInstallingSnapshot(string path);

src/DependencyManagement/InstalledDependenciesLocator.cs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
namespace Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement
77
{
8+
using System;
89
using System.Linq;
910

1011
internal class InstalledDependenciesLocator : IInstalledDependenciesLocator
@@ -23,7 +24,7 @@ public string GetPathWithAcceptableDependencyVersionsInstalled()
2324
if (lastSnapshotPath != null)
2425
{
2526
var dependencies = _storage.GetDependencies();
26-
if (dependencies.All(entry => IsMajorVersionInstalled(lastSnapshotPath, entry)))
27+
if (dependencies.All(entry => IsAcceptableVersionInstalled(lastSnapshotPath, entry)))
2728
{
2829
return lastSnapshotPath;
2930
}
@@ -32,12 +33,26 @@ public string GetPathWithAcceptableDependencyVersionsInstalled()
3233
return null;
3334
}
3435

35-
private bool IsMajorVersionInstalled(string snapshotPath, DependencyManifestEntry dependency)
36+
private bool IsAcceptableVersionInstalled(string snapshotPath, DependencyManifestEntry dependency)
3637
{
37-
var installedVersions =
38-
_storage.GetInstalledModuleVersions(
39-
snapshotPath, dependency.Name, dependency.MajorVersion);
38+
switch (dependency.VersionSpecificationType)
39+
{
40+
case VersionSpecificationType.ExactVersion:
41+
return _storage.IsModuleVersionInstalled(
42+
snapshotPath, dependency.Name, dependency.VersionSpecification);
43+
44+
case VersionSpecificationType.MajorVersion:
45+
return IsMajorVersionInstalled(
46+
snapshotPath, dependency.Name, dependency.VersionSpecification);
47+
48+
default:
49+
throw new ArgumentException($"Unknown version specification type: {dependency.VersionSpecificationType}");
50+
}
51+
}
4052

53+
private bool IsMajorVersionInstalled(string snapshotPath, string name, string majorVersion)
54+
{
55+
var installedVersions = _storage.GetInstalledModuleVersions(snapshotPath, name, majorVersion);
4156
return installedVersions.Any();
4257
}
4358
}

src/DependencyManagement/PowerShellGalleryModuleProvider.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ public void SaveModule(PowerShell pwsh, string moduleName, string version, strin
106106
.AddParameter("Repository", Repository)
107107
.AddParameter("Name", moduleName)
108108
.AddParameter("RequiredVersion", version)
109+
.AddParameter("AllowPrerelease", Utils.BoxedTrue)
109110
.AddParameter("Path", path)
110111
.AddParameter("Force", Utils.BoxedTrue)
111112
.AddParameter("ErrorAction", "Stop")
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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+
#pragma warning disable 1591 // Missing XML comment for publicly visible type or member 'member'
7+
8+
namespace Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement
9+
{
10+
public enum VersionSpecificationType
11+
{
12+
ExactVersion,
13+
MajorVersion
14+
}
15+
}

0 commit comments

Comments
 (0)