diff --git a/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/RemotePSSessionHelper.ps1 b/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/RemotePSSessionHelper.ps1 new file mode 100644 index 0000000000..7f6418346f --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/RemotePSSessionHelper.ps1 @@ -0,0 +1,55 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)] + [string]$serverName, + + [Parameter(Mandatory=$true)] + [string]$accountName, + + [Parameter(Mandatory=$true)] + [string]$accountPassword, + + [Parameter(Mandatory=$true)] + [string]$executablePath, + + [Parameter(Mandatory=$true)] + [string]$serverType, + + [Parameter(Mandatory=$true)] + [string]$serverAction, + + [Parameter(Mandatory=$true)] + [string]$applicationBaseUrl, + + [Parameter(Mandatory=$false)] + [string]$environmentVariables +) + +Write-Host "`nExecuting deployment helper script on machine '$serverName'" +Write-Host "`nStarting a powershell session to machine '$serverName'" + +$securePassword = ConvertTo-SecureString $accountPassword -AsPlainText -Force +$credentials= New-Object System.Management.Automation.PSCredential ($accountName, $securePassword) +$psSession = New-PSSession -ComputerName $serverName -credential $credentials + +$remoteResult="0" +if ($serverAction -eq "StartServer") +{ + Write-Host "Starting the application on machine '$serverName'" + $startServerScriptPath = "$PSScriptRoot\StartServer.ps1" + $remoteResult=Invoke-Command -Session $psSession -FilePath $startServerScriptPath -ArgumentList $executablePath, $serverType, $serverName, $applicationBaseUrl, $environmentVariables +} +else +{ + Write-Host "Stopping the application on machine '$serverName'" + $stopServerScriptPath = "$PSScriptRoot\StopServer.ps1" + $serverProcessName = [System.IO.Path]::GetFileNameWithoutExtension($executablePath) + $remoteResult=Invoke-Command -Session $psSession -FilePath $stopServerScriptPath -ArgumentList $serverProcessName, $serverType, $serverName +} + +Remove-PSSession $psSession + +# NOTE: Currenty there is no straight forward way to get the exit code from a remotely executing session, so +# we print out the exit code in the remote script and capture it's output to get the exit code. +$finalExitCode=$remoteResult[$remoteResult.Length-1] +exit $finalExitCode \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/RemoteWindowsDeployer.cs b/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/RemoteWindowsDeployer.cs new file mode 100644 index 0000000000..b1017b1743 --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/RemoteWindowsDeployer.cs @@ -0,0 +1,286 @@ +// 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.Linq; +using System.Reflection; +using System.Text; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Server.Testing +{ + public class RemoteWindowsDeployer : ApplicationDeployer + { + /// + /// Example: If the share path is '\\foo\bar', then this returns the full path to the + /// deployed folder. Example: '\\foo\bar\048f6c99-de3e-488a-8020-f9eb277818d9' + /// + private string _deployedFolderPathInFileShare; + private readonly RemoteWindowsDeploymentParameters _deploymentParameters; + private bool _isDisposed; + private static readonly Lazy _scripts = new Lazy(() => CopyEmbeddedScriptFilesToDisk()); + + public RemoteWindowsDeployer(RemoteWindowsDeploymentParameters deploymentParameters, ILogger logger) + : base(deploymentParameters, logger) + { + _deploymentParameters = deploymentParameters; + + if (_deploymentParameters.ServerType != ServerType.IIS + && _deploymentParameters.ServerType != ServerType.Kestrel + && _deploymentParameters.ServerType != ServerType.WebListener) + { + throw new InvalidOperationException($"Server type {_deploymentParameters.ServerType} is not supported for remote deployment." + + $" Supported server types are {nameof(ServerType.Kestrel)}, {nameof(ServerType.IIS)} and {nameof(ServerType.WebListener)}"); + } + + if (string.IsNullOrWhiteSpace(_deploymentParameters.ServerName)) + { + throw new ArgumentException($"Invalid value '{_deploymentParameters.ServerName}' for {nameof(RemoteWindowsDeploymentParameters.ServerName)}"); + } + + if (string.IsNullOrWhiteSpace(_deploymentParameters.ServerAccountName)) + { + throw new ArgumentException($"Invalid value '{_deploymentParameters.ServerAccountName}' for {nameof(RemoteWindowsDeploymentParameters.ServerAccountName)}." + + " Account credentials are required to enable creating a powershell session to the remote server."); + } + + if (string.IsNullOrWhiteSpace(_deploymentParameters.ServerAccountPassword)) + { + throw new ArgumentException($"Invalid value '{_deploymentParameters.ServerAccountPassword}' for {nameof(RemoteWindowsDeploymentParameters.ServerAccountPassword)}." + + " Account credentials are required to enable creating a powershell session to the remote server."); + } + + if (string.IsNullOrWhiteSpace(_deploymentParameters.RemoteServerFileSharePath)) + { + throw new ArgumentException($"Invalid value for {nameof(RemoteWindowsDeploymentParameters.RemoteServerFileSharePath)}." + + " . A file share is required to copy the application's published output."); + } + + if (string.IsNullOrWhiteSpace(_deploymentParameters.RemoteServerRelativeExecutablePath)) + { + throw new ArgumentException($"Invalid value for {nameof(RemoteWindowsDeploymentParameters.RemoteServerRelativeExecutablePath)}." + + " This is the name of the executable in the published output which needs to be executed on the remote server."); + } + + if (string.IsNullOrWhiteSpace(_deploymentParameters.ApplicationBaseUriHint)) + { + throw new ArgumentException($"Invalid value for {nameof(RemoteWindowsDeploymentParameters.ApplicationBaseUriHint)}."); + } + } + + public override DeploymentResult Deploy() + { + if (_isDisposed) + { + throw new ObjectDisposedException("This instance of deployer has already been disposed."); + } + + // Publish the app to a local temp folder on the machine where the test is running + DotnetPublish(); + + var folderId = Guid.NewGuid().ToString(); + _deployedFolderPathInFileShare = Path.Combine(_deploymentParameters.RemoteServerFileSharePath, folderId); + + DirectoryCopy( + _deploymentParameters.PublishedApplicationRootPath, + _deployedFolderPathInFileShare, + copySubDirs: true); + Logger.LogInformation($"Copied the locally published folder to the file share path '{_deployedFolderPathInFileShare}'"); + + RunScript("StartServer"); + + return new DeploymentResult + { + ApplicationBaseUri = DeploymentParameters.ApplicationBaseUriHint, + DeploymentParameters = DeploymentParameters + }; + } + + public override void Dispose() + { + if (_isDisposed) + { + return; + } + + _isDisposed = true; + + try + { + Logger.LogInformation($"Stopping the application on the server '{_deploymentParameters.ServerName}'"); + RunScript("StopServer"); + } + catch (Exception ex) + { + Logger.LogWarning(0, "Failed to stop the server.", ex); + } + + try + { + Logger.LogInformation($"Deleting the deployed folder '{_deployedFolderPathInFileShare}'"); + Directory.Delete(_deployedFolderPathInFileShare, recursive: true); + } + catch (Exception ex) + { + Logger.LogWarning(0, $"Failed to delete the deployed folder '{_deployedFolderPathInFileShare}'.", ex); + } + + try + { + Logger.LogInformation($"Deleting the locally published folder '{DeploymentParameters.PublishedApplicationRootPath}'"); + Directory.Delete(DeploymentParameters.PublishedApplicationRootPath, recursive: true); + } + catch (Exception ex) + { + Logger.LogWarning(0, $"Failed to delete the locally published folder '{DeploymentParameters.PublishedApplicationRootPath}'.", ex); + } + } + + private void RunScript(string serverAction) + { + var remotePSSessionHelperScript = _scripts.Value.RemotePSSessionHelper; + + var parameterBuilder = new StringBuilder(); + parameterBuilder.Append($"\"{remotePSSessionHelperScript}\""); + parameterBuilder.Append($" -serverName {_deploymentParameters.ServerName}"); + parameterBuilder.Append($" -accountName {_deploymentParameters.ServerAccountName}"); + parameterBuilder.Append($" -accountPassword {_deploymentParameters.ServerAccountPassword}"); + parameterBuilder.Append($" -executablePath \"{Path.Combine(_deployedFolderPathInFileShare, _deploymentParameters.RemoteServerRelativeExecutablePath)}\""); + parameterBuilder.Append($" -serverType {_deploymentParameters.ServerType}"); + parameterBuilder.Append($" -serverAction {serverAction}"); + parameterBuilder.Append($" -applicationBaseUrl {_deploymentParameters.ApplicationBaseUriHint}"); + var environmentVariables = string.Join("`,", _deploymentParameters.EnvironmentVariables.Select(envVariable => $"{envVariable.Key}={envVariable.Value}")); + parameterBuilder.Append($" -environmentVariables \"{environmentVariables}\""); + + var startInfo = new ProcessStartInfo + { + FileName = "powershell.exe", + Arguments = parameterBuilder.ToString(), + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true, + RedirectStandardOutput = true, + RedirectStandardInput = true + }; + + using (var runScriptsOnRemoteServerProcess = new Process() { StartInfo = startInfo }) + { + runScriptsOnRemoteServerProcess.EnableRaisingEvents = true; + runScriptsOnRemoteServerProcess.ErrorDataReceived += (sender, dataArgs) => + { + if (!string.IsNullOrEmpty(dataArgs.Data)) + { + Logger.LogWarning($"[{_deploymentParameters.ServerName}]: {dataArgs.Data}"); + } + }; + + runScriptsOnRemoteServerProcess.OutputDataReceived += (sender, dataArgs) => + { + if (!string.IsNullOrEmpty(dataArgs.Data)) + { + Logger.LogInformation($"[{_deploymentParameters.ServerName}]: {dataArgs.Data}"); + } + }; + + runScriptsOnRemoteServerProcess.Start(); + runScriptsOnRemoteServerProcess.BeginErrorReadLine(); + runScriptsOnRemoteServerProcess.BeginOutputReadLine(); + runScriptsOnRemoteServerProcess.WaitForExit((int)TimeSpan.FromMinutes(1).TotalMilliseconds); + + if (runScriptsOnRemoteServerProcess.HasExited && runScriptsOnRemoteServerProcess.ExitCode != 0) + { + throw new Exception($"Failed to execute the script on '{_deploymentParameters.ServerName}'."); + } + } + } + + private static void DirectoryCopy(string sourceDirName, string destDirName, bool copySubDirs) + { + var dir = new DirectoryInfo(sourceDirName); + + if (!dir.Exists) + { + throw new DirectoryNotFoundException( + "Source directory does not exist or could not be found: " + + sourceDirName); + } + + var dirs = dir.GetDirectories(); + if (!Directory.Exists(destDirName)) + { + Directory.CreateDirectory(destDirName); + } + + var files = dir.GetFiles(); + foreach (var file in files) + { + var temppath = Path.Combine(destDirName, file.Name); + file.CopyTo(temppath, false); + } + + if (copySubDirs) + { + foreach (var subdir in dirs) + { + var temppath = Path.Combine(destDirName, subdir.Name); + DirectoryCopy(subdir.FullName, temppath, copySubDirs); + } + } + } + + private static Scripts CopyEmbeddedScriptFilesToDisk() + { + var embeddedFileNames = new[] { "RemotePSSessionHelper.ps1", "StartServer.ps1", "StopServer.ps1" }; + + // Copy the scripts from this assembly's embedded resources to the temp path on the machine where these + // tests are being run + var embeddedFileProvider = new EmbeddedFileProvider( + typeof(RemoteWindowsDeployer).GetTypeInfo().Assembly, + "Microsoft.AspNetCore.Server.Testing.Deployers.RemoteWindowsDeployer"); + + var filesOnDisk = new string[embeddedFileNames.Length]; + for (var i = 0; i < embeddedFileNames.Length; i++) + { + var embeddedFileName = embeddedFileNames[i]; + var physicalFilePath = Path.Combine(Path.GetTempPath(), embeddedFileName); + var sourceStream = embeddedFileProvider + .GetFileInfo(embeddedFileName) + .CreateReadStream(); + + using (sourceStream) + { + var destinationStream = File.Create(physicalFilePath); + using (destinationStream) + { + sourceStream.CopyTo(destinationStream); + } + } + + filesOnDisk[i] = physicalFilePath; + } + + var scripts = new Scripts(filesOnDisk[0], filesOnDisk[1], filesOnDisk[2]); + + return scripts; + } + + private class Scripts + { + public Scripts(string remotePSSessionHelper, string startServer, string stopServer) + { + RemotePSSessionHelper = remotePSSessionHelper; + StartServer = startServer; + StopServer = stopServer; + } + + public string RemotePSSessionHelper { get; } + + public string StartServer { get; } + + public string StopServer { get; } + } + } +} diff --git a/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/RemoteWindowsDeploymentParameters.cs b/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/RemoteWindowsDeploymentParameters.cs new file mode 100644 index 0000000000..e1d9d74b96 --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/RemoteWindowsDeploymentParameters.cs @@ -0,0 +1,43 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// See License.txt in the project root for license information + +namespace Microsoft.AspNetCore.Server.Testing +{ + public class RemoteWindowsDeploymentParameters : DeploymentParameters + { + public RemoteWindowsDeploymentParameters( + string applicationPath, + ServerType serverType, + RuntimeFlavor runtimeFlavor, + RuntimeArchitecture runtimeArchitecture, + string remoteServerFileSharePath, + string remoteServerName, + string remoteServerAccountName, + string remoteServerAccountPassword, + string remoteServerRelativeExecutablePath) + : base(applicationPath, serverType, runtimeFlavor, runtimeArchitecture) + { + RemoteServerFileSharePath = remoteServerFileSharePath; + ServerName = remoteServerName; + ServerAccountName = remoteServerAccountName; + ServerAccountPassword = remoteServerAccountPassword; + RemoteServerRelativeExecutablePath = remoteServerRelativeExecutablePath; + } + + public string ServerName { get; } + + public string ServerAccountName { get; } + + public string ServerAccountPassword { get; } + + /// + /// The full path to the remote server's file share + /// + public string RemoteServerFileSharePath { get; } + + /// + /// The relative path to the executable in the published output + /// + public string RemoteServerRelativeExecutablePath { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/StartServer.ps1 b/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/StartServer.ps1 new file mode 100644 index 0000000000..9377614e5e --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/StartServer.ps1 @@ -0,0 +1,51 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)] + [string]$executablePath, + + [Parameter(Mandatory=$true)] + [string]$serverType, + + [Parameter(Mandatory=$true)] + [string]$serverName, + + [Parameter(Mandatory=$true)] + [string]$applicationBaseUrl, + + # These are of the format: key1=value1,key2=value2,key3=value3 + [Parameter(Mandatory=$false)] + [string]$environmentVariables +) + +Write-Host "Executing the start server script on machine '$serverName'" + +IF (-Not [string]::IsNullOrWhitespace($environmentVariables)) +{ + Write-Host "Setting up environment variables" + foreach ($envVariablePair in $environmentVariables.Split(",")){ + $pair=$envVariablePair.Split("="); + [Environment]::SetEnvironmentVariable($pair[0], $pair[1]) + } +} + +if ($serverType -eq "IIS") +{ + throw [System.NotImplementedException] "IIS deployment scenarios not yet implemented." +} +elseif ($serverType -eq "Kestrel") +{ + Write-Host "Starting the process '$executablePath'" + & $executablePath --server.urls $applicationBaseUrl +} +elseif ($serverType -eq "WebListener") +{ + Write-Host "Starting the process '$executablePath'" + & $executablePath --server.urls $applicationBaseUrl --server "Microsoft.AspNetCore.Server.WebListener" +} +else +{ + throw [System.InvalidOperationException] "Server type '$serverType' is not supported." +} + +# NOTE: Make sure this is the last statement in this script as its used to get the exit code of this script +$LASTEXITCODE \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/StopServer.ps1 b/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/StopServer.ps1 new file mode 100644 index 0000000000..dd8e618c96 --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.Testing/Deployers/RemoteWindowsDeployer/StopServer.ps1 @@ -0,0 +1,59 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)] + [string]$serverProcessName, + + [Parameter(Mandatory=$true)] + [string]$serverType, + + [Parameter(Mandatory=$true)] + [string]$serverName +) + +function DoesCommandExist($command) +{ + $oldPreference = $ErrorActionPreference + $ErrorActionPreference="stop" + + try + { + if (Get-Command $command) + { + return $true + } + } + catch + { + Write-Host "Command '$command' does not exist" + return $false + } + finally + { + $ErrorActionPreference=$oldPreference + } +} + +Write-Host "Executing the stop server script on machine '$serverName'" + +if ($serverType -eq "IIS") +{ + throw [System.NotImplementedException] "IIS deployment scenarios not yet implemented." +} +else +{ + Write-Host "Stopping the process '$serverProcessName'" + $serverProcess=Get-Process -Name "$serverProcessName" + + if (DoesCommandExist("taskkill")) + { + # Kill the parent and child processes + & taskkill /pid $serverProcess.Id /t /f + } + else + { + Stop-Process -Id $serverProcess.Id + } +} + +# NOTE: Make sure this is the last statement in this script as its used to get the exit code of this script +$LASTEXITCODE \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Server.Testing/project.json b/src/Microsoft.AspNetCore.Server.Testing/project.json index d328faee03..382f7b50f8 100644 --- a/src/Microsoft.AspNetCore.Server.Testing/project.json +++ b/src/Microsoft.AspNetCore.Server.Testing/project.json @@ -13,9 +13,15 @@ "type": "git", "url": "git://github.com/aspnet/hosting" }, + "resource": [ + "Deployers/RemoteWindowsDeployer/RemotePSSessionHelper.ps1", + "Deployers/RemoteWindowsDeployer/StartServer.ps1", + "Deployers/RemoteWindowsDeployer/StopServer.ps1" + ], "dependencies": { "Microsoft.AspNetCore.Testing": "1.0.0-*", "Microsoft.Extensions.Logging.Abstractions": "1.0.0-*", + "Microsoft.Extensions.FileProviders.Embedded": "1.0.0-*", "Microsoft.Extensions.PlatformAbstractions": "1.0.0-*", "Microsoft.Extensions.Process.Sources": { "type": "build", diff --git a/src/Microsoft.AspNetCore.Server.Testing/xunit/SkipIfEnvironmentVariableNotEnabled.cs b/src/Microsoft.AspNetCore.Server.Testing/xunit/SkipIfEnvironmentVariableNotEnabled.cs new file mode 100644 index 0000000000..34e22cf908 --- /dev/null +++ b/src/Microsoft.AspNetCore.Server.Testing/xunit/SkipIfEnvironmentVariableNotEnabled.cs @@ -0,0 +1,41 @@ +// 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 Microsoft.AspNetCore.Testing.xunit; + +namespace Microsoft.AspNetCore.Server.Testing +{ + /// + /// Skip test if a given environment variable is not enabled. To enable the test, set environment variable + /// to "true" for the test process. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + public class SkipIfEnvironmentVariableNotEnabledAttribute : Attribute, ITestCondition + { + private readonly string _environmentVariableName; + + public SkipIfEnvironmentVariableNotEnabledAttribute(string environmentVariableName) + { + _environmentVariableName = environmentVariableName; + } + + public bool IsMet + { + get + { + return string.Compare(Environment.GetEnvironmentVariable(_environmentVariableName), "true", ignoreCase: true) == 0; + } + } + + public string SkipReason + { + get + { + return $"To run this test, set the environment variable {_environmentVariableName}=\"true\". {AdditionalInfo}"; + } + } + + public string AdditionalInfo { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Server.Testing/xunit/SkipIfIISVariationsNotEnabledAttribute.cs b/src/Microsoft.AspNetCore.Server.Testing/xunit/SkipIfIISVariationsNotEnabledAttribute.cs deleted file mode 100644 index 2150a43c61..0000000000 --- a/src/Microsoft.AspNetCore.Server.Testing/xunit/SkipIfIISVariationsNotEnabledAttribute.cs +++ /dev/null @@ -1,33 +0,0 @@ -// 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 Microsoft.AspNetCore.Testing.xunit; - -namespace Microsoft.AspNetCore.Server.Testing -{ - /// - /// Skip test if IIS variations are not enabled. To enable set environment variable - /// IIS_VARIATIONS_ENABLED=true for the test process. - /// - [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] - public class SkipIfIISVariationsNotEnabledAttribute : Attribute, ITestCondition - { - public bool IsMet - { - get - { - return Environment.GetEnvironmentVariable("IIS_VARIATIONS_ENABLED") == "true"; - } - } - - public string SkipReason - { - get - { - return "Skipping IIS variation of tests. " + - "To run the IIS variations, setup IIS and set the environment variable IIS_VARIATIONS_ENABLED=true."; - } - } - } -} \ No newline at end of file