Skip to content

Commit

Permalink
perf: When evaluating whether Docker and/or Node are installed, we no…
Browse files Browse the repository at this point in the history
…w cache valid results, only check what is needed for the selected recipe, and add a timeout to prevent runaway commands.
  • Loading branch information
ashovlin committed Jun 20, 2024
1 parent bca37f9 commit ee15007
Show file tree
Hide file tree
Showing 10 changed files with 350 additions and 147 deletions.
6 changes: 3 additions & 3 deletions src/AWS.Deploy.CLI/Utilities/CommandLineWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ public async Task Run(
bool redirectIO = true,
string? stdin = null,
IDictionary<string, string>? environmentVariables = null,
CancellationToken cancelToken = default,
bool needAwsCredentials = false)
bool needAwsCredentials = false,
CancellationToken cancellationToken = default)
{
StringBuilder strOutput = new StringBuilder();
StringBuilder strError = new StringBuilder();
Expand Down Expand Up @@ -116,7 +116,7 @@ public async Task Run(
process.StandardInput.Close();
}

await process.WaitForExitAsync();
await process.WaitForExitAsync(cancellationToken);

if (onComplete != null)
{
Expand Down
37 changes: 23 additions & 14 deletions src/AWS.Deploy.Orchestration/SystemCapabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,19 @@

namespace AWS.Deploy.Orchestration
{
public class SystemCapabilities
{
public Version? NodeJsVersion { get; set; }
public DockerInfo DockerInfo { get; set; }

public SystemCapabilities(
Version? nodeJsVersion,
DockerInfo dockerInfo)
{
NodeJsVersion = nodeJsVersion;
DockerInfo = dockerInfo;
}
}

/// <summary>
/// Information about the user's Docker installation
/// </summary>
public class DockerInfo
{
/// <summary>
/// Whether or not Docker is installed
/// </summary>
public bool DockerInstalled { get; set; }

/// <summary>
/// Docker's current OSType, expected to be "windows" or "linux"
/// </summary>
public string DockerContainerType { get; set; }

public DockerInfo(
Expand All @@ -30,6 +26,19 @@ public DockerInfo(
}
}

/// <summary>
/// Information about the user's NodeJS installation
/// </summary>
public class NodeInfo
{
/// <summary>
/// Version of Node if it's installed, else null if not detected
/// </summary>
public Version? NodeJsVersion { get; set; }

public NodeInfo(Version? version) => NodeJsVersion = version;
}

public class SystemCapability
{
public readonly string Name;
Expand Down
171 changes: 114 additions & 57 deletions src/AWS.Deploy.Orchestration/SystemCapabilityEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using AWS.Deploy.Common;
using AWS.Deploy.Common.Recipes;
Expand All @@ -27,105 +28,161 @@ public class SystemCapabilityEvaluator : ISystemCapabilityEvaluator
private readonly ICommandLineWrapper _commandLineWrapper;
private static readonly Version MinimumNodeJSVersion = new Version(14,17,0);

public SystemCapabilityEvaluator(ICommandLineWrapper commandLineWrapper)
{
_commandLineWrapper = commandLineWrapper;
}
/// <summary>
/// How long to wait for the commands we run to determine if Node/Docker/etc. are installed to finish
/// </summary>
private const int CAPABILITY_EVALUATION_TIMEOUT_MS = 60000; // one minute

public async Task<SystemCapabilities> Evaluate()
{
var dockerTask = HasDockerInstalledAndRunning();
var nodeTask = GetNodeJsVersion();
/// <summary>
/// How long to cache the results of a VALID Node/Docker/etc. check
/// </summary>
private static readonly TimeSpan DEPENDENCY_CACHE_INTERVAL = TimeSpan.FromHours(1);

var capabilities = new SystemCapabilities(await nodeTask, await dockerTask);
/// <summary>
/// If we ran a successful Node evaluation, this is the timestamp until which that result
/// is valid and we will skip subsequent evaluations
/// </summary>
private DateTime? _nodeDependencyValidUntilUtc = null;

return capabilities;
/// <summary>
/// If we ran a successful Docker evaluation, this is the timestamp until which that result
/// is valid and we will skip subsequent evaluations
/// </summary>
private DateTime? _dockerDependencyValidUntilUtc = null;

public SystemCapabilityEvaluator(ICommandLineWrapper commandLineWrapper)
{
_commandLineWrapper = commandLineWrapper;
}

private async Task<DockerInfo> HasDockerInstalledAndRunning()
private async Task<DockerInfo> HasDockerInstalledAndRunningAsync()
{
var processExitCode = -1;
var containerType = "";
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "docker info -f \"{{.OSType}}\"" : "docker info";

await _commandLineWrapper.Run(
command,
streamOutputToInteractiveService: false,
onComplete: proc =>
{
processExitCode = proc.ExitCode;
containerType = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
proc.StandardOut?.TrimEnd('\n') ??
throw new DockerInfoException(DeployToolErrorCode.FailedToCheckDockerInfo, "Failed to check if Docker is running in Windows or Linux container mode.") :
"linux";
});

var dockerInfo = new DockerInfo(processExitCode == 0, containerType);
var cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.CancelAfter(CAPABILITY_EVALUATION_TIMEOUT_MS);

return dockerInfo;
try
{
await _commandLineWrapper.Run(
command,
streamOutputToInteractiveService: false,
onComplete: proc =>
{
processExitCode = proc.ExitCode;
containerType = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
proc.StandardOut?.TrimEnd('\n') ??
throw new DockerInfoException(DeployToolErrorCode.FailedToCheckDockerInfo, "Failed to check if Docker is running in Windows or Linux container mode.") :
"linux";
},
cancellationToken: cancellationTokenSource.Token);


var dockerInfo = new DockerInfo(processExitCode == 0, containerType);

return dockerInfo;
}
catch (TaskCanceledException)
{
// If the check timed out, treat Docker as not installed
return new DockerInfo(false, "");
}
}

/// <summary>
/// From https://docs.aws.amazon.com/cdk/latest/guide/work-with.html#work-with-prerequisites,
/// min version is 10.3
/// </summary>
private async Task<Version?> GetNodeJsVersion()
private async Task<NodeInfo> GetNodeJsVersionAsync()
{
// run node --version to get the version
var result = await _commandLineWrapper.TryRunWithResult("node --version");
var cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.CancelAfter(CAPABILITY_EVALUATION_TIMEOUT_MS);

try
{
// run node --version to get the version
var result = await _commandLineWrapper.TryRunWithResult("node --version", cancellationToken: cancellationTokenSource.Token);

var versionString = result.StandardOut ?? "";

var versionString = result.StandardOut ?? "";
if (versionString.StartsWith("v", StringComparison.OrdinalIgnoreCase))
versionString = versionString.Substring(1, versionString.Length - 1);

if (versionString.StartsWith("v", StringComparison.OrdinalIgnoreCase))
versionString = versionString.Substring(1, versionString.Length - 1);
if (!result.Success || !Version.TryParse(versionString, out var version))
return new NodeInfo(null);

if (!result.Success || !Version.TryParse(versionString, out var version))
return null;
return new NodeInfo(version);

return version;
}
catch (TaskCanceledException)
{
// If the check timed out, treat Node as not installed
return new NodeInfo(null);
}
}

/// <summary>
/// Checks if the system meets all the necessary requirements for deployment.
/// </summary>
public async Task<List<SystemCapability>> EvaluateSystemCapabilities(Recommendation selectedRecommendation)
{
var capabilities = new List<SystemCapability>();
var systemCapabilities = await Evaluate();
var missingCapabilitiesForRecipe = new List<SystemCapability>();
string? message;

// We only need to check that Node is installed if the user is deploying a recipe that uses CDK
if (selectedRecommendation.Recipe.DeploymentType == DeploymentTypes.CdkProject)
{
if (systemCapabilities.NodeJsVersion == null)
// If we haven't cached that NodeJS installation is valid, or the cache is expired
if (_nodeDependencyValidUntilUtc == null || DateTime.UtcNow >= _nodeDependencyValidUntilUtc)
{

message = $"Install Node.js {MinimumNodeJSVersion} or later and restart your IDE/Shell. The latest Node.js LTS version is recommended. This deployment option uses the AWS CDK, which requires Node.js.";

capabilities.Add(new SystemCapability(NODEJS_DEPENDENCY_NAME, message, NODEJS_INSTALLATION_URL));
}
else if (systemCapabilities.NodeJsVersion < MinimumNodeJSVersion)
{
message = $"Install Node.js {MinimumNodeJSVersion} or later and restart your IDE/Shell. The latest Node.js LTS version is recommended. This deployment option uses the AWS CDK, which requires Node.js version higher than your current installation ({systemCapabilities.NodeJsVersion}). ";


capabilities.Add(new SystemCapability(NODEJS_DEPENDENCY_NAME, message, NODEJS_INSTALLATION_URL));
var nodeInfo = await GetNodeJsVersionAsync();

if (nodeInfo.NodeJsVersion == null)
{
message = $"Install Node.js {MinimumNodeJSVersion} or later and restart your IDE/Shell. The latest Node.js LTS version is recommended. This deployment option uses the AWS CDK, which requires Node.js.";

missingCapabilitiesForRecipe.Add(new SystemCapability(NODEJS_DEPENDENCY_NAME, message, NODEJS_INSTALLATION_URL));
}
else if (nodeInfo.NodeJsVersion < MinimumNodeJSVersion)
{
message = $"Install Node.js {MinimumNodeJSVersion} or later and restart your IDE/Shell. The latest Node.js LTS version is recommended. This deployment option uses the AWS CDK, which requires Node.js version higher than your current installation ({nodeInfo.NodeJsVersion}). ";

missingCapabilitiesForRecipe.Add(new SystemCapability(NODEJS_DEPENDENCY_NAME, message, NODEJS_INSTALLATION_URL));
}
else // It is valid, so update the cache interval
{
_nodeDependencyValidUntilUtc = DateTime.UtcNow.Add(DEPENDENCY_CACHE_INTERVAL);
}
}
}

// We only need to check that Docker is installed if the user is deploying a recipe that uses Docker
if (selectedRecommendation.Recipe.DeploymentBundle == DeploymentBundleTypes.Container)
{
if (!systemCapabilities.DockerInfo.DockerInstalled)
{
message = "Install and start Docker version appropriate for your OS. This deployment option requires Docker, which was not detected.";
capabilities.Add(new SystemCapability(DOCKER_DEPENDENCY_NAME, message, DOCKER_INSTALLATION_URL));
}
else if (!systemCapabilities.DockerInfo.DockerContainerType.Equals("linux", StringComparison.OrdinalIgnoreCase))
if (_dockerDependencyValidUntilUtc == null || DateTime.UtcNow >= _dockerDependencyValidUntilUtc)
{
message = "This is Linux-based deployment. Switch your Docker from Windows to Linux container mode.";
capabilities.Add(new SystemCapability(DOCKER_DEPENDENCY_NAME, message));
var dockerInfo = await HasDockerInstalledAndRunningAsync();

if (!dockerInfo.DockerInstalled)
{
message = "Install and start Docker version appropriate for your OS. This deployment option requires Docker, which was not detected.";
missingCapabilitiesForRecipe.Add(new SystemCapability(DOCKER_DEPENDENCY_NAME, message, DOCKER_INSTALLATION_URL));
}
else if (!dockerInfo.DockerContainerType.Equals("linux", StringComparison.OrdinalIgnoreCase))
{
message = "This is Linux-based deployment. Switch your Docker from Windows to Linux container mode.";
missingCapabilitiesForRecipe.Add(new SystemCapability(DOCKER_DEPENDENCY_NAME, message));
}
else // It is valid, so update the cache interval
{
_dockerDependencyValidUntilUtc = DateTime.UtcNow.Add(DEPENDENCY_CACHE_INTERVAL);
}
}
}

return capabilities;
return missingCapabilitiesForRecipe;
}
}
}
38 changes: 20 additions & 18 deletions src/AWS.Deploy.Orchestration/Utilities/ICommandLineWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,19 @@ public interface ICommandLineWrapper
/// By default, <see cref="Process.StandardInput"/>, <see cref="Process.StandardOutput"/> and <see cref="Process.StandardError"/> will be redirected.
/// Set this to false to avoid redirection.
/// </param>
/// <param name="stdin">
/// Text to pass into the process through standard input.
/// </param>
/// <param name="environmentVariables">
/// <see cref="command"/> is executed as a child process of running process which inherits the parent process's environment variables.
/// <see cref="environmentVariables"/> allows to add (replace if exists) extra environment variables to the child process.
/// <paramref name="command"/> is executed as a child process of running process which inherits the parent process's environment variables.
/// <paramref name="environmentVariables"/> allows to add (replace if exists) extra environment variables to the child process.
/// <remarks>
/// AWS Execution Environment string to append in AWS_EXECUTION_ENV env var.
/// AWS SDK calls made while executing <see cref="command"/> will have User-Agent string containing
/// AWS SDK calls made while executing <paramref name="command"/> will have User-Agent string containing
/// </remarks>
/// </param>
/// <param name="cancelToken">
/// <see cref="CancellationToken"/>
/// </param>
/// <param name="needAwsCredentials">Whether the command requires AWS credentials, which will be set as environment variables</param>
/// <param name="cancellationToken">Token which can be used to cancel the task</param>
public Task Run(
string command,
string workingDirectory = "",
Expand All @@ -53,8 +55,9 @@ public Task Run(
bool redirectIO = true,
string? stdin = null,
IDictionary<string, string>? environmentVariables = null,
CancellationToken cancelToken = default,
bool needAwsCredentials = false);
bool needAwsCredentials = false,
CancellationToken cancellationToken = default
);

/// <summary>
/// Configure the child process that executes the command passed as parameter in <see cref="Run"/> method.
Expand Down Expand Up @@ -92,16 +95,15 @@ public static class CommandLineWrapperExtensions
/// Text to pass into the process through standard input.
/// </param>
/// <param name="environmentVariables">
/// <see cref="command"/> is executed as a child process of running process which inherits the parent process's environment variables.
/// <see cref="environmentVariables"/> allows to add (replace if exists) extra environment variables to the child process.
/// <paramref name="command"/> is executed as a child process of running process which inherits the parent process's environment variables.
/// <paramref name="environmentVariables"/> allows to add (replace if exists) extra environment variables to the child process.
/// <remarks>
/// AWS Execution Environment string to append in AWS_EXECUTION_ENV env var.
/// AWS SDK calls made while executing <see cref="command"/> will have User-Agent string containing
/// AWS SDK calls made while executing <paramref name="command"/> will have User-Agent string containing
/// </remarks>
/// </param>
/// <param name="cancelToken">
/// <see cref="CancellationToken"/>
/// </param>
/// <param name="needAwsCredentials">Whether the command requires AWS credentials, which will be set as environment variables</param>
/// <param name="cancellationToken">Token which can be used to cancel the task</param>
public static async Task<TryRunResult> TryRunWithResult(
this ICommandLineWrapper commandLineWrapper,
string command,
Expand All @@ -110,8 +112,8 @@ public static async Task<TryRunResult> TryRunWithResult(
bool redirectIO = true,
string? stdin = null,
IDictionary<string, string>? environmentVariables = null,
CancellationToken cancelToken = default,
bool needAwsCredentials = false)
bool needAwsCredentials = false,
CancellationToken cancellationToken = default)
{
var result = new TryRunResult();

Expand All @@ -123,8 +125,8 @@ await commandLineWrapper.Run(
redirectIO: redirectIO,
stdin: stdin,
environmentVariables: environmentVariables,
cancelToken: cancelToken,
needAwsCredentials: needAwsCredentials);
needAwsCredentials: needAwsCredentials,
cancellationToken: cancellationToken);

return result;
}
Expand Down
Loading

0 comments on commit ee15007

Please sign in to comment.