diff --git a/src/DependencyManagement/DependencyManagementUtils.cs b/src/DependencyManagement/DependencyManagementUtils.cs index 8d0962e2..4a79e67e 100644 --- a/src/DependencyManagement/DependencyManagementUtils.cs +++ b/src/DependencyManagement/DependencyManagementUtils.cs @@ -6,16 +6,11 @@ using System; using System.Collections.Generic; using System.IO; -using System.Net; -using System.Xml; namespace Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement { internal class DependencyManagementUtils { - // The PowerShellGallery uri to query for the latest module version. - private const string PowerShellGalleryFindPackagesByIdUri = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id="; - /// /// Deletes the contents at the given directory. /// @@ -51,104 +46,5 @@ internal static void EmptyDirectory(string path) throw new InvalidOperationException(errorMsg); } } - - /// - /// Returns the latest module version from the PSGallery for the given module name and major version. - /// - internal static string GetModuleLatestSupportedVersion(string moduleName, string majorVersion) - { - Uri address = new Uri($"{PowerShellGalleryFindPackagesByIdUri}'{moduleName}'"); - int configuredRetries = 3; - int noOfRetries = 1; - - string latestVersionForMajorVersion = null; - - while (noOfRetries <= configuredRetries) - { - try - { - HttpWebRequest request = WebRequest.Create(address) as HttpWebRequest; - using (HttpWebResponse response = request?.GetResponse() as HttpWebResponse) - { - if (response != null) - { - // Load up the XML response - XmlDocument doc = new XmlDocument(); - using (XmlReader reader = XmlReader.Create(response.GetResponseStream())) - { - doc.Load(reader); - } - - // Add the namespaces for the gallery xml content - XmlNamespaceManager nsmgr = new XmlNamespaceManager(doc.NameTable); - nsmgr.AddNamespace("ps", "http://www.w3.org/2005/Atom"); - nsmgr.AddNamespace("d", "http://schemas.microsoft.com/ado/2007/08/dataservices"); - nsmgr.AddNamespace("m", "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"); - - // Find the version information - XmlNode root = doc.DocumentElement; - var props = root.SelectNodes("//m:properties/d:Version", nsmgr); - if (props != null && props.Count > 0) - { - for (int i = 0; i < props.Count; i++) - { - if (props[i].FirstChild.Value.StartsWith(majorVersion)) - { - latestVersionForMajorVersion = props[i].FirstChild.Value; - } - } - } - break; - } - } - } - catch (Exception ex) - { - WebException webEx = ex as WebException; - if (webEx == null || noOfRetries >= configuredRetries) - { - throw; - } - - // Only retry the web exception - if (ShouldRetry(webEx)) - { - noOfRetries++; - } - } - } - - // If we could not find the latest module version error out. - if (string.IsNullOrEmpty(latestVersionForMajorVersion)) - { - var errorMsg = string.Format(PowerShellWorkerStrings.CannotFindModuleVersion, moduleName, majorVersion); - var argException = new ArgumentException(errorMsg); - throw argException; - } - - return latestVersionForMajorVersion; - } - - /// - /// Returns true if the given WebException status matches one of the following: - /// SendFailure, ConnectFailure, UnknownError or Timeout. - /// - private static bool ShouldRetry(WebException webEx) - { - if (webEx == null) - { - return false; - } - - if (webEx.Status == WebExceptionStatus.SendFailure || - webEx.Status == WebExceptionStatus.ConnectFailure || - webEx.Status == WebExceptionStatus.UnknownError || - webEx.Status == WebExceptionStatus.Timeout) - { - return true; - } - - return false; - } } } diff --git a/src/DependencyManagement/DependencyManager.cs b/src/DependencyManagement/DependencyManager.cs index 94fe871b..88f8442d 100644 --- a/src/DependencyManagement/DependencyManager.cs +++ b/src/DependencyManagement/DependencyManager.cs @@ -10,8 +10,10 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; +using System.Xml; using Microsoft.Azure.Functions.PowerShellWorker.PowerShell; using Microsoft.Azure.Functions.PowerShellWorker.Utility; using Microsoft.Azure.Functions.PowerShellWorker.Messaging; @@ -22,7 +24,6 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.DependencyManagement { using System.Management.Automation; using System.Management.Automation.Language; - using System.Management.Automation.Runspaces; internal class DependencyManager { @@ -60,19 +61,28 @@ internal class DependencyManager // Managed Dependencies folder name. private const string ManagedDependenciesFolderName = "ManagedDependencies"; - //Set when any error occurs while downloading dependencies + // Set when any error occurs while downloading dependencies private Exception _dependencyError; - //Dependency download task + // Dependency download task private Task _dependencyDownloadTask; // This flag is used to figure out if we need to install/reinstall all the function app dependencies. // If we do, we use it to clean up the module destination path. private bool _shouldUpdateFunctionAppDependencies; + // This string holds the message to be logged if we skipped updating function app dependencies. + private string _dependenciesNotUpdatedMessage; + // Maximum number of tries for retry logic when installing function app dependencies. private const int MaxNumberOfTries = 3; + // Save-Module cmdlet name. + private const string SaveModuleCmdletName = "PowerShellGet\\Save-Module"; + + // The PowerShellGallery uri to query for the latest module version. + private const string PowerShellGalleryFindPackagesByIdUri = "https://www.powershellgallery.com/api/v2/FindPackagesById()?id="; + internal DependencyManager() { Dependencies = new List(); @@ -90,17 +100,28 @@ internal void ProcessDependencyDownload(MessagingStream msgStream, StreamingMess { var rpcLogger = new RpcLogger(msgStream); rpcLogger.SetContext(request.RequestId, null); - if (Dependencies.Count == 0) + + if (!_shouldUpdateFunctionAppDependencies) { - // If there are no dependencies to install, log and return. - rpcLogger.Log(LogLevel.Trace, PowerShellWorkerStrings.FunctionAppDoesNotHaveDependentModulesToInstall, isUserLog: true); + if (!string.IsNullOrEmpty(_dependenciesNotUpdatedMessage)) + { + // We were not able to update the function app dependencies. + // However, there is a previous installation, so continue with the function app execution. + rpcLogger.Log(LogLevel.Warning, _dependenciesNotUpdatedMessage, isUserLog: true); + } + else + { + // The function app already has the latest dependencies installed. + rpcLogger.Log(LogLevel.Trace, PowerShellWorkerStrings.LatestFunctionAppDependenciesAlreadyInstalled, isUserLog: true); + } + return; } - if (!_shouldUpdateFunctionAppDependencies) + if (Dependencies.Count == 0) { - // The function app already has the latest dependencies installed. - rpcLogger.Log(LogLevel.Trace, PowerShellWorkerStrings.LatestFunctionAppDependenciesAlreadyInstalled, isUserLog: true); + // If there are no dependencies to install, log and return. + rpcLogger.Log(LogLevel.Trace, PowerShellWorkerStrings.FunctionAppDoesNotHaveDependentModulesToInstall, isUserLog: true); return; } @@ -158,8 +179,33 @@ internal void Initialize(FunctionLoadRequest request) // Validate the module version. string majorVersion = GetMajorVersion(version); - string latestVersion = DependencyManagementUtils.GetModuleLatestSupportedVersion(name, majorVersion); - ValidateModuleMajorVersion(name, majorVersion, latestVersion); + + // Try to connect to the PSGallery to get the latest module supported version. + string latestVersion = null; + try + { + latestVersion = GetModuleLatestSupportedVersion(name, majorVersion); + } + catch (Exception e) + { + // If we fail to get the latest module version (this could be because the PSGallery is down). + + // Check to see if there are previous managed dependencies installed. If this is the case, + // DependenciesPath is already set, so Get-Module will be able to find the modules. + var pathToInstalledModule = Path.Combine(DependenciesPath, name); + if (Directory.Exists(pathToInstalledModule)) + { + // Message to the user for skipped dependencies upgrade. + _dependenciesNotUpdatedMessage = string.Format(PowerShellWorkerStrings.DependenciesUpgradeSkippedMessage, e.Message); + + // Make sure that function app dependencies will NOT be installed, just continue with the function app execution. + _shouldUpdateFunctionAppDependencies = false; + return; + } + + // Otherwise, rethrow and stop the function app execution. + throw; + } // Before installing the module, check the path to see if it is already installed. var moduleVersionFolderPath = Path.Combine(DependenciesPath, name, latestVersion); @@ -192,25 +238,15 @@ internal void InstallFunctionAppDependencies(PowerShell pwsh, ILogger logger) // Install the function dependencies. logger.Log(LogLevel.Trace, PowerShellWorkerStrings.InstallingFunctionAppDependentModules, isUserLog: true); - if (Directory.Exists(DependenciesPath)) + try { - // Save-Module supports downloading side-by-size module versions. However, we only want to keep one version at the time. - // If the ManagedDependencies folder exits, remove all its contents. - DependencyManagementUtils.EmptyDirectory(DependenciesPath); + SetDependenciesDestinationPath(DependenciesPath); } - else + catch (Exception e) { - // If the destination path does not exist, create it. - // If the user does not have write access to the path, an exception will be raised. - try - { - Directory.CreateDirectory(DependenciesPath); - } - catch (Exception e) - { - var message = string.Format(PowerShellWorkerStrings.FailToCreateFunctionAppDependenciesDestinationPath, DependenciesPath, e.Message); - logger.Log(LogLevel.Trace, message, isUserLog: true); - } + logger.Log(LogLevel.Error, e.Message, isUserLog: true); + _dependencyError = new DependencyInstallationException(e.Message, e); + return; } try @@ -227,14 +263,7 @@ internal void InstallFunctionAppDependencies(PowerShell pwsh, ILogger logger) try { // Save the module to the given path - pwsh.AddCommand("PowerShellGet\\Save-Module") - .AddParameter("Repository", Repository) - .AddParameter("Name", moduleName) - .AddParameter("RequiredVersion", latestVersion) - .AddParameter("Path", DependenciesPath) - .AddParameter("Force", Utils.BoxedTrue) - .AddParameter("ErrorAction", "Stop") - .InvokeAndClearCommands(); + RunSaveModuleCommand(pwsh, Repository, moduleName, latestVersion, DependenciesPath); var message = string.Format(PowerShellWorkerStrings.ModuleHasBeenInstalled, moduleName, latestVersion); logger.Log(LogLevel.Trace, message, isUserLog: true); @@ -243,17 +272,15 @@ internal void InstallFunctionAppDependencies(PowerShell pwsh, ILogger logger) } catch (Exception e) { + string currentAttempt = GetCurrentAttemptMessage(tries); + var errorMsg = string.Format(PowerShellWorkerStrings.FailToInstallModule, moduleName, latestVersion, currentAttempt, e.Message); + logger.Log(LogLevel.Error, errorMsg, isUserLog: true); + if (tries >= MaxNumberOfTries) { - var errorMsg = string.Format(PowerShellWorkerStrings.FailToInstallFuncAppDependencies, e.Message); + errorMsg = string.Format(PowerShellWorkerStrings.FailToInstallFuncAppDependencies, e.Message); _dependencyError = new DependencyInstallationException(errorMsg, e); - - throw _dependencyError; - } - else - { - var errorMsg = string.Format(PowerShellWorkerStrings.FailToInstallFuncAppDependency, moduleName, latestVersion, e.Message); - logger.Log(LogLevel.Error, errorMsg, isUserLog: true); + return; } } @@ -269,31 +296,126 @@ internal void InstallFunctionAppDependencies(PowerShell pwsh, ILogger logger) finally { // Clean up - pwsh.AddCommand(Utils.RemoveModuleCmdletInfo) - .AddParameter("Name", "PackageManagement, PowerShellGet") - .AddParameter("Force", Utils.BoxedTrue) - .AddParameter("ErrorAction", "SilentlyContinue") - .InvokeAndClearCommands(); + RemoveSaveModuleModules(pwsh); } } #region Helper_Methods /// - /// Validates that the given major version is less or equal to the latest supported major version. + /// Runs Save-Module which downloads a module locally from the specified repository. + /// + protected virtual void RunSaveModuleCommand(PowerShell pwsh, string repository, string moduleName, string version, string path) + { + pwsh.AddCommand(SaveModuleCmdletName) + .AddParameter("Repository", repository) + .AddParameter("Name", moduleName) + .AddParameter("RequiredVersion", version) + .AddParameter("Path", path) + .AddParameter("Force", Utils.BoxedTrue) + .AddParameter("ErrorAction", "Stop") + .InvokeAndClearCommands(); + } + + /// + /// Removes the PowerShell modules used by the Save-Module cmdlet. /// - private void ValidateModuleMajorVersion(string moduleName, string majorVersion, string latestVersion) + protected virtual void RemoveSaveModuleModules(PowerShell pwsh) { - // A Version object cannot be created with a single digit so add a '.0' to it. - var requestedVersion = new Version($"{majorVersion}.0"); - var latestSupportedVersion = new Version(latestVersion); + pwsh.AddCommand(Utils.RemoveModuleCmdletInfo) + .AddParameter("Name", "PackageManagement, PowerShellGet") + .AddParameter("Force", Utils.BoxedTrue) + .AddParameter("ErrorAction", "SilentlyContinue") + .InvokeAndClearCommands(); + } + + /// + /// Returs the string representation of the given attempt number. + /// 1 returns 1st + /// 2 returns 2nd + /// 3 returns 3rd + /// + internal string GetCurrentAttemptMessage(int attempt) + { + string result = null; - if (requestedVersion.Major > latestSupportedVersion.Major) + switch (attempt) { - // The requested major version is greater than the latest major supported version. - var errorMsg = string.Format(PowerShellWorkerStrings.InvalidModuleMajorVersion, moduleName, majorVersion); - throw new ArgumentException(errorMsg); + case 1: + result = PowerShellWorkerStrings.FirstAttempt; + break; + case 2: + result = PowerShellWorkerStrings.SecondAttempt; + break; + case 3: + result = PowerShellWorkerStrings.ThirdAttempt; + break; + default: + throw new InvalidOperationException("Invalid attempt number. Unreachable code."); } + + return result; + } + + /// + /// Sets/prepares the destination path where the function app dependencies will be installed. + /// + internal void SetDependenciesDestinationPath(string path) + { + // Save-Module supports downloading side-by-size module versions. However, we only want to keep one version at the time. + // If the ManagedDependencies folder exits, remove all its contents. + if (Directory.Exists(path)) + { + DependencyManagementUtils.EmptyDirectory(path); + } + else + { + // If the destination path does not exist, create it. + // If the user does not have write access to the path, an exception will be raised. + try + { + Directory.CreateDirectory(path); + } + catch (Exception e) + { + var errorMsg = string.Format(PowerShellWorkerStrings.FailToCreateFunctionAppDependenciesDestinationPath, path, e.Message); + throw new InvalidOperationException(errorMsg); + } + } + } + + /// + /// Gets the latest module version from the PSGallery for the given module name and major version. + /// + internal string GetModuleLatestSupportedVersion(string moduleName, string majorVersion) + { + string latestVersion = null; + + string errorDetails = null; + bool throwException = false; + + try + { + latestVersion = GetLatestModuleVersionFromThePSGallery(moduleName, majorVersion); + } + catch (Exception e) + { + throwException = true; + + if (!string.IsNullOrEmpty(e.Message)) + { + errorDetails = string.Format(PowerShellWorkerStrings.ErrorDetails, e.Message.ToString()); + } + } + + // If we could not find the latest module version error out. + if (string.IsNullOrEmpty(latestVersion) || throwException) + { + var errorMsg = string.Format(PowerShellWorkerStrings.FailToGetModuleLatestVersion, moduleName, majorVersion, errorDetails ?? string.Empty); + throw new InvalidOperationException(errorMsg); + } + + return latestVersion; } /// @@ -423,6 +545,76 @@ private string GetManagedDependenciesPath(string functionAppRootPath) return managedDependenciesFolderPath; } + /// + /// Returns the latest module version from the PSGallery for the given module name and major version. + /// + protected virtual string GetLatestModuleVersionFromThePSGallery(string moduleName, string majorVersion) + { + Uri address = new Uri($"{PowerShellGalleryFindPackagesByIdUri}'{moduleName}'"); + + string latestMajorVersion = null; + Stream stream = null; + + var retryCount = 3; + while (true) + { + using (var client = new HttpClient()) + { + try + { + var response = client.GetAsync(address).Result; + + // Throw is not a successful request + response.EnsureSuccessStatusCode(); + + stream = response.Content.ReadAsStreamAsync().Result; + break; + } + catch (Exception e) + { + if (retryCount <= 0) + { + throw e; + } + retryCount--; + } + } + } + + if (stream != null) + { + // Load up the XML response + XmlDocument doc = new XmlDocument(); + using (XmlReader reader = XmlReader.Create(stream)) + { + doc.Load(reader); + } + + // Add the namespaces for the gallery xml content + XmlNamespaceManager nsmgr = new XmlNamespaceManager(doc.NameTable); + nsmgr.AddNamespace("ps", "http://www.w3.org/2005/Atom"); + nsmgr.AddNamespace("d", "http://schemas.microsoft.com/ado/2007/08/dataservices"); + nsmgr.AddNamespace("m", "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata"); + + // Find the version information + XmlNode root = doc.DocumentElement; + var props = root.SelectNodes("//m:properties/d:Version", nsmgr); + + if (props != null && props.Count > 0) + { + foreach (XmlNode prop in props) + { + if (prop.FirstChild.Value.StartsWith(majorVersion)) + { + latestMajorVersion = prop.FirstChild.Value; + } + } + } + } + + return latestMajorVersion; + } + #endregion } } diff --git a/src/resources/PowerShellWorkerStrings.resx b/src/resources/PowerShellWorkerStrings.resx index f6ed3e04..49a40a21 100644 --- a/src/resources/PowerShellWorkerStrings.resx +++ b/src/resources/PowerShellWorkerStrings.resx @@ -166,8 +166,8 @@ Custom pipe name specified. You can attach to the process by using vscode or by running `Enter-PSHostProcess -CustomPipeName {0}` - - Cannot find a supported version for module '{0}' with major version '{0}'. + + Fail to get latest version for module '{0}' with major version '{1}'. {2} FunctionApp does have dependent modules to install. @@ -176,7 +176,7 @@ Dependency '{0}' is null or empty. - Module name '{0}' version '{0}' has been installed. + Module name '{0}' version '{1}' has been installed. FunctionApp has the latest dependencies already installed. @@ -190,11 +190,8 @@ Version is not in the correct format. Please use the following notation: '{0}' - - Invalid major version for module '{0}'. The maximum available major version is '{1}'. - - Fail to install FunctionApp dependencies. For more information, please see error logs. Restarting the app may resolve the error. Error: '{0}' + Fail to install FunctionApp dependencies. Error: '{0}' Fail to resolve '{0}' path in App Service. @@ -227,9 +224,24 @@ Unrecognized data collecting behavior '{0}'. - Fail to create FunctionApp dependencies destination path '{0}'. Please make sure you have write access to this location. Error '{1}''. + Fail to create FunctionApp dependencies destination path '{0}'. Please make sure you have write access to this location. Error '{1}'. + + + Fail to install module '{0}' version '{1}'. {2} attempt. Error: '{3}' + + + 1st + + + 2nd + + + 3rd + + + Error: '{0}' - - Fail to install FunctionApp dependent module '{0}' version '{1}'. Error: '{2}''. + + Function app dependencies upgrade skipped. Error details: {0}. \ No newline at end of file diff --git a/test/Unit/DependencyManagement/DependencyManagementTests.cs b/test/Unit/DependencyManagement/DependencyManagementTests.cs index 83c3f6f3..074f47d9 100644 --- a/test/Unit/DependencyManagement/DependencyManagementTests.cs +++ b/test/Unit/DependencyManagement/DependencyManagementTests.cs @@ -11,18 +11,27 @@ namespace Microsoft.Azure.Functions.PowerShellWorker.Test { - public class DependencyManagementTests + using System.Management.Automation; + + public class DependencyManagementTests : IDisposable { private readonly string _dependencyManagementDirectory; private readonly string _functionId; private const string ManagedDependenciesFolderName = "ManagedDependencies"; private const string AzureFunctionsFolderName = "AzureFunctions"; + private readonly ConsoleLogger _testLogger; public DependencyManagementTests() { _dependencyManagementDirectory = Path.Combine( AppDomain.CurrentDomain.BaseDirectory, "TestScripts", "DependencyManagement"); _functionId = Guid.NewGuid().ToString(); + _testLogger = new ConsoleLogger(); + } + + public void Dispose() + { + _testLogger.FullLog.Clear(); } private FunctionLoadRequest GetFuncLoadRequest(string functionAppRoot, bool managedDependencyEnabled) @@ -106,7 +115,7 @@ public void TestManagedDependencyEmptyHashtableRequirement() { // Test case setup. var requirementsDirectoryName = "EmptyHashtableRequirement"; - var functionFolderPath = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName,"FunctionDirectory"); + var functionFolderPath = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName, "FunctionDirectory"); var functionAppRoot = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName); var managedDependenciesFolderPath = GetManagedDependenciesPath(functionAppRoot); var functionLoadRequest = GetFuncLoadRequest(functionFolderPath, true); @@ -177,5 +186,281 @@ public void TestManagedDependencyNoRequirementsFileShouldThrow() Assert.Contains("No 'requirements.psd1'", exception.Message); Assert.Contains("is found at the FunctionApp root folder", exception.Message); } + + [Fact] + public void TestManagedDependencySuccessfulModuleDownload() + { + try + { + // Test case setup. + var requirementsDirectoryName = "BasicRequirements"; + var functionFolderPath = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName, "FunctionDirectory"); + var functionAppRoot = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName); + var managedDependenciesFolderPath = GetManagedDependenciesPath(functionAppRoot); + var functionLoadRequest = GetFuncLoadRequest(functionFolderPath, true); + + // Create DependencyManager and process the requirements.psd1 file at the function app root. + var dependencyManager = new TestDependencyManager(); + dependencyManager.Initialize(functionLoadRequest); + + // Configure the dependency manager to mimic a successful download. + dependencyManager.SuccessfulDownload = true; + + // Install the function app dependencies. + dependencyManager.InstallFunctionAppDependencies(null, _testLogger); + + // Here we will get two logs: one that says that we are installing the dependencies, and one for a successful download. + bool correctLogCount = (_testLogger.FullLog.Count == 2); + Assert.True(correctLogCount); + + // The first log should say "Installing FunctionApp dependent modules." + Assert.Contains(PowerShellWorkerStrings.InstallingFunctionAppDependentModules, _testLogger.FullLog[0]); + + // In the overwritten RunSaveModuleCommand method, we saved in DownloadedModuleInfo the module name and version. + // This same information is logged after running save-module, so validate that they match. + Assert.Contains(dependencyManager.DownloadedModuleInfo, _testLogger.FullLog[1]); + + // Lastly, DependencyError should be null since the module was downloaded successfully. + Assert.Null(dependencyManager.DependencyError); + } + finally + { + TestCaseCleanup(); + } + } + + [Fact] + public void TestManagedDependencySuccessfulModuleDownloadAfterTwoTries() + { + try + { + // Test case setup + var requirementsDirectoryName = "BasicRequirements"; + var functionFolderPath = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName, "FunctionDirectory"); + var functionAppRoot = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName); + var managedDependenciesFolderPath = GetManagedDependenciesPath(functionAppRoot); + + var functionLoadRequest = GetFuncLoadRequest(functionFolderPath, true); + + // Create DependencyManager and process the requirements.psd1 file at the function app root. + var dependencyManager = new TestDependencyManager(); + dependencyManager.Initialize(functionLoadRequest); + + // Configure the dependencyManager to not throw in the RunSaveModuleCommand call after 2 tries. + dependencyManager.ShouldNotThrowAfterCount = 2; + + // Try to install the function app dependencies. + dependencyManager.InstallFunctionAppDependencies(null, _testLogger); + + // Here we will get four logs: + // - one that say that we are installing the dependencies + // - two that say that we failed to download the module + // - one for a successful module download + bool correctLogCount = (_testLogger.FullLog.Count == 4); + Assert.True(correctLogCount); + + // The first log should say "Installing FunctionApp dependent modules." + Assert.Contains(PowerShellWorkerStrings.InstallingFunctionAppDependentModules, _testLogger.FullLog[0]); + + // The subsequent two logs should contain the following: "Fail to install module" + for (int index = 1; index < _testLogger.FullLog.Count - 1; index++) + { + Assert.Contains("Fail to install module", _testLogger.FullLog[index]); + var currentAttempt = dependencyManager.GetCurrentAttemptMessage(index); + Assert.Contains(currentAttempt, _testLogger.FullLog[index]); + } + + // Successful module download log after two retries. + // In the overwritten RunSaveModuleCommand method, we saved in DownloadedModuleInfo the module name and version. + // This same information is logged after running save-module, so validate that they match. + Assert.Contains(dependencyManager.DownloadedModuleInfo, _testLogger.FullLog[3]); + + // Lastly, DependencyError should be null since the module was downloaded successfully after two tries. + Assert.Null(dependencyManager.DependencyError); + } + finally + { + TestCaseCleanup(); + } + } + + [Fact] + public void TestManagedDependencyRetryLogicMaxNumberOfTries() + { + try + { + // Test case setup + var requirementsDirectoryName = "BasicRequirements"; + var functionFolderPath = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName, "FunctionDirectory"); + var functionAppRoot = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName); + var managedDependenciesFolderPath = GetManagedDependenciesPath(functionAppRoot); + + var functionLoadRequest = GetFuncLoadRequest(functionFolderPath, true); + + // Create DependencyManager and process the requirements.psd1 file at the function app root. + var dependencyManager = new TestDependencyManager(); + dependencyManager.Initialize(functionLoadRequest); + + // Try to install the function app dependencies. + dependencyManager.InstallFunctionAppDependencies(null, _testLogger); + + // Here we will get four logs: one that says that we are installing the + // dependencies, and three for failing to install the module. + bool correctLogCount = (_testLogger.FullLog.Count == 4); + Assert.True(correctLogCount); + + // The first log should say "Installing FunctionApp dependent modules." + Assert.Contains(PowerShellWorkerStrings.InstallingFunctionAppDependentModules, _testLogger.FullLog[0]); + + // The subsequent logs should contain the following: + for (int index = 1; index < _testLogger.FullLog.Count; index++) + { + Assert.Contains("Fail to install module", _testLogger.FullLog[index]); + var currentAttempt = dependencyManager.GetCurrentAttemptMessage(index); + Assert.Contains(currentAttempt, _testLogger.FullLog[index]); + } + + // Lastly, DependencyError should get set after unsuccessfully retyring 3 times. + Assert.NotNull(dependencyManager.DependencyError); + Assert.Contains("Fail to install FunctionApp dependencies. Error:", dependencyManager.DependencyError.Message); + } + finally + { + TestCaseCleanup(); + } + } + + [Fact] + public void FunctionAppExecutionShouldStopIfNoPreviousDependenciesAreInstalled() + { + try + { + // Test case setup + var requirementsDirectoryName = "BasicRequirements"; + var functionFolderPath = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName, "FunctionDirectory"); + var functionAppRoot = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName); + var managedDependenciesFolderPath = GetManagedDependenciesPath(functionAppRoot); + + var functionLoadRequest = GetFuncLoadRequest(functionFolderPath, true); + + // Create DependencyManager and configure it to mimic being unable to reach + // the PSGallery to retrieve the latest module version + var dependencyManager = new TestDependencyManager(); + dependencyManager.GetLatestModuleVersionThrows = true; + + // Trying to initialize the dependencyManager should throw + var exception = Assert.Throws(() => dependencyManager.Initialize(functionLoadRequest)); + Assert.Contains("Fail to install FunctionApp dependencies.", exception.Message); + Assert.Contains("Fail to connect to the PSGallery", exception.Message); + + // Dependencies.Count should be 0, and DependencyManager.DependenciesPath should null + Assert.True(DependencyManager.Dependencies.Count == 0); + Assert.Null(DependencyManager.DependenciesPath); + } + finally + { + TestCaseCleanup(); + } + } + + [Fact] + public void FunctionAppExecutionShouldContinueIfPreviousDependenciesExist() + { + string AzModulePath = null; + try + { + // Test case setup + var requirementsDirectoryName = "BasicRequirements"; + var functionFolderPath = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName, "FunctionDirectory"); + var functionAppRoot = Path.Combine(_dependencyManagementDirectory, requirementsDirectoryName); + var managedDependenciesFolderPath = GetManagedDependenciesPath(functionAppRoot); + var functionLoadRequest = GetFuncLoadRequest(functionFolderPath, true); + + // Create DependencyManager and configure it to mimic being unable to reach + // the PSGallery to retrive the latest module version + var dependencyManager = new TestDependencyManager(); + dependencyManager.GetLatestModuleVersionThrows = true; + + // Create a path to mimic an existing installation of the Az module + AzModulePath = Path.Join(managedDependenciesFolderPath, "Az"); + if (Directory.Exists(AzModulePath)) + { + Directory.Delete(AzModulePath, true); + } + Directory.CreateDirectory(AzModulePath); + + // Initializing the dependency manager should not throw even though we were not able + // to connect to the PSGallery--given that a previous installation of the Az module is present + dependencyManager.Initialize(functionLoadRequest); + + // Dependencies.Count should be 0 (since no dependencies will be installed) + Assert.True(DependencyManager.Dependencies.Count == 0); + + // Validate that DependencyManager.DependenciesPath is set, so + // Get-Module can find the existing dependencies installed + var dependenciesPathIsValid = managedDependenciesFolderPath.Equals(DependencyManager.DependenciesPath, + StringComparison.CurrentCultureIgnoreCase); + Assert.True(dependenciesPathIsValid); + } + finally + { + if (Directory.Exists(AzModulePath)) + { + Directory.Delete(AzModulePath, true); + } + + TestCaseCleanup(); + } + } + } + + internal class TestDependencyManager : DependencyManager + { + // RunSaveModuleCommand in the DependencyManager class has retry logic with a max number of tries + // set to three. By default, we set ShouldNotThrowAfterCount to 4 to always throw. + public int ShouldNotThrowAfterCount { get; set; } = 4; + + public bool SuccessfulDownload { get; set; } + + public string DownloadedModuleInfo { get; set; } + + private int SaveModuleCount { get; set; } + + // Settings for GetLatestModuleVersionFromTheGallery + public bool GetLatestModuleVersionThrows { get; set; } + + internal TestDependencyManager() + { + } + + protected override void RunSaveModuleCommand(PowerShell pwsh, string repository, string moduleName, string version, string path) + { + if (SuccessfulDownload || (SaveModuleCount >= ShouldNotThrowAfterCount)) + { + // Save the module name and version for a successful download. + DownloadedModuleInfo = string.Format(PowerShellWorkerStrings.ModuleHasBeenInstalled, moduleName, version); + return; + } + + SaveModuleCount++; + + var errorMsg = $"Fail to install module '{moduleName}' version '{version}'"; + throw new InvalidOperationException(errorMsg); + } + + protected override void RemoveSaveModuleModules(PowerShell pwsh) + { + return; + } + + protected override string GetLatestModuleVersionFromThePSGallery(string moduleName, string majorVersion) + { + if (GetLatestModuleVersionThrows) + { + throw new InvalidOperationException("Fail to connect to the PSGallery"); + } + + return "2.0"; + } } }