diff --git a/docs/designs/PowerShell-AzF-Overall-Design.md b/docs/designs/PowerShell-AzF-Overall-Design.md index 7ecd1487..2e8fe53e 100644 --- a/docs/designs/PowerShell-AzF-Overall-Design.md +++ b/docs/designs/PowerShell-AzF-Overall-Design.md @@ -466,15 +466,20 @@ Note that, checking out a PowerShell Manager instance from the pool is a blockin 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). -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: `.*`, so a typical manifest looks like this: +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: `.*`. So, a typical manifest may look like this: ``` PowerShell @{ 'Az' = '2.*' 'PSDepend' = '0.*' + 'Pester' = '5.0.0-alpha3' } ``` +When the `.*` format is used, the worker will retrieve the latest available module version (within the specified major version) from the PowerShell Gallery, ignoring prerelease versions. + +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. + The number of entries in the _requirements.psd1_ file should not exceed **10**. This limit is not user-configurable. 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: diff --git a/src/DependencyManagement/DependencyInfo.cs b/src/DependencyManagement/DependencyInfo.cs index b18cd511..bbe20c24 100644 --- a/src/DependencyManagement/DependencyInfo.cs +++ b/src/DependencyManagement/DependencyInfo.cs @@ -8,12 +8,12 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement internal class DependencyInfo { internal readonly string Name; - internal readonly string LatestVersion; + internal readonly string ExactVersion; - internal DependencyInfo(string name, string latestVersion) + internal DependencyInfo(string name, string exactVersion) { Name = name; - LatestVersion = latestVersion; + ExactVersion = exactVersion; } } } diff --git a/src/DependencyManagement/DependencyManagerStorage.cs b/src/DependencyManagement/DependencyManagerStorage.cs index 2994d078..dfd5b37d 100644 --- a/src/DependencyManagement/DependencyManagerStorage.cs +++ b/src/DependencyManagement/DependencyManagerStorage.cs @@ -66,6 +66,12 @@ public IEnumerable GetInstalledModuleVersions(string snapshotPath, strin return Directory.EnumerateDirectories(modulePath, $"{majorVersion}.*"); } + public bool IsModuleVersionInstalled(string snapshotPath, string moduleName, string version) + { + var moduleVersionPath = Path.Join(snapshotPath, moduleName, version); + return Directory.Exists(moduleVersionPath); + } + public string CreateNewSnapshotPath() { return Path.Join( diff --git a/src/DependencyManagement/DependencyManifest.cs b/src/DependencyManagement/DependencyManifest.cs index 9f7d4908..070af254 100644 --- a/src/DependencyManagement/DependencyManifest.cs +++ b/src/DependencyManagement/DependencyManifest.cs @@ -38,14 +38,52 @@ public IEnumerable GetEntries() foreach (DictionaryEntry entry in hashtable) { - // A valid entry is of the form: 'ModuleName'='MajorVersion.*" - var name = (string)entry.Key; - var version = (string)entry.Value; + // A valid entry is of the form: + // 'ModuleName'='MajorVersion.*' + // or + // 'ModuleName'='ExactVersion' + + yield return CreateDependencyManifestEntry( + name: (string)entry.Key, + version: (string)entry.Value); + } + } + + private static DependencyManifestEntry CreateDependencyManifestEntry(string name, string version) + { + ValidateModuleName(name); - ValidateModuleName(name); + var match = Regex.Match(version, @"^(\d+)(.*)"); + if (match.Success) + { + // Look for the 'MajorVersion.*' pattern first. + var majorVersion = match.Groups[1].Value; + var afterMajorVersion = match.Groups[2].Value; + if (afterMajorVersion == ".*") + { + return new DependencyManifestEntry( + name, + VersionSpecificationType.MajorVersion, + majorVersion); + } - yield return new DependencyManifestEntry(name, GetMajorVersion(version)); + // At this point, we know this is not the 'MajorVersion.*' pattern. + // We want to perform a very basic sanity check of the format to detect some + // obviously wrong cases: make sure afterMajorVersion starts with a dot, + // does not contain * anywhere, and ends with a word character. + // Not even trying to match the actual version format rules, + // as they are quite complex and controlled by the server side anyway. + if (Regex.IsMatch(afterMajorVersion, @"^(\.[^\*]*?\w)?$")) + { + return new DependencyManifestEntry( + name, + VersionSpecificationType.ExactVersion, + version); + } } + + var errorMessage = string.Format(PowerShellWorkerStrings.InvalidVersionFormat, version, "MajorVersion.*"); + throw new ArgumentException(errorMessage); } /// @@ -106,38 +144,5 @@ private static void ValidateModuleName(string name) throw new ArgumentException(PowerShellWorkerStrings.DependencyNameIsNullOrEmpty); } } - - /// - /// Parses the given string version and extracts the major version. - /// Please note that the only version we currently support is of the form '1.*'. - /// - private static string GetMajorVersion(string version) - { - ValidateVersionFormat(version); - return version.Split(".")[0]; - } - - private static void ValidateVersionFormat(string version) - { - if (version == null) - { - throw new ArgumentNullException(version); - } - - if (!IsValidVersionFormat(version)) - { - var errorMessage = string.Format(PowerShellWorkerStrings.InvalidVersionFormat, "MajorVersion.*"); - throw new ArgumentException(errorMessage); - } - } - - /// - /// Validates the given version format. Currently, we only support 'Number.*'. - /// - private static bool IsValidVersionFormat(string version) - { - var pattern = @"^(\d)+(\.)(\*)"; - return Regex.IsMatch(version, pattern); - } } } diff --git a/src/DependencyManagement/DependencyManifestEntry.cs b/src/DependencyManagement/DependencyManifestEntry.cs index b0f838b2..9d17c4cb 100644 --- a/src/DependencyManagement/DependencyManifestEntry.cs +++ b/src/DependencyManagement/DependencyManifestEntry.cs @@ -9,12 +9,18 @@ internal class DependencyManifestEntry { public string Name { get; } - public string MajorVersion { get; } + public VersionSpecificationType VersionSpecificationType { get; } - public DependencyManifestEntry(string name, string majorVersion) + public string VersionSpecification { get; } + + public DependencyManifestEntry( + string name, + VersionSpecificationType versionSpecificationType, + string versionSpecification) { Name = name; - MajorVersion = majorVersion; + VersionSpecificationType = versionSpecificationType; + VersionSpecification = versionSpecification; } } } diff --git a/src/DependencyManagement/DependencySnapshotInstaller.cs b/src/DependencyManagement/DependencySnapshotInstaller.cs index 64d96454..5f1244c5 100644 --- a/src/DependencyManagement/DependencySnapshotInstaller.cs +++ b/src/DependencyManagement/DependencySnapshotInstaller.cs @@ -43,12 +43,9 @@ public void InstallSnapshot( try { - foreach (DependencyInfo module in GetLatestPublishedVersionsOfDependencies(dependencies)) + foreach (DependencyInfo module in GetExactVersionsOfDependencies(dependencies)) { - string moduleName = module.Name; - string latestVersion = module.LatestVersion; - - logger.Log(isUserOnlyLog: false, LogLevel.Trace, string.Format(PowerShellWorkerStrings.StartedInstallingModule, moduleName, latestVersion)); + logger.Log(isUserOnlyLog: false, LogLevel.Trace, string.Format(PowerShellWorkerStrings.StartedInstallingModule, module.Name, module.ExactVersion)); int tries = 1; @@ -56,9 +53,9 @@ public void InstallSnapshot( { try { - _moduleProvider.SaveModule(pwsh, moduleName, latestVersion, installingPath); + _moduleProvider.SaveModule(pwsh, module.Name, module.ExactVersion, installingPath); - var message = string.Format(PowerShellWorkerStrings.ModuleHasBeenInstalled, moduleName, latestVersion); + var message = string.Format(PowerShellWorkerStrings.ModuleHasBeenInstalled, module.Name, module.ExactVersion); logger.Log(isUserOnlyLog: false, LogLevel.Trace, message); break; @@ -66,7 +63,7 @@ public void InstallSnapshot( catch (Exception e) { string currentAttempt = GetCurrentAttemptMessage(tries); - var errorMsg = string.Format(PowerShellWorkerStrings.FailToInstallModule, moduleName, latestVersion, currentAttempt, e.Message); + var errorMsg = string.Format(PowerShellWorkerStrings.FailToInstallModule, module.Name, module.ExactVersion, currentAttempt, e.Message); logger.Log(isUserOnlyLog: false, LogLevel.Error, errorMsg); if (tries >= MaxNumberOfTries) @@ -127,22 +124,35 @@ internal static string GetCurrentAttemptMessage(int attempt) } } - private List GetLatestPublishedVersionsOfDependencies( + private List GetExactVersionsOfDependencies( IEnumerable dependencies) { var result = new List(); foreach (var entry in dependencies) { - var latestVersion = GetModuleLatestPublishedVersion(entry.Name, entry.MajorVersion); - - var dependencyInfo = new DependencyInfo(entry.Name, latestVersion); + var dependencyInfo = new DependencyInfo(entry.Name, GetExactVersion(entry)); result.Add(dependencyInfo); } return result; } + private string GetExactVersion(DependencyManifestEntry entry) + { + switch (entry.VersionSpecificationType) + { + case VersionSpecificationType.ExactVersion: + return entry.VersionSpecification; + + case VersionSpecificationType.MajorVersion: + return GetModuleLatestPublishedVersion(entry.Name, entry.VersionSpecification); + + default: + throw new ArgumentException($"Unknown version specification type: {entry.VersionSpecificationType}"); + } + } + /// /// Gets the latest published module version for the given module name and major version. /// diff --git a/src/DependencyManagement/IDependencyManagerStorage.cs b/src/DependencyManagement/IDependencyManagerStorage.cs index d22ff595..5825d48e 100644 --- a/src/DependencyManagement/IDependencyManagerStorage.cs +++ b/src/DependencyManagement/IDependencyManagerStorage.cs @@ -20,6 +20,8 @@ internal interface IDependencyManagerStorage IEnumerable GetInstalledModuleVersions(string snapshotPath, string moduleName, string majorVersion); + bool IsModuleVersionInstalled(string snapshotPath, string moduleName, string version); + string CreateNewSnapshotPath(); string CreateInstallingSnapshot(string path); diff --git a/src/DependencyManagement/InstalledDependenciesLocator.cs b/src/DependencyManagement/InstalledDependenciesLocator.cs index 1a8f9dad..897c7194 100644 --- a/src/DependencyManagement/InstalledDependenciesLocator.cs +++ b/src/DependencyManagement/InstalledDependenciesLocator.cs @@ -5,6 +5,7 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement { + using System; using System.Linq; internal class InstalledDependenciesLocator : IInstalledDependenciesLocator @@ -23,7 +24,7 @@ public string GetPathWithAcceptableDependencyVersionsInstalled() if (lastSnapshotPath != null) { var dependencies = _storage.GetDependencies(); - if (dependencies.All(entry => IsMajorVersionInstalled(lastSnapshotPath, entry))) + if (dependencies.All(entry => IsAcceptableVersionInstalled(lastSnapshotPath, entry))) { return lastSnapshotPath; } @@ -32,12 +33,26 @@ public string GetPathWithAcceptableDependencyVersionsInstalled() return null; } - private bool IsMajorVersionInstalled(string snapshotPath, DependencyManifestEntry dependency) + private bool IsAcceptableVersionInstalled(string snapshotPath, DependencyManifestEntry dependency) { - var installedVersions = - _storage.GetInstalledModuleVersions( - snapshotPath, dependency.Name, dependency.MajorVersion); + switch (dependency.VersionSpecificationType) + { + case VersionSpecificationType.ExactVersion: + return _storage.IsModuleVersionInstalled( + snapshotPath, dependency.Name, dependency.VersionSpecification); + + case VersionSpecificationType.MajorVersion: + return IsMajorVersionInstalled( + snapshotPath, dependency.Name, dependency.VersionSpecification); + + default: + throw new ArgumentException($"Unknown version specification type: {dependency.VersionSpecificationType}"); + } + } + private bool IsMajorVersionInstalled(string snapshotPath, string name, string majorVersion) + { + var installedVersions = _storage.GetInstalledModuleVersions(snapshotPath, name, majorVersion); return installedVersions.Any(); } } diff --git a/src/DependencyManagement/PowerShellGalleryModuleProvider.cs b/src/DependencyManagement/PowerShellGalleryModuleProvider.cs index 57ba4b4b..3b38e0b7 100644 --- a/src/DependencyManagement/PowerShellGalleryModuleProvider.cs +++ b/src/DependencyManagement/PowerShellGalleryModuleProvider.cs @@ -106,6 +106,7 @@ public void SaveModule(PowerShell pwsh, string moduleName, string version, strin .AddParameter("Repository", Repository) .AddParameter("Name", moduleName) .AddParameter("RequiredVersion", version) + .AddParameter("AllowPrerelease", Utils.BoxedTrue) .AddParameter("Path", path) .AddParameter("Force", Utils.BoxedTrue) .AddParameter("ErrorAction", "Stop") diff --git a/src/DependencyManagement/VersionSpecificationType.cs b/src/DependencyManagement/VersionSpecificationType.cs new file mode 100644 index 00000000..890e8cda --- /dev/null +++ b/src/DependencyManagement/VersionSpecificationType.cs @@ -0,0 +1,15 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// + +#pragma warning disable 1591 // Missing XML comment for publicly visible type or member 'member' + +namespace Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement +{ + public enum VersionSpecificationType + { + ExactVersion, + MajorVersion + } +} diff --git a/src/resources/PowerShellWorkerStrings.resx b/src/resources/PowerShellWorkerStrings.resx index 03940110..aea97078 100644 --- a/src/resources/PowerShellWorkerStrings.resx +++ b/src/resources/PowerShellWorkerStrings.resx @@ -188,7 +188,7 @@ The PowerShell data file '{0}' is invalid since it cannot be evaluated into a Hashtable object. - Version is not in the correct format. Please use the following notation: '{0}' + Version specification '{0}' is not in the correct format. Please specify the exact version or use the following notation: '{1}' Fail to install FunctionApp dependencies. Error: '{0}' diff --git a/test/Unit/DependencyManagement/DependencyManagementTests.cs b/test/Unit/DependencyManagement/DependencyManagementTests.cs index 0f04b26d..704b1ab3 100644 --- a/test/Unit/DependencyManagement/DependencyManagementTests.cs +++ b/test/Unit/DependencyManagement/DependencyManagementTests.cs @@ -179,9 +179,9 @@ public void TestManagedDependencyInvalidRequirementsFormatShouldThrow() var exception = Assert.Throws( () => { dependencyManager.Initialize(_testLogger); }); - Assert.Contains("Version is not in the correct format.", exception.Message); - Assert.Contains("Please use the following notation:", exception.Message); - Assert.Contains("MajorVersion.*", exception.Message); + Assert.Contains("not in the correct format.", exception.Message); + Assert.Contains("1.0.*", exception.Message); + Assert.Contains("Please specify the exact version or use the following notation: 'MajorVersion.*'", exception.Message); } } diff --git a/test/Unit/DependencyManagement/DependencyManagerTests.cs b/test/Unit/DependencyManagement/DependencyManagerTests.cs index b2673ccd..7806e9a9 100644 --- a/test/Unit/DependencyManagement/DependencyManagerTests.cs +++ b/test/Unit/DependencyManagement/DependencyManagerTests.cs @@ -34,8 +34,7 @@ public void DoesNothingOnConstruction() [Fact] public void Initialize_ReturnsNewSnapshotPath_WhenNoAcceptableDependencyVersionsInstalled() { - _mockStorage.Setup(_ => _.GetDependencies()).Returns( - new[] { new DependencyManifestEntry("ModuleName", "1") }); + _mockStorage.Setup(_ => _.GetDependencies()).Returns(GetAnyNonEmptyDependencyManifestEntries()); _mockInstalledDependenciesLocator.Setup(_ => _.GetPathWithAcceptableDependencyVersionsInstalled()) .Returns(default(string)); _mockStorage.Setup(_ => _.CreateNewSnapshotPath()).Returns("NewSnapshot"); @@ -92,45 +91,6 @@ public void StartDependencyInstallationIfNeeded_InstallsNothing_WhenNoDependenci _mockInstaller.VerifyNoOtherCalls(); } - [Fact] - public void StartDependencyInstallationIfNeeded_InstallsSnapshotWithLatestPublishedModuleVersions() - { - _mockInstalledDependenciesLocator.Setup(_ => _.GetPathWithAcceptableDependencyVersionsInstalled()) - .Returns(default(string)); - - var dependencyManifestEntries = new[] - { - new DependencyManifestEntry("A", "3"), - new DependencyManifestEntry("C", "7"), - new DependencyManifestEntry("B", "11") - }; - - _mockStorage.Setup(_ => _.GetDependencies()).Returns(dependencyManifestEntries); - _mockStorage.Setup(_ => _.CreateNewSnapshotPath()).Returns("NewSnapshot"); - _mockPurger.Setup(_ => _.SetCurrentlyUsedSnapshot(It.IsAny(), _mockLogger.Object)); - - _mockInstaller.Setup( - _ => _.InstallSnapshot( - dependencyManifestEntries, - "NewSnapshot", - It.IsAny(), - _mockLogger.Object)); - - _mockStorage.Setup(_ => _.SnapshotExists(It.IsAny())).Returns(false); - - var dependencyManager = CreateDependencyManagerWithMocks(); - dependencyManager.Initialize(_mockLogger.Object); - dependencyManager.StartDependencyInstallationIfNeeded(PowerShell.Create(), PowerShell.Create, _mockLogger.Object); - dependencyManager.WaitForDependenciesAvailability(() => _mockLogger.Object); - - _mockInstaller.Verify( - _ => _.InstallSnapshot( - It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny()), - Times.Once()); - - _mockInstaller.VerifyNoOtherCalls(); - } - [Fact] public void StartDependencyInstallationIfNeeded_InstallsSnapshotInForeground_WhenNoAcceptableDependenciesInstalled() { @@ -317,7 +277,7 @@ public void StartDependencyInstallationIfNeeded_HandlesExceptionThrownBy_Install private static DependencyManifestEntry[] GetAnyNonEmptyDependencyManifestEntries() { - return new[] { new DependencyManifestEntry("ModuleName", "1") }; + return new[] { new DependencyManifestEntry("ModuleName", VersionSpecificationType.MajorVersion, "1") }; } private void VerifyExactlyOneSnapshotInstalled() diff --git a/test/Unit/DependencyManagement/DependencyManifestTests.cs b/test/Unit/DependencyManagement/DependencyManifestTests.cs index 1438ea54..48c9c81a 100644 --- a/test/Unit/DependencyManagement/DependencyManifestTests.cs +++ b/test/Unit/DependencyManagement/DependencyManifestTests.cs @@ -64,11 +64,21 @@ public void GetEntriesParsesRequirementsFileWithNoEntries() } [Theory] - [InlineData("@{ MyModule = '0.*' }", "MyModule", "0")] - [InlineData("@{ MyModule = '1.*' }", "MyModule", "1")] - [InlineData("@{ MyModule = '23.*' }", "MyModule", "23")] - [InlineData("@{ MyModule = '456.*' }", "MyModule", "456")] - public void GetEntriesParsesRequirementsFileWithSingleEntry(string content, string moduleName, string majorVersion) + [InlineData("@{ MyModule = '0.*' }", "MyModule", "0", VersionSpecificationType.MajorVersion)] + [InlineData("@{ MyModule = '1.*' }", "MyModule", "1", VersionSpecificationType.MajorVersion)] + [InlineData("@{ MyModule = '23.*' }", "MyModule", "23", VersionSpecificationType.MajorVersion)] + [InlineData("@{ MyModule = '456.*' }", "MyModule", "456", VersionSpecificationType.MajorVersion)] + [InlineData("@{ MyModule = '0' }", "MyModule", "0", VersionSpecificationType.ExactVersion)] + [InlineData("@{ MyModule = '1' }", "MyModule", "1", VersionSpecificationType.ExactVersion)] + [InlineData("@{ MyModule = '1.0' }", "MyModule", "1.0", VersionSpecificationType.ExactVersion)] + [InlineData("@{ MyModule = '3.4.5' }", "MyModule", "3.4.5", VersionSpecificationType.ExactVersion)] + [InlineData("@{ MyModule = '123.45.67.89' }", "MyModule", "123.45.67.89", VersionSpecificationType.ExactVersion)] + [InlineData("@{ MyModule = '123.45.67.89-alpha4' }", "MyModule", "123.45.67.89-alpha4", VersionSpecificationType.ExactVersion)] + public void GetEntriesParsesRequirementsFileWithSingleEntry( + string content, + string moduleName, + string majorVersion, + VersionSpecificationType versionSpecificationType) { CreateRequirementsFile(content); @@ -77,7 +87,8 @@ public void GetEntriesParsesRequirementsFileWithSingleEntry(string content, stri Assert.Single(entries); Assert.Equal(moduleName, entries.Single().Name); - Assert.Equal(majorVersion, entries.Single().MajorVersion); + Assert.Equal(versionSpecificationType, entries.Single().VersionSpecificationType); + Assert.Equal(majorVersion, entries.Single().VersionSpecification); } [Fact] @@ -89,20 +100,31 @@ public void GetEntriesParsesRequirementsFileWithMultipleEntries() var entries = manifest.GetEntries().ToList(); Assert.Equal(3, entries.Count); - Assert.Equal("3", entries.Single(entry => entry.Name == "A").MajorVersion); - Assert.Equal("7", entries.Single(entry => entry.Name == "B").MajorVersion); - Assert.Equal("0", entries.Single(entry => entry.Name == "C").MajorVersion); + + var entryA = entries.Single(entry => entry.Name == "A"); + Assert.Equal(VersionSpecificationType.MajorVersion, entryA.VersionSpecificationType); + Assert.Equal("3", entryA.VersionSpecification); + + var entryB = entries.Single(entry => entry.Name == "B"); + Assert.Equal(VersionSpecificationType.MajorVersion, entryB.VersionSpecificationType); + Assert.Equal("7", entryB.VersionSpecification); + + var entryC = entries.Single(entry => entry.Name == "C"); + Assert.Equal(VersionSpecificationType.MajorVersion, entryC.VersionSpecificationType); + Assert.Equal("0", entryC.VersionSpecification); } [Theory] [InlineData("@{ MyModule = '' }")] + [InlineData("@{ MyModule = ' ' }")] [InlineData("@{ MyModule = 'a' }")] + [InlineData("@{ MyModule = '1a' }")] [InlineData("@{ MyModule = '.' }")] - [InlineData("@{ MyModule = '1' }")] [InlineData("@{ MyModule = '1.' }")] - [InlineData("@{ MyModule = '1.0' }")] - [InlineData("@{ MyModule = '1.2' }")] - [InlineData("@{ MyModule = '2.3.4' }")] + [InlineData("@{ MyModule = '*' }")] + [InlineData("@{ MyModule = '*.1' }")] + [InlineData("@{ MyModule = '1.*.2' }")] + [InlineData("@{ MyModule = '1.0.*' }")] public void GetEntriesThrowsOnInvalidVersionSpecification(string content) { CreateRequirementsFile(content); @@ -110,7 +132,7 @@ public void GetEntriesThrowsOnInvalidVersionSpecification(string content) var manifest = new DependencyManifest(_appRootPath); var exception = Assert.Throws(() => manifest.GetEntries().ToList()); - Assert.Contains("Version is not in the correct format", exception.Message); + Assert.Contains("not in the correct format", exception.Message); } [Theory] diff --git a/test/Unit/DependencyManagement/DependencySnapshotInstallerTests.cs b/test/Unit/DependencyManagement/DependencySnapshotInstallerTests.cs index 72afe0ce..9bf52d1b 100644 --- a/test/Unit/DependencyManagement/DependencySnapshotInstallerTests.cs +++ b/test/Unit/DependencyManagement/DependencySnapshotInstallerTests.cs @@ -7,6 +7,7 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.Test.DependencyManagement { using System; using System.Collections.Generic; + using System.Linq; using System.Management.Automation; using Moq; @@ -19,26 +20,10 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.Test.DependencyManagement public class DependencySnapshotInstallerTests { - private readonly Mock _mockModuleProvider = new Mock(MockBehavior.Strict); + private readonly Mock _mockModuleProvider = new Mock(); private readonly Mock _mockStorage = new Mock(MockBehavior.Strict); private readonly Mock _mockLogger = new Mock(); - private readonly IEnumerable _testDependencyManifestEntries = - new[] - { - new DependencyManifestEntry("A", "3"), - new DependencyManifestEntry("C", "7"), - new DependencyManifestEntry("B", "11") - }; - - private readonly Dictionary _testLatestPublishedModuleVersions = - new Dictionary - { - { "A", "3.8.2" }, - { "B", "11.0" }, - { "C", "7.0.1.3" } - }; - private readonly string _targetPathInstalled; private readonly string _targetPathInstalling; @@ -46,86 +31,113 @@ public DependencySnapshotInstallerTests() { _targetPathInstalled = DependencySnapshotFolderNameTools.CreateUniqueName(); _targetPathInstalling = DependencySnapshotFolderNameTools.ConvertInstalledToInstalling(_targetPathInstalled); + _mockStorage.Setup(_ => _.CreateInstallingSnapshot(_targetPathInstalled)).Returns(_targetPathInstalling); + _mockStorage.Setup(_ => _.PromoteInstallingSnapshotToInstalledAtomically(_targetPathInstalled)); } [Fact] public void DoesNothingOnConstruction() { CreateDependenciesSnapshotInstallerWithMocks(); + + _mockModuleProvider.VerifyNoOtherCalls(); + _mockStorage.VerifyNoOtherCalls(); + _mockLogger.VerifyNoOtherCalls(); } [Fact] - public void InstallsDependencySnapshots() + public void SavesSpecifiedVersion_WhenExactVersionIsSpecified() { - // Arrange + var manifestEntries = + new[] { new DependencyManifestEntry("Module", VersionSpecificationType.ExactVersion, "Exact version") }; - var dummyPowerShell = PowerShell.Create(); - _mockStorage.Setup(_ => _.CreateInstallingSnapshot(_targetPathInstalled)) - .Returns(_targetPathInstalling); + var installer = CreateDependenciesSnapshotInstallerWithMocks(); + installer.InstallSnapshot(manifestEntries, _targetPathInstalled, PowerShell.Create(), _mockLogger.Object); - foreach (var entry in _testDependencyManifestEntries) - { - _mockModuleProvider.Setup( - _ => _.GetLatestPublishedModuleVersion(entry.Name, entry.MajorVersion)) - .Returns(_testLatestPublishedModuleVersions[entry.Name]); + _mockModuleProvider.Verify( + _ => _.SaveModule(It.IsAny(), "Module", "Exact version", _targetPathInstalling), + Times.Once); - _mockModuleProvider.Setup( - _ => _.SaveModule(dummyPowerShell, entry.Name, _testLatestPublishedModuleVersions[entry.Name], _targetPathInstalling)); - } + _mockModuleProvider.Verify( + _ => _.GetLatestPublishedModuleVersion(It.IsAny(), It.IsAny()), + Times.Never); + } - _mockStorage.Setup(_ => _.PromoteInstallingSnapshotToInstalledAtomically(_targetPathInstalled)); - _mockModuleProvider.Setup(_ => _.Cleanup(dummyPowerShell)); + [Fact] + public void SavesLatestPublishedVersion_WhenMajorVersionIsSpecified() + { + var manifestEntries = + new[] { new DependencyManifestEntry("Module", VersionSpecificationType.MajorVersion, "Major version") }; - // Act + _mockModuleProvider.Setup( + _ => _.GetLatestPublishedModuleVersion("Module", "Major version")) + .Returns("Latest version"); var installer = CreateDependenciesSnapshotInstallerWithMocks(); - installer.InstallSnapshot(_testDependencyManifestEntries, _targetPathInstalled, dummyPowerShell, _mockLogger.Object); + installer.InstallSnapshot(manifestEntries, _targetPathInstalled, PowerShell.Create(), _mockLogger.Object); - // Assert + _mockModuleProvider.Verify( + _ => _.SaveModule(It.IsAny(), "Module", "Latest version", _targetPathInstalling), + Times.Once); + } - _mockStorage.Verify(_ => _.CreateInstallingSnapshot(_targetPathInstalled), Times.Once); + [Fact] + public void PromotesInstallingSnapshotToInstalledIfSaveModuleDoesNotThrow() + { + var manifestEntries = + new[] { new DependencyManifestEntry("Module", VersionSpecificationType.ExactVersion, "Version") }; - foreach (var entry in _testDependencyManifestEntries) - { - _mockLogger.Verify( - _ => _.Log( - false, - LogLevel.Trace, - It.Is( - message => message.Contains("Started installing") - && message.Contains(entry.Name) - && message.Contains(_testLatestPublishedModuleVersions[entry.Name])), - null), - Times.Once); - - _mockModuleProvider.Verify( - _ => _.SaveModule(dummyPowerShell, entry.Name, _testLatestPublishedModuleVersions[entry.Name], _targetPathInstalling), - Times.Once); - - _mockLogger.Verify( - _ => _.Log( - false, - LogLevel.Trace, - It.Is( - message => message.Contains("has been installed") - && message.Contains(entry.Name) - && message.Contains(_testLatestPublishedModuleVersions[entry.Name])), - null), - Times.Once); - } + var installer = CreateDependenciesSnapshotInstallerWithMocks(); + installer.InstallSnapshot(manifestEntries, _targetPathInstalled, PowerShell.Create(), _mockLogger.Object); + _mockStorage.Verify(_ => _.CreateInstallingSnapshot(_targetPathInstalled), Times.Once); _mockStorage.Verify(_ => _.PromoteInstallingSnapshotToInstalledAtomically(_targetPathInstalled), Times.Once); + } + + [Fact] + public void CleansUpPowerShellRunspaceAfterSuccessfullySavingModule() + { + var manifestEntries = + new[] { new DependencyManifestEntry("Module", VersionSpecificationType.ExactVersion, "Version") }; + + var dummyPowerShell = PowerShell.Create(); + + var installer = CreateDependenciesSnapshotInstallerWithMocks(); + installer.InstallSnapshot(manifestEntries, _targetPathInstalled, dummyPowerShell, _mockLogger.Object); + _mockModuleProvider.Verify(_ => _.Cleanup(dummyPowerShell), Times.Once); } + [Fact] + public void LogsInstallationStartAndFinish() + { + var manifestEntries = + new[] + { + new DependencyManifestEntry("A", VersionSpecificationType.ExactVersion, "Exact A version"), + new DependencyManifestEntry("B", VersionSpecificationType.MajorVersion, "Major B version") + }; + + _mockModuleProvider.Setup( + _ => _.GetLatestPublishedModuleVersion(It.IsAny(), It.IsAny())) + .Returns("Exact B version"); + + var installer = CreateDependenciesSnapshotInstallerWithMocks(); + installer.InstallSnapshot(manifestEntries, _targetPathInstalled, PowerShell.Create(), _mockLogger.Object); + + VerifyLoggedOnce(new[] { "Started installing", "A", "Exact A version" }); + VerifyLoggedOnce(new[] { "has been installed", "A", "Exact A version" }); + VerifyLoggedOnce(new[] { "Started installing", "B", "Exact B version" }); + VerifyLoggedOnce(new[] { "has been installed", "B", "Exact B version" }); + } + [Fact] public void DoesNotSaveModuleIfGetLatestPublishedModuleVersionThrows() { - // Arrange + var manifestEntries = + new[] { new DependencyManifestEntry("Module", VersionSpecificationType.MajorVersion, "Version") }; var dummyPowerShell = PowerShell.Create(); - _mockStorage.Setup(_ => _.CreateInstallingSnapshot(_targetPathInstalled)) - .Returns(_targetPathInstalling); var injectedException = new InvalidOperationException("Couldn't get latest published module version"); @@ -137,13 +149,9 @@ public void DoesNotSaveModuleIfGetLatestPublishedModuleVersionThrows() _mockModuleProvider.Setup(_ => _.Cleanup(dummyPowerShell)); - // Act - var installer = CreateDependenciesSnapshotInstallerWithMocks(); var caughtException = Assert.Throws( - () => installer.InstallSnapshot(_testDependencyManifestEntries, _targetPathInstalled, dummyPowerShell, _mockLogger.Object)); - - // Assert + () => installer.InstallSnapshot(manifestEntries, _targetPathInstalled, dummyPowerShell, _mockLogger.Object)); Assert.Contains(injectedException.Message, caughtException.Message); @@ -159,36 +167,22 @@ public void DoesNotSaveModuleIfGetLatestPublishedModuleVersionThrows() [Fact] public void DoesNotPromoteDependenciesSnapshotIfSaveModuleKeepsThrowing() { - // Arrange + var manifestEntries = + new[] { new DependencyManifestEntry("Module", VersionSpecificationType.ExactVersion, "Version") }; var dummyPowerShell = PowerShell.Create(); - _mockStorage.Setup(_ => _.CreateInstallingSnapshot(_targetPathInstalled)) - .Returns(_targetPathInstalling); var injectedException = new Exception("Couldn't save module"); - foreach (var entry in _testDependencyManifestEntries) - { - _mockModuleProvider.Setup( - _ => _.GetLatestPublishedModuleVersion(entry.Name, entry.MajorVersion)) - .Returns(_testLatestPublishedModuleVersions[entry.Name]); - } - _mockModuleProvider.Setup( - _ => _.SaveModule(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + _ => _.SaveModule(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Throws(injectedException); _mockStorage.Setup(_ => _.RemoveSnapshot(_targetPathInstalling)); - _mockModuleProvider.Setup(_ => _.Cleanup(dummyPowerShell)); - - // Act - var installer = CreateDependenciesSnapshotInstallerWithMocks(); var thrownException = Assert.Throws( - () => installer.InstallSnapshot(_testDependencyManifestEntries, _targetPathInstalled, dummyPowerShell, _mockLogger.Object)); - - // Assert + () => installer.InstallSnapshot(manifestEntries, _targetPathInstalled, dummyPowerShell, _mockLogger.Object)); Assert.Contains(injectedException.Message, thrownException.Message); @@ -201,5 +195,17 @@ private DependencySnapshotInstaller CreateDependenciesSnapshotInstallerWithMocks { return new DependencySnapshotInstaller(_mockModuleProvider.Object, _mockStorage.Object); } + + private void VerifyLoggedOnce(IEnumerable messageParts) + { + _mockLogger.Verify( + _ => _.Log( + false, // isUserOnlyLog + LogLevel.Trace, + It.Is( // the message should contain every item of messageParts + message => messageParts.All(part => message.Contains(part))), + null), // exception + Times.Once); + } } } diff --git a/test/Unit/DependencyManagement/InstalledDependenciesLocatorTests.cs b/test/Unit/DependencyManagement/InstalledDependenciesLocatorTests.cs index 73da8dff..6b2b7bb6 100644 --- a/test/Unit/DependencyManagement/InstalledDependenciesLocatorTests.cs +++ b/test/Unit/DependencyManagement/InstalledDependenciesLocatorTests.cs @@ -16,11 +16,28 @@ public class InstalledDependenciesLocatorTests private readonly DependencyManifestEntry[] _dependencyManifestEntries = { - new DependencyManifestEntry("A", "3"), - new DependencyManifestEntry("C", "7"), - new DependencyManifestEntry("B", "11") + new DependencyManifestEntry("A", VersionSpecificationType.ExactVersion, "exact version of A"), + new DependencyManifestEntry("B", VersionSpecificationType.MajorVersion, "major version of B") }; + [Fact] + public void ReturnsLatestSnapshotPath_WhenAllDependenciesHaveAcceptableVersionInstalled() + { + // Even though multiple snapshots can be currently installed, only the latest one will be considered + // (determined by name). + _mockStorage.Setup(_ => _.GetInstalledSnapshots()).Returns(new[] { "s1", "s3", "s2" }); + + _mockStorage.Setup(_ => _.GetDependencies()).Returns(_dependencyManifestEntries); + + _mockStorage.Setup(_ => _.IsModuleVersionInstalled("s3", "A", "exact version of A")).Returns(true); + _mockStorage.Setup(_ => _.GetInstalledModuleVersions("s3", "B", "major version of B")).Returns(new [] { "exact version of B" }); + + var installedDependenciesLocator = new InstalledDependenciesLocator(_mockStorage.Object); + var result = installedDependenciesLocator.GetPathWithAcceptableDependencyVersionsInstalled(); + + Assert.Equal("s3", result); + } + [Fact] public void ReturnsNull_WhenNoInstalledDependencySnapshotsFound() { @@ -33,7 +50,7 @@ public void ReturnsNull_WhenNoInstalledDependencySnapshotsFound() } [Fact] - public void ReturnsNull_WhenAnyDependencyDoesNotHaveAcceptableVersionInstalled() + public void ReturnsNull_WhenNoMajorVersionInstalled() { // Even though multiple snapshots can be currently installed, only the latest one will be considered // (determined by name). @@ -41,10 +58,9 @@ public void ReturnsNull_WhenAnyDependencyDoesNotHaveAcceptableVersionInstalled() _mockStorage.Setup(_ => _.GetDependencies()).Returns(_dependencyManifestEntries); - // No 11.* version for module B detected! - _mockStorage.Setup(_ => _.GetInstalledModuleVersions("s3", "A", "3")).Returns(new[] { "3.1", "3.3" }); - _mockStorage.Setup(_ => _.GetInstalledModuleVersions("s3", "B", "11")).Returns(new string[0]); - _mockStorage.Setup(_ => _.GetInstalledModuleVersions("s3", "C", "7")).Returns(new[] { "7.0" }); + // No version for module B detected! + _mockStorage.Setup(_ => _.IsModuleVersionInstalled("s3", "A", "exact version of A")).Returns(true); + _mockStorage.Setup(_ => _.GetInstalledModuleVersions("s3", "B", "major version of B")).Returns(new string[0]); var installedDependenciesLocator = new InstalledDependenciesLocator(_mockStorage.Object); var result = installedDependenciesLocator.GetPathWithAcceptableDependencyVersionsInstalled(); @@ -53,7 +69,7 @@ public void ReturnsNull_WhenAnyDependencyDoesNotHaveAcceptableVersionInstalled() } [Fact] - public void ReturnsLatestSnapshotPath_WhenAnyDependencyDoesNotHaveAcceptableVersionInstalled() + public void ReturnsNull_WhenExactModuleVersionIsNotInstalled() { // Even though multiple snapshots can be currently installed, only the latest one will be considered // (determined by name). @@ -61,15 +77,14 @@ public void ReturnsLatestSnapshotPath_WhenAnyDependencyDoesNotHaveAcceptableVers _mockStorage.Setup(_ => _.GetDependencies()).Returns(_dependencyManifestEntries); - // No 11.* version for module B detected! - _mockStorage.Setup(_ => _.GetInstalledModuleVersions("s3", "A", "3")).Returns(new[] { "3.1", "3.3" }); - _mockStorage.Setup(_ => _.GetInstalledModuleVersions("s3", "B", "11")).Returns(new [] { "11.8.0.2" }); - _mockStorage.Setup(_ => _.GetInstalledModuleVersions("s3", "C", "7")).Returns(new[] { "7.0" }); + // The specified module A version is not installed + _mockStorage.Setup(_ => _.IsModuleVersionInstalled("s3", "A", "exact version of A")).Returns(false); + _mockStorage.Setup(_ => _.GetInstalledModuleVersions("s3", "B", "major version of B")).Returns(new [] { "exact version of B" }); var installedDependenciesLocator = new InstalledDependenciesLocator(_mockStorage.Object); var result = installedDependenciesLocator.GetPathWithAcceptableDependencyVersionsInstalled(); - Assert.Equal("s3", result); + Assert.Null(result); } } }