From 658906bf7c760f6523b0d0acaa6723411ee5be29 Mon Sep 17 00:00:00 2001 From: Pavel Krymets Date: Tue, 31 Jul 2018 16:36:31 -0700 Subject: [PATCH] Add TestApplication to allow publish output caching (#1511) --- build/dependencies.props | 56 ++++---- .../ApplicationPublisher.cs | 127 +++++++++++++++++ .../CachingApplicationPublisher.cs | 101 ++++++++++++++ .../Common/DeploymentParameters.cs | 2 + .../Deployers/ApplicationDeployer.cs | 130 ++---------------- .../ProcessHelpers.cs | 36 +++++ .../PublishedApplication.cs | 31 +++++ 7 files changed, 333 insertions(+), 150 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Server.IntegrationTesting/ApplicationPublisher.cs create mode 100644 src/Microsoft.AspNetCore.Server.IntegrationTesting/CachingApplicationPublisher.cs create mode 100644 src/Microsoft.AspNetCore.Server.IntegrationTesting/ProcessHelpers.cs create mode 100644 src/Microsoft.AspNetCore.Server.IntegrationTesting/PublishedApplication.cs diff --git a/build/dependencies.props b/build/dependencies.props index f17eac01..9a9e6c76 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -4,34 +4,34 @@ 2.2.0-preview1-17102 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 - 2.2.0-preview1-34823 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 + 2.2.0-preview1-34825 2.0.9 2.1.2 2.2.0-preview1-26618-02 diff --git a/src/Microsoft.AspNetCore.Server.IntegrationTesting/ApplicationPublisher.cs b/src/Microsoft.AspNetCore.Server.IntegrationTesting/ApplicationPublisher.cs new file mode 100644 index 00000000..1af71c3a --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.IntegrationTesting/ApplicationPublisher.cs @@ -0,0 +1,127 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.IntegrationTesting +{ + public class ApplicationPublisher + { + public string ApplicationPath { get; } + + public ApplicationPublisher(string applicationPath) + { + ApplicationPath = applicationPath; + } + + public static readonly string DotnetCommandName = "dotnet"; + + public virtual Task Publish(DeploymentParameters deploymentParameters, ILogger logger) + { + var publishDirectory = CreateTempDirectory(); + using (logger.BeginScope("dotnet-publish")) + { + if (string.IsNullOrEmpty(deploymentParameters.TargetFramework)) + { + throw new Exception($"A target framework must be specified in the deployment parameters for applications that require publishing before deployment"); + } + + var parameters = $"publish " + + $" --output \"{publishDirectory.FullName}\"" + + $" --framework {deploymentParameters.TargetFramework}" + + $" --configuration {deploymentParameters.Configuration}" + + (deploymentParameters.RestoreOnPublish + ? string.Empty + : " --no-restore -p:VerifyMatchingImplicitPackageVersion=false"); + // Set VerifyMatchingImplicitPackageVersion to disable errors when Microsoft.NETCore.App's version is overridden externally + // This verification doesn't matter if we are skipping restore during tests. + + if (deploymentParameters.ApplicationType == ApplicationType.Standalone) + { + parameters += $" --runtime {GetRuntimeIdentifier(deploymentParameters)}"; + } + + parameters += $" {deploymentParameters.AdditionalPublishParameters}"; + + var startInfo = new ProcessStartInfo + { + FileName = DotnetCommandName, + Arguments = parameters, + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + WorkingDirectory = deploymentParameters.ApplicationPath, + }; + + ProcessHelpers.AddEnvironmentVariablesToProcess(startInfo, deploymentParameters.PublishEnvironmentVariables, logger); + + var hostProcess = new Process() { StartInfo = startInfo }; + + logger.LogInformation($"Executing command {DotnetCommandName} {parameters}"); + + hostProcess.StartAndCaptureOutAndErrToLogger("dotnet-publish", logger); + + // A timeout is passed to Process.WaitForExit() for two reasons: + // + // 1. When process output is read asynchronously, WaitForExit() without a timeout blocks until child processes + // are killed, which can cause hangs due to MSBuild NodeReuse child processes started by dotnet.exe. + // With a timeout, WaitForExit() returns when the parent process is killed and ignores child processes. + // https://stackoverflow.com/a/37983587/102052 + // + // 2. If "dotnet publish" does hang indefinitely for some reason, tests should fail fast with an error message. + const int timeoutMinutes = 5; + if (hostProcess.WaitForExit(milliseconds: timeoutMinutes * 60 * 1000)) + { + if (hostProcess.ExitCode != 0) + { + var message = $"{DotnetCommandName} publish exited with exit code : {hostProcess.ExitCode}"; + logger.LogError(message); + throw new Exception(message); + } + } + else + { + var message = $"{DotnetCommandName} publish failed to exit after {timeoutMinutes} minutes"; + logger.LogError(message); + throw new Exception(message); + } + + logger.LogInformation($"{DotnetCommandName} publish finished with exit code : {hostProcess.ExitCode}"); + } + + return Task.FromResult(new PublishedApplication(publishDirectory.FullName, logger)); + } + + private static string GetRuntimeIdentifier(DeploymentParameters deploymentParameters) + { + var architecture = deploymentParameters.RuntimeArchitecture; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return "win7-" + architecture; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return "linux-" + architecture; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return "osx-" + architecture; + } + throw new InvalidOperationException("Unrecognized operation system platform"); + } + + protected static DirectoryInfo CreateTempDirectory() + { + var tempPath = Path.GetTempPath() + Guid.NewGuid().ToString("N"); + var target = new DirectoryInfo(tempPath); + target.Create(); + return target; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Server.IntegrationTesting/CachingApplicationPublisher.cs b/src/Microsoft.AspNetCore.Server.IntegrationTesting/CachingApplicationPublisher.cs new file mode 100644 index 00000000..582297cc --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.IntegrationTesting/CachingApplicationPublisher.cs @@ -0,0 +1,101 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.IntegrationTesting +{ + public class CachingApplicationPublisher: ApplicationPublisher, IDisposable + { + private readonly Dictionary _publishCache = new Dictionary(); + + public CachingApplicationPublisher(string applicationPath) : base(applicationPath) + { + } + + public override async Task Publish(DeploymentParameters deploymentParameters, ILogger logger) + { + if (ApplicationPath != deploymentParameters.ApplicationPath) + { + throw new InvalidOperationException("ApplicationPath mismatch"); + } + + if (deploymentParameters.PublishEnvironmentVariables.Any()) + { + throw new InvalidOperationException("DeploymentParameters.PublishEnvironmentVariables not supported"); + } + + if (!string.IsNullOrEmpty(deploymentParameters.PublishedApplicationRootPath)) + { + throw new InvalidOperationException("DeploymentParameters.PublishedApplicationRootPath not supported"); + } + + if (deploymentParameters.RestoreOnPublish) + { + throw new InvalidOperationException("DeploymentParameters.RestoreOnPublish not supported"); + } + + var dotnetPublishParameters = new DotnetPublishParameters + { + TargetFramework = deploymentParameters.TargetFramework, + Configuration = deploymentParameters.Configuration, + ApplicationType = deploymentParameters.ApplicationType, + RuntimeArchitecture = deploymentParameters.RuntimeArchitecture + }; + + if (!_publishCache.TryGetValue(dotnetPublishParameters, out var publishedApplication)) + { + publishedApplication = await base.Publish(deploymentParameters, logger); + _publishCache.Add(dotnetPublishParameters, publishedApplication); + } + + return new PublishedApplication(CopyPublishedOutput(publishedApplication, logger), logger); + } + + private string CopyPublishedOutput(PublishedApplication application, ILogger logger) + { + var target = CreateTempDirectory(); + + var source = new DirectoryInfo(application.Path); + CopyFiles(source, target, logger); + return target.FullName; + } + + public static void CopyFiles(DirectoryInfo source, DirectoryInfo target, ILogger logger) + { + foreach (DirectoryInfo directoryInfo in source.GetDirectories()) + { + CopyFiles(directoryInfo, target.CreateSubdirectory(directoryInfo.Name), logger); + } + + logger.LogDebug($"Processing {target.FullName}"); + foreach (FileInfo fileInfo in source.GetFiles()) + { + logger.LogDebug($" Copying {fileInfo.Name}"); + var destFileName = Path.Combine(target.FullName, fileInfo.Name); + fileInfo.CopyTo(destFileName); + } + } + + public void Dispose() + { + foreach (var publishedApp in _publishCache.Values) + { + publishedApp.Dispose(); + } + } + + private struct DotnetPublishParameters + { + public string TargetFramework { get; set; } + public string Configuration { get; set; } + public ApplicationType ApplicationType { get; set; } + public RuntimeArchitecture RuntimeArchitecture { get; set; } + } + } +} diff --git a/src/Microsoft.AspNetCore.Server.IntegrationTesting/Common/DeploymentParameters.cs b/src/Microsoft.AspNetCore.Server.IntegrationTesting/Common/DeploymentParameters.cs index a5f9460c..4b946577 100644 --- a/src/Microsoft.AspNetCore.Server.IntegrationTesting/Common/DeploymentParameters.cs +++ b/src/Microsoft.AspNetCore.Server.IntegrationTesting/Common/DeploymentParameters.cs @@ -99,6 +99,8 @@ namespace Microsoft.AspNetCore.Server.IntegrationTesting } } + public ApplicationPublisher ApplicationPublisher { get; set; } + public ServerType ServerType { get; set; } public RuntimeFlavor RuntimeFlavor { get; set; } diff --git a/src/Microsoft.AspNetCore.Server.IntegrationTesting/Deployers/ApplicationDeployer.cs b/src/Microsoft.AspNetCore.Server.IntegrationTesting/Deployers/ApplicationDeployer.cs index 999b9c78..5e1457e7 100644 --- a/src/Microsoft.AspNetCore.Server.IntegrationTesting/Deployers/ApplicationDeployer.cs +++ b/src/Microsoft.AspNetCore.Server.IntegrationTesting/Deployers/ApplicationDeployer.cs @@ -20,12 +20,10 @@ namespace Microsoft.AspNetCore.Server.IntegrationTesting { public static readonly string DotnetCommandName = "dotnet"; - // This is the argument that separates the dotnet arguments for the args being passed to the - // app being run when running dotnet run - public static readonly string DotnetArgumentSeparator = "--"; - private readonly Stopwatch _stopwatch = new Stopwatch(); + private PublishedApplication _publishedApplication; + public ApplicationDeployer(DeploymentParameters deploymentParameters, ILoggerFactory loggerFactory) { DeploymentParameters = deploymentParameters; @@ -82,78 +80,9 @@ namespace Microsoft.AspNetCore.Server.IntegrationTesting protected void DotnetPublish(string publishRoot = null) { - using (Logger.BeginScope("dotnet-publish")) - { - if (string.IsNullOrEmpty(DeploymentParameters.TargetFramework)) - { - throw new Exception($"A target framework must be specified in the deployment parameters for applications that require publishing before deployment"); - } - - DeploymentParameters.PublishedApplicationRootPath = publishRoot ?? Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - - var parameters = $"publish " - + $" --output \"{DeploymentParameters.PublishedApplicationRootPath}\"" - + $" --framework {DeploymentParameters.TargetFramework}" - + $" --configuration {DeploymentParameters.Configuration}" - + (DeploymentParameters.RestoreOnPublish - ? string.Empty - : " --no-restore -p:VerifyMatchingImplicitPackageVersion=false"); - // Set VerifyMatchingImplicitPackageVersion to disable errors when Microsoft.NETCore.App's version is overridden externally - // This verification doesn't matter if we are skipping restore during tests. - - if (DeploymentParameters.ApplicationType == ApplicationType.Standalone) - { - parameters += $" --runtime {GetRuntimeIdentifier()}"; - } - - parameters += $" {DeploymentParameters.AdditionalPublishParameters}"; - - var startInfo = new ProcessStartInfo - { - FileName = DotnetCommandName, - Arguments = parameters, - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardError = true, - RedirectStandardOutput = true, - WorkingDirectory = DeploymentParameters.ApplicationPath, - }; - - AddEnvironmentVariablesToProcess(startInfo, DeploymentParameters.PublishEnvironmentVariables); - - var hostProcess = new Process() { StartInfo = startInfo }; - - Logger.LogInformation($"Executing command {DotnetCommandName} {parameters}"); - - hostProcess.StartAndCaptureOutAndErrToLogger("dotnet-publish", Logger); - - // A timeout is passed to Process.WaitForExit() for two reasons: - // - // 1. When process output is read asynchronously, WaitForExit() without a timeout blocks until child processes - // are killed, which can cause hangs due to MSBuild NodeReuse child processes started by dotnet.exe. - // With a timeout, WaitForExit() returns when the parent process is killed and ignores child processes. - // https://stackoverflow.com/a/37983587/102052 - // - // 2. If "dotnet publish" does hang indefinitely for some reason, tests should fail fast with an error message. - const int timeoutMinutes = 5; - if (hostProcess.WaitForExit(milliseconds: timeoutMinutes * 60 * 1000)) - { - if (hostProcess.ExitCode != 0) - { - var message = $"{DotnetCommandName} publish exited with exit code : {hostProcess.ExitCode}"; - Logger.LogError(message); - throw new Exception(message); - } - } - else - { - var message = $"{DotnetCommandName} publish failed to exit after {timeoutMinutes} minutes"; - Logger.LogError(message); - throw new Exception(message); - } - - Logger.LogInformation($"{DotnetCommandName} publish finished with exit code : {hostProcess.ExitCode}"); - } + var publisher = DeploymentParameters.ApplicationPublisher ?? new ApplicationPublisher(DeploymentParameters.ApplicationPath); + _publishedApplication = publisher.Publish(DeploymentParameters, Logger).GetAwaiter().GetResult(); + DeploymentParameters.PublishedApplicationRootPath = _publishedApplication.Path; } protected void CleanPublishedOutput() @@ -168,11 +97,7 @@ namespace Microsoft.AspNetCore.Server.IntegrationTesting } else { - RetryHelper.RetryOperation( - () => Directory.Delete(DeploymentParameters.PublishedApplicationRootPath, true), - e => Logger.LogWarning($"Failed to delete directory : {e.Message}"), - retryCount: 3, - retryDelayMilliseconds: 100); + _publishedApplication.Dispose(); } } } @@ -219,26 +144,8 @@ namespace Microsoft.AspNetCore.Server.IntegrationTesting protected void AddEnvironmentVariablesToProcess(ProcessStartInfo startInfo, IDictionary environmentVariables) { var environment = startInfo.Environment; - SetEnvironmentVariable(environment, "ASPNETCORE_ENVIRONMENT", DeploymentParameters.EnvironmentName); - - foreach (var environmentVariable in environmentVariables) - { - SetEnvironmentVariable(environment, environmentVariable.Key, environmentVariable.Value); - } - } - - protected void SetEnvironmentVariable(IDictionary environment, string name, string value) - { - if (value == null) - { - Logger.LogInformation("Removing environment variable {name}", name); - environment.Remove(name); - } - else - { - Logger.LogInformation("SET {name}={value}", name, value); - environment[name] = value; - } + ProcessHelpers.SetEnvironmentVariable(environment, "ASPNETCORE_ENVIRONMENT", DeploymentParameters.EnvironmentName, Logger); + ProcessHelpers.AddEnvironmentVariablesToProcess(startInfo, environmentVariables, Logger); } protected void InvokeUserApplicationCleanup() @@ -286,26 +193,5 @@ namespace Microsoft.AspNetCore.Server.IntegrationTesting } public abstract void Dispose(); - - private string GetRuntimeIdentifier() - { - var architecture = DeploymentParameters.RuntimeArchitecture; - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - return "win7-" + architecture; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - return "linux-" + architecture; - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - return "osx-" + architecture; - } - else - { - throw new InvalidOperationException("Unrecognized operation system platform"); - } - } } } diff --git a/src/Microsoft.AspNetCore.Server.IntegrationTesting/ProcessHelpers.cs b/src/Microsoft.AspNetCore.Server.IntegrationTesting/ProcessHelpers.cs new file mode 100644 index 00000000..c00b6c2b --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.IntegrationTesting/ProcessHelpers.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.IntegrationTesting +{ + internal class ProcessHelpers + { + public static void AddEnvironmentVariablesToProcess(ProcessStartInfo startInfo, IDictionary environmentVariables, ILogger logger) + { + var environment = startInfo.Environment; + + foreach (var environmentVariable in environmentVariables) + { + SetEnvironmentVariable(environment, environmentVariable.Key, environmentVariable.Value, logger); + } + } + + public static void SetEnvironmentVariable(IDictionary environment, string name, string value, ILogger logger) + { + if (value == null) + { + logger.LogInformation("Removing environment variable {name}", name); + environment.Remove(name); + } + else + { + logger.LogInformation("SET {name}={value}", name, value); + environment[name] = value; + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Server.IntegrationTesting/PublishedApplication.cs b/src/Microsoft.AspNetCore.Server.IntegrationTesting/PublishedApplication.cs new file mode 100644 index 00000000..3913a7e9 --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.IntegrationTesting/PublishedApplication.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.IntegrationTesting +{ + public class PublishedApplication: IDisposable + { + private readonly ILogger _logger; + + public string Path { get; } + + public PublishedApplication(string path, ILogger logger) + { + _logger = logger; + Path = path; + } + + public void Dispose() + { + RetryHelper.RetryOperation( + () => Directory.Delete(Path, true), + e => _logger.LogWarning($"Failed to delete directory : {e.Message}"), + retryCount: 3, + retryDelayMilliseconds: 100); + } + } +} \ No newline at end of file