// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\r // SPDX-License-Identifier: Apache-2.0 using System; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Net.NetworkInformation; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Amazon.Runtime; using AWS.Deploy.ServerMode.Client.Utilities; using Newtonsoft.Json; namespace AWS.Deploy.ServerMode.Client { /// /// Helper class that allows launching deployment tool in server mode. /// It abstracts the server mode setup, CLI command execution and retries when desired port is unavailable. /// public interface IServerModeSession { /// /// Starts deployment tool in server mode. /// It creates symmetric key and tries to setup the server mode. /// It also handles the retries when a desired port is unavailable in the provided range. /// /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// Throws when deployment tool server failed to start for un unknown reason. /// Throws when deployment tool server failed to start due to unavailability of free ports. Task Start(CancellationToken cancellationToken); /// /// Builds client using the cached base URL. /// If succeeded, is initialized with current session client. /// /// Func to that provides AWS credentials /// client to initialize. /// True, if is initialized successfully. bool TryGetRestAPIClient(Func> credentialsGenerator, out IRestAPIClient? restApiClient); /// /// Builds client using the cached base URL. /// If succeeded, is initialized with current session client. /// /// client to initialize. /// True, if is initialized successfully. bool TryGetDeploymentCommunicationClient(out IDeploymentCommunicationClient? deploymentCommunicationClient); /// /// Returns the status of the deployment server by checking /api/v1/health API. /// Returns true, if the deployment server returns a success HTTP code. /// /// A cancellation token that can be used by other objects or threads to receive notice of cancellation. /// Returns true, if the deployment server returns a success HTTP code. Task IsAlive(CancellationToken cancellationToken); } public class ServerModeSession : IServerModeSession, IDisposable { private const int TCP_PORT_ERROR = -100; private readonly int _startPort; private readonly int _endPort; private readonly CommandLineWrapper _commandLineWrapper; private readonly HttpClientHandler _httpClientHandler; private readonly TimeSpan _serverTimeout; private readonly string _deployToolPath; private string? _baseUrl; private Aes? _aes; private string HealthUrl { get { if (_baseUrl == null) { throw new InvalidOperationException($"{nameof(_baseUrl)} must not be null."); } return $"{_baseUrl}/api/v1/health"; } } public ServerModeSession(int startPort = 10000, int endPort = 10100, string deployToolPath = "", bool diagnosticLoggingEnabled = false) : this(new CommandLineWrapper(diagnosticLoggingEnabled), new HttpClientHandler(), TimeSpan.FromSeconds(60), startPort, endPort, deployToolPath) { } public ServerModeSession(CommandLineWrapper commandLineWrapper, HttpClientHandler httpClientHandler, TimeSpan serverTimeout, int startPort = 10000, int endPort = 10100, string deployToolPath = "") { _startPort = startPort; _endPort = endPort; _commandLineWrapper = commandLineWrapper; _httpClientHandler = httpClientHandler; _serverTimeout = serverTimeout; _deployToolPath = deployToolPath; } public async Task Start(CancellationToken cancellationToken) { var deployToolRoot = "dotnet aws"; if (!string.IsNullOrEmpty(_deployToolPath)) { if (!PathUtilities.IsDeployToolPathValid(_deployToolPath)) throw new InvalidAssemblyReferenceException("The specified assembly location is invalid."); deployToolRoot = _deployToolPath; } var currentProcessId = Process.GetCurrentProcess().Id; for (var port = _startPort; port <= _endPort; port++) { // This ensures that deploy tool CLI doesn't try on the in-use port // because server availability task will return success response for // an in-use port if (IsPortInUse(port)) { continue; } _aes = Aes.Create(); _aes.GenerateKey(); var keyInfo = new EncryptionKeyInfo( EncryptionKeyInfo.VERSION_1_0, Convert.ToBase64String(_aes.Key)); var keyInfoStdin = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(keyInfo))); var command = $"\"{deployToolRoot}\" server-mode --port {port} --parent-pid {currentProcessId}"; var startServerTask = _commandLineWrapper.Run(command, keyInfoStdin); _baseUrl = $"http://localhost:{port}"; var isServerAvailableTask = IsServerAvailable(cancellationToken); if (isServerAvailableTask == await Task.WhenAny(startServerTask, isServerAvailableTask).ConfigureAwait(false)) { // The server timed out, this isn't a transient error, therefore, we throw if (!isServerAvailableTask.Result) { throw new InternalServerModeException($"\"{command}\" failed for unknown reason."); } // Server has started, it is safe to return return; } // For -100 errors, we want to check all the ports in the configured port range // If the error code other than -100, this is an unexpected exit code. if (startServerTask.Result.ExitCode != TCP_PORT_ERROR) { throw new InternalServerModeException( string.IsNullOrEmpty(startServerTask.Result.StandardError) ? $"\"{command}\" failed for unknown reason." : startServerTask.Result.StandardError); } } throw new PortUnavailableException($"Free port unavailable in {_startPort}-{_endPort} range."); } public bool TryGetRestAPIClient(Func> credentialsGenerator, out IRestAPIClient? restApiClient) { if (_baseUrl == null || _aes == null) { restApiClient = null; return false; } var httpClient = ServerModeHttpClientFactory.ConstructHttpClient(credentialsGenerator, _aes); restApiClient = new RestAPIClient(_baseUrl, httpClient); return true; } /// /// Builds client based on a deploy tool server mode running on the specified port. /// public static bool TryGetRestAPIClient(int port, Aes? aes, Func> credentialsGenerator, out IRestAPIClient? restApiClient) { // This ensures that deploy tool CLI doesn't try on the in-use port // because server availability task will return success response for // an in-use port if (!IsPortInUse(port)) { restApiClient = null; throw new PortUnavailableException($"There is no running process on port {port}."); } var baseUrl = $"http://localhost:{port}"; var httpClient = ServerModeHttpClientFactory.ConstructHttpClient(credentialsGenerator, aes); restApiClient = new RestAPIClient(baseUrl, httpClient); return true; } public bool TryGetDeploymentCommunicationClient(out IDeploymentCommunicationClient? deploymentCommunicationClient) { if (_baseUrl == null || _aes == null) { deploymentCommunicationClient = null; return false; } deploymentCommunicationClient = new DeploymentCommunicationClient(_baseUrl); return true; } public async Task IsAlive(CancellationToken cancellationToken) { var client = new HttpClient(_httpClientHandler); try { var response = await client.GetAsync(HealthUrl, cancellationToken); return response.IsSuccessStatusCode; } catch (Exception) { return false; } } #region Private methods private Task IsServerAvailable(CancellationToken cancellationToken) { return WaitUntilHelper.WaitUntilSuccessStatusCode( HealthUrl, _httpClientHandler, TimeSpan.FromMilliseconds(100), _serverTimeout, cancellationToken); } private static bool IsPortInUse(int port) { var ipGlobalProperties = IPGlobalProperties.GetIPGlobalProperties(); var listeners = ipGlobalProperties.GetActiveTcpListeners(); return listeners.Any(x => x.Port == port); } #endregion #region Disposable protected virtual void Dispose(bool disposing) { if (disposing) { _aes?.Dispose(); _aes = null; } } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } #endregion private class EncryptionKeyInfo { public const string VERSION_1_0 = "1.0"; public string Version { get; set; } public string Key { get; set; } public string? IV { get; set; } public EncryptionKeyInfo(string version, string key, string? iv = null) { Version = version; Key = key; IV = iv; } } } }