// 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.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.InteropServices; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using Microsoft.AspNetCore.Server.IntegrationTesting.Common; using Microsoft.AspNetCore.Testing; using Microsoft.Extensions.Logging; namespace Microsoft.AspNetCore.Server.IntegrationTesting.IIS { /// /// Deployment helper for IISExpress. /// public class IISExpressDeployer : IISDeployerBase { private const string IISExpressRunningMessage = "IIS Express is running."; private const string FailedToInitializeBindingsMessage = "Failed to initialize site bindings"; private const string UnableToStartIISExpressMessage = "Unable to start iisexpress."; private const int MaximumAttempts = 5; private readonly TimeSpan ShutdownTimeSpan = TimeSpan.FromSeconds(60); private static readonly Regex UrlDetectorRegex = new Regex(@"^\s*Successfully registered URL ""(?[^""]+)"" for site.*$"); private Process _hostProcess; public IISExpressDeployer(DeploymentParameters deploymentParameters, ILoggerFactory loggerFactory) : base(new IISDeploymentParameters(deploymentParameters), loggerFactory) { } public IISExpressDeployer(IISDeploymentParameters deploymentParameters, ILoggerFactory loggerFactory) : base(deploymentParameters, loggerFactory) { } public override async Task DeployAsync() { using (Logger.BeginScope("Deployment")) { // Start timer StartTimer(); // For an unpublished application the dllroot points pre-built dlls like projectdir/bin/debug/net461/ // and contentRoot points to the project directory so you get things like static assets. // For a published app both point to the publish directory. var dllRoot = CheckIfPublishIsRequired(); var contentRoot = string.Empty; if (DeploymentParameters.PublishApplicationBeforeDeployment) { DotnetPublish(); contentRoot = DeploymentParameters.PublishedApplicationRootPath; } else { // Core+Standalone always publishes. This must be Clr+Standalone or Core+Portable. // Update processPath and arguments for our current scenario contentRoot = DeploymentParameters.ApplicationPath; var executableExtension = DeploymentParameters.ApplicationType == ApplicationType.Portable ? ".dll" : ".exe"; var entryPoint = Path.Combine(dllRoot, DeploymentParameters.ApplicationName + executableExtension); var executableName = string.Empty; var executableArgs = string.Empty; if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.CoreClr && DeploymentParameters.ApplicationType == ApplicationType.Portable) { executableName = GetDotNetExeForArchitecture(); executableArgs = entryPoint; } else { executableName = entryPoint; } Logger.LogInformation("Executing: {exe} {args}", executableName, executableArgs); DeploymentParameters.EnvironmentVariables["LAUNCHER_PATH"] = executableName; DeploymentParameters.EnvironmentVariables["LAUNCHER_ARGS"] = executableArgs; // CurrentDirectory will point to bin/{config}/{tfm}, but the config and static files aren't copied, point to the app base instead. Logger.LogInformation("ContentRoot: {path}", DeploymentParameters.ApplicationPath); DeploymentParameters.EnvironmentVariables["ASPNETCORE_CONTENTROOT"] = DeploymentParameters.ApplicationPath; } RunWebConfigActions(contentRoot); var testUri = TestUriHelper.BuildTestUri(ServerType.IISExpress, DeploymentParameters.ApplicationBaseUriHint); // Launch the host process. var (actualUri, hostExitToken) = await StartIISExpressAsync(testUri, contentRoot); Logger.LogInformation("Application ready at URL: {appUrl}", actualUri); // Right now this works only for urls like http://localhost:5001/. Does not work for http://localhost:5001/subpath. return new IISDeploymentResult( LoggerFactory, IISDeploymentParameters, applicationBaseUri: actualUri.ToString(), contentRoot: contentRoot, hostShutdownToken: hostExitToken, hostProcess: _hostProcess); } } private string CheckIfPublishIsRequired() { var targetFramework = DeploymentParameters.TargetFramework; // IISIntegration uses this layout var dllRoot = Path.Combine(DeploymentParameters.ApplicationPath, "bin", DeploymentParameters.RuntimeArchitecture.ToString(), DeploymentParameters.Configuration, targetFramework); if (!Directory.Exists(dllRoot)) { // Most repos use this layout dllRoot = Path.Combine(DeploymentParameters.ApplicationPath, "bin", DeploymentParameters.Configuration, targetFramework); if (!Directory.Exists(dllRoot)) { // The bits we need weren't pre-compiled, compile on publish DeploymentParameters.PublishApplicationBeforeDeployment = true; } else if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.Clr && DeploymentParameters.RuntimeArchitecture == RuntimeArchitecture.x86) { // x64 is the default. Publish to rebuild for the right bitness DeploymentParameters.PublishApplicationBeforeDeployment = true; } } if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.CoreClr && DeploymentParameters.ApplicationType == ApplicationType.Standalone) { // Publish is always required to get the correct standalone files in the output directory DeploymentParameters.PublishApplicationBeforeDeployment = true; } return dllRoot; } private async Task<(Uri url, CancellationToken hostExitToken)> StartIISExpressAsync(Uri uri, string contentRoot) { using (Logger.BeginScope("StartIISExpress")) { var port = uri.Port; if (port == 0) { port = (uri.Scheme == "https") ? TestPortHelper.GetNextSSLPort() : TestPortHelper.GetNextPort(); } Logger.LogInformation("Attempting to start IIS Express on port: {port}", port); PrepareConfig(contentRoot, port); var parameters = string.IsNullOrEmpty(DeploymentParameters.ServerConfigLocation) ? string.Format("/port:{0} /path:\"{1}\" /trace:error /systray:false", uri.Port, contentRoot) : string.Format("/site:{0} /config:{1} /trace:error /systray:false", DeploymentParameters.SiteName, DeploymentParameters.ServerConfigLocation); var iisExpressPath = GetIISExpressPath(); for (var attempt = 0; attempt < MaximumAttempts; attempt++) { Logger.LogInformation("Executing command : {iisExpress} {parameters}", iisExpressPath, parameters); var startInfo = new ProcessStartInfo { FileName = iisExpressPath, Arguments = parameters, UseShellExecute = false, CreateNoWindow = true, RedirectStandardError = true, RedirectStandardOutput = true, // VS sets current directory to C:\Program Files\IIS Express WorkingDirectory = Path.GetDirectoryName(iisExpressPath) }; AddEnvironmentVariablesToProcess(startInfo, DeploymentParameters.EnvironmentVariables); Uri url = null; var started = new TaskCompletionSource(); var process = new Process() { StartInfo = startInfo }; process.OutputDataReceived += (sender, dataArgs) => { if (string.Equals(dataArgs.Data, UnableToStartIISExpressMessage)) { // We completely failed to start and we don't really know why started.TrySetException(new InvalidOperationException("Failed to start IIS Express")); } else if (string.Equals(dataArgs.Data, FailedToInitializeBindingsMessage)) { started.TrySetResult(false); } else if (string.Equals(dataArgs.Data, IISExpressRunningMessage)) { started.TrySetResult(true); } else if (!string.IsNullOrEmpty(dataArgs.Data)) { var m = UrlDetectorRegex.Match(dataArgs.Data); if (m.Success) { url = new Uri(m.Groups["url"].Value); } } }; process.EnableRaisingEvents = true; var hostExitTokenSource = new CancellationTokenSource(); process.Exited += (sender, e) => { Logger.LogInformation("iisexpress Process {pid} shut down", process.Id); // If TrySetResult was called above, this will just silently fail to set the new state, which is what we want started.TrySetException(new Exception($"Command exited unexpectedly with exit code: {process.ExitCode}")); TriggerHostShutdown(hostExitTokenSource); }; process.StartAndCaptureOutAndErrToLogger("iisexpress", Logger); Logger.LogInformation("iisexpress Process {pid} started", process.Id); if (process.HasExited) { Logger.LogError("Host process {processName} {pid} exited with code {exitCode} or failed to start.", startInfo.FileName, process.Id, process.ExitCode); throw new Exception("Failed to start host"); } // Wait for the app to start // The timeout here is large, because we don't know how long the test could need // We cover a lot of error cases above, but I want to make sure we eventually give up and don't hang the build // just in case we missed one -anurse if (!await started.Task.TimeoutAfter(TimeSpan.FromMinutes(10))) { Logger.LogInformation("iisexpress Process {pid} failed to bind to port {port}, trying again", process.Id, port); // Wait for the process to exit and try again process.WaitForExit(30 * 1000); await Task.Delay(1000); // Wait a second to make sure the socket is completely cleaned up } else { _hostProcess = process; Logger.LogInformation("Started iisexpress successfully. Process Id : {processId}, Port: {port}", _hostProcess.Id, port); return (url: url, hostExitToken: hostExitTokenSource.Token); } } var message = $"Failed to initialize IIS Express after {MaximumAttempts} attempts to select a port"; Logger.LogError(message); throw new TimeoutException(message); } } private void PrepareConfig(string contentRoot, int port) { var serverConfig = DeploymentParameters.ServerConfigTemplateContent;; // Config is required. If not present then fall back to one we carry with us. if (string.IsNullOrEmpty(serverConfig)) { using (var stream = GetType().Assembly.GetManifestResourceStream("Microsoft.AspNetCore.Server.IntegrationTesting.IIS.Http.config")) using (var reader = new StreamReader(stream)) { serverConfig = reader.ReadToEnd(); } } XDocument config = XDocument.Parse(serverConfig); // Pass on the applicationhost.config to iis express. With this don't need to pass in the /path /port switches as they are in the applicationHost.config // We take a copy of the original specified applicationHost.Config to prevent modifying the one in the repo. config.Root .RequiredElement("location") .RequiredElement("system.webServer") .RequiredElement("modules") .GetOrAdd("add", "name", DeploymentParameters.AncmVersion.ToString()); ConfigureModuleAndBinding(config.Root, contentRoot, port); if (!DeploymentParameters.PublishApplicationBeforeDeployment) { // The elements normally in the web.config are in the applicationhost.config for unpublished apps. AddAspNetCoreElement(config.Root); } RunServerConfigActions(config.Root, contentRoot); serverConfig = config.ToString(); DeploymentParameters.ServerConfigLocation = Path.GetTempFileName(); Logger.LogDebug("Saving Config to {configPath}", DeploymentParameters.ServerConfigLocation); File.WriteAllText(DeploymentParameters.ServerConfigLocation, serverConfig); } private void AddAspNetCoreElement(XElement config) { var aspNetCore = config .RequiredElement("system.webServer") .GetOrAdd("aspNetCore"); aspNetCore.SetAttributeValue("hostingModel", DeploymentParameters.HostingModel.ToString()); aspNetCore.SetAttributeValue("arguments", "%LAUNCHER_ARGS%"); aspNetCore.SetAttributeValue("processPath", "%LAUNCHER_PATH%"); var handlers = config .RequiredElement("location") .RequiredElement("system.webServer") .RequiredElement("handlers"); var aspNetCoreHandler = handlers .GetOrAdd("add", "name", "aspNetCore"); aspNetCoreHandler.SetAttributeValue("path", "*"); aspNetCoreHandler.SetAttributeValue("verb", "*"); aspNetCoreHandler.SetAttributeValue("modules", DeploymentParameters.AncmVersion.ToString()); aspNetCoreHandler.SetAttributeValue("resourceType", "Unspecified"); // Make aspNetCore handler first aspNetCoreHandler.Remove(); handlers.AddFirst(aspNetCoreHandler); } protected override IEnumerable> GetWebConfigActions() { if (IISDeploymentParameters.PublishApplicationBeforeDeployment) { // For published apps, prefer the content in the web.config, but update it. yield return WebConfigHelpers.AddOrModifyAspNetCoreSection( key: "hostingModel", value: DeploymentParameters.HostingModel == HostingModel.InProcess ? "inprocess" : ""); yield return WebConfigHelpers.AddOrModifyHandlerSection( key: "modules", value: DeploymentParameters.AncmVersion.ToString()); // We assume the x64 dotnet.exe is on the path so we need to provide an absolute path for x86 scenarios. // Only do it for scenarios that rely on dotnet.exe (Core, portable, etc.). if (DeploymentParameters.RuntimeFlavor == RuntimeFlavor.CoreClr && DeploymentParameters.ApplicationType == ApplicationType.Portable && DotNetCommands.IsRunningX86OnX64(DeploymentParameters.RuntimeArchitecture)) { var executableName = DotNetCommands.GetDotNetExecutable(DeploymentParameters.RuntimeArchitecture); if (!File.Exists(executableName)) { throw new Exception($"Unable to find '{executableName}'.'"); } yield return WebConfigHelpers.AddOrModifyAspNetCoreSection("processPath", executableName); } } foreach (var action in base.GetWebConfigActions()) { yield return action; } } private string GetIISExpressPath() { var programFiles = "Program Files"; if (DotNetCommands.IsRunningX86OnX64(DeploymentParameters.RuntimeArchitecture)) { programFiles = "Program Files (x86)"; } // Get path to program files var iisExpressPath = Path.Combine(Environment.GetEnvironmentVariable("SystemDrive") + "\\", programFiles, "IIS Express", "iisexpress.exe"); if (!File.Exists(iisExpressPath)) { throw new Exception("Unable to find IISExpress on the machine: " + iisExpressPath); } return iisExpressPath; } public override void Dispose() { using (Logger.BeginScope("Dispose")) { if (IISDeploymentParameters.GracefulShutdown) { GracefullyShutdownProcess(_hostProcess); } else { ShutDownIfAnyHostProcess(_hostProcess); } if (!string.IsNullOrEmpty(DeploymentParameters.ServerConfigLocation) && File.Exists(DeploymentParameters.ServerConfigLocation)) { // Delete the temp applicationHostConfig that we created. Logger.LogDebug("Deleting applicationHost.config file from {configLocation}", DeploymentParameters.ServerConfigLocation); try { File.Delete(DeploymentParameters.ServerConfigLocation); } catch (Exception exception) { // Ignore delete failures - just write a log. Logger.LogWarning("Failed to delete '{config}'. Exception : {exception}", DeploymentParameters.ServerConfigLocation, exception.Message); } } if (DeploymentParameters.PublishApplicationBeforeDeployment) { CleanPublishedOutput(); } InvokeUserApplicationCleanup(); StopTimer(); } // If by this point, the host process is still running (somehow), throw an error. // A test failure is better than a silent hang and unknown failure later on if (_hostProcess != null && !_hostProcess.HasExited) { throw new Exception($"iisexpress Process {_hostProcess.Id} failed to shutdown"); } } private class WindowsNativeMethods { [DllImport("user32.dll")] internal static extern IntPtr GetTopWindow(IntPtr hWnd); [DllImport("user32.dll")] internal static extern IntPtr GetWindow(IntPtr hWnd, uint uCmd); [DllImport("user32.dll")] internal static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint lpdwProcessId); [DllImport("user32.dll")] internal static extern bool PostMessage(HandleRef hWnd, uint Msg, IntPtr wParam, IntPtr lParam); } private static void SendStopMessageToProcess(int pid) { for (var ptr = WindowsNativeMethods.GetTopWindow(IntPtr.Zero); ptr != IntPtr.Zero; ptr = WindowsNativeMethods.GetWindow(ptr, 2)) { uint num; WindowsNativeMethods.GetWindowThreadProcessId(ptr, out num); if (pid == num) { var hWnd = new HandleRef(null, ptr); WindowsNativeMethods.PostMessage(hWnd, 0x12, IntPtr.Zero, IntPtr.Zero); return; } } } private void GracefullyShutdownProcess(Process hostProcess) { if (hostProcess != null && !hostProcess.HasExited) { // Calling hostProcess.StandardInput.WriteLine("q") with StandardInput redirected // for the process does not work when stopping IISExpress // Also, hostProcess.CloseMainWindow() doesn't work either. // Instead we have to send WM_QUIT to the iisexpress process via pInvokes. // See: https://stackoverflow.com/questions/4772092/starting-and-stopping-iis-express-programmatically SendStopMessageToProcess(hostProcess.Id); if (!hostProcess.WaitForExit((int)ShutdownTimeSpan.TotalMilliseconds)) { throw new InvalidOperationException($"iisexpress Process {hostProcess.Id} failed to gracefully shutdown."); } if (hostProcess.ExitCode != 0) { Logger.LogWarning($"IISExpress exit code is non-zero after graceful shutdown. Exit code: {hostProcess.ExitCode}"); } } else { throw new InvalidOperationException($"iisexpress Process {hostProcess.Id} crashed before shutdown was triggered."); } } } }