// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Amazon.CloudFormation;
using Amazon.ECS;
using Amazon.Runtime;
using AWS.Deploy.CLI.Commands;
using AWS.Deploy.CLI.Common.UnitTests.IO;
using AWS.Deploy.CLI.Extensions;
using AWS.Deploy.CLI.IntegrationTests.Extensions;
using AWS.Deploy.CLI.IntegrationTests.Helpers;
using AWS.Deploy.CLI.IntegrationTests.Services;
using AWS.Deploy.CLI.IntegrationTests.Utilities;
using AWS.Deploy.CLI.ServerMode;
using AWS.Deploy.Orchestration.Utilities;
using AWS.Deploy.ServerMode.Client;
using AWS.Deploy.ServerMode.Client.Utilities;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using Xunit;
namespace AWS.Deploy.CLI.IntegrationTests
{
public class ServerModeTests : IDisposable
{
private bool _isDisposed;
private string _stackName;
private readonly IServiceProvider _serviceProvider;
private readonly CloudFormationHelper _cloudFormationHelper;
private readonly string _awsRegion;
private readonly TestAppManager _testAppManager;
private readonly InMemoryInteractiveService _interactiveService;
public ServerModeTests()
{
_interactiveService = new InMemoryInteractiveService();
var cloudFormationClient = new AmazonCloudFormationClient(Amazon.RegionEndpoint.USWest2);
_cloudFormationHelper = new CloudFormationHelper(cloudFormationClient);
var serviceCollection = new ServiceCollection();
serviceCollection.AddCustomServices();
serviceCollection.AddTestServices();
_serviceProvider = serviceCollection.BuildServiceProvider();
_awsRegion = "us-west-2";
_testAppManager = new TestAppManager();
}
///
/// ServerMode must only be connectable from 127.0.0.1 or localhost. This test confirms that connect attempts using
/// the host name fail.
///
///
[Fact]
public async Task ConfirmLocalhostOnly()
{
var portNumber = 4900;
var serverCommand = new ServerModeCommand(_serviceProvider.GetRequiredService(), portNumber, null, true);
var cancelSource = new CancellationTokenSource();
_ = serverCommand.ExecuteAsync(cancelSource.Token);
try
{
var restClient = new RestAPIClient($"http://localhost:{portNumber}/", ServerModeHttpClientFactory.ConstructHttpClient(ServerModeUtilities.ResolveDefaultCredentials));
await restClient.WaitUntilServerModeReady();
using var client = new HttpClient();
var localhostUrl = $"http://localhost:{portNumber}/api/v1/Health";
await client.GetStringAsync(localhostUrl);
var host = Dns.GetHostName();
var hostnameUrl = $"http://{host}:{portNumber}/api/v1/Health";
await Assert.ThrowsAsync(async () => await client.GetStringAsync(hostnameUrl));
}
finally
{
cancelSource.Cancel();
}
}
[Fact]
public async Task GetRecommendations()
{
var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppNoDockerFile", "WebAppNoDockerFile.csproj"));
var portNumber = 4000;
using var httpClient = ServerModeHttpClientFactory.ConstructHttpClient(ServerModeUtilities.ResolveDefaultCredentials);
var serverCommand = new ServerModeCommand(_serviceProvider.GetRequiredService(), portNumber, null, true);
var cancelSource = new CancellationTokenSource();
var serverTask = serverCommand.ExecuteAsync(cancelSource.Token);
try
{
var restClient = new RestAPIClient($"http://localhost:{portNumber}/", httpClient);
await restClient.WaitUntilServerModeReady();
var startSessionOutput = await restClient.StartDeploymentSessionAsync(new StartDeploymentSessionInput
{
AwsRegion = _awsRegion,
ProjectPath = projectPath
});
var sessionId = startSessionOutput.SessionId;
Assert.NotNull(sessionId);
var getRecommendationOutput = await restClient.GetRecommendationsAsync(sessionId);
Assert.NotEmpty(getRecommendationOutput.Recommendations);
var beanstalkRecommendation = getRecommendationOutput.Recommendations.FirstOrDefault();
Assert.Equal("AspNetAppElasticBeanstalkLinux", beanstalkRecommendation.RecipeId);
Assert.Null(beanstalkRecommendation.BaseRecipeId);
Assert.False(beanstalkRecommendation.IsPersistedDeploymentProject);
Assert.NotNull(beanstalkRecommendation.ShortDescription);
Assert.NotNull(beanstalkRecommendation.Description);
Assert.True(beanstalkRecommendation.ShortDescription.Length < beanstalkRecommendation.Description.Length);
Assert.Equal("AWS Elastic Beanstalk", beanstalkRecommendation.TargetService);
}
finally
{
cancelSource.Cancel();
}
}
[Fact]
public async Task GetRecommendationsWithEncryptedCredentials()
{
var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppNoDockerFile", "WebAppNoDockerFile.csproj"));
var portNumber = 4000;
var aes = Aes.Create();
aes.GenerateKey();
aes.GenerateIV();
using var httpClient = ServerModeHttpClientFactory.ConstructHttpClient(ServerModeUtilities.ResolveDefaultCredentials, aes);
var keyInfo = new EncryptionKeyInfo
{
Version = EncryptionKeyInfo.VERSION_1_0,
Key = Convert.ToBase64String(aes.Key),
IV = Convert.ToBase64String(aes.IV)
};
var keyInfoStdin = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(keyInfo)));
await _interactiveService.StdInWriter.WriteAsync(keyInfoStdin);
await _interactiveService.StdInWriter.FlushAsync();
var serverCommand = new ServerModeCommand(_interactiveService, portNumber, null, false);
var cancelSource = new CancellationTokenSource();
var serverTask = serverCommand.ExecuteAsync(cancelSource.Token);
try
{
var restClient = new RestAPIClient($"http://localhost:{portNumber}/", httpClient);
await restClient.WaitUntilServerModeReady();
var startSessionOutput = await restClient.StartDeploymentSessionAsync(new StartDeploymentSessionInput
{
AwsRegion = _awsRegion,
ProjectPath = projectPath
});
var sessionId = startSessionOutput.SessionId;
Assert.NotNull(sessionId);
var getRecommendationOutput = await restClient.GetRecommendationsAsync(sessionId);
Assert.NotEmpty(getRecommendationOutput.Recommendations);
Assert.Equal("AspNetAppElasticBeanstalkLinux", getRecommendationOutput.Recommendations.FirstOrDefault().RecipeId);
var listDeployStdOut = _interactiveService.StdOutReader.ReadAllLines();
Assert.Contains("Waiting on symmetric key from stdin", listDeployStdOut);
Assert.Contains("Encryption provider enabled", listDeployStdOut);
}
finally
{
cancelSource.Cancel();
}
}
[Fact]
public async Task WebFargateDeploymentNoConfigChanges()
{
_stackName = $"ServerModeWebFargate{Guid.NewGuid().ToString().Split('-').Last()}";
var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppWithDockerFile", "WebAppWithDockerFile.csproj"));
var portNumber = 4011;
using var httpClient = ServerModeHttpClientFactory.ConstructHttpClient(ServerModeUtilities.ResolveDefaultCredentials);
var serverCommand = new ServerModeCommand(_serviceProvider.GetRequiredService(), portNumber, null, true);
var cancelSource = new CancellationTokenSource();
var serverTask = serverCommand.ExecuteAsync(cancelSource.Token);
try
{
var baseUrl = $"http://localhost:{portNumber}/";
var restClient = new RestAPIClient(baseUrl, httpClient);
await restClient.WaitUntilServerModeReady();
var startSessionOutput = await restClient.StartDeploymentSessionAsync(new StartDeploymentSessionInput
{
AwsRegion = _awsRegion,
ProjectPath = projectPath
});
var sessionId = startSessionOutput.SessionId;
Assert.NotNull(sessionId);
var signalRClient = new DeploymentCommunicationClient(baseUrl);
await signalRClient.JoinSession(sessionId);
var logOutput = new StringBuilder();
RegisterSignalRMessageCallbacks(signalRClient, logOutput);
var getRecommendationOutput = await restClient.GetRecommendationsAsync(sessionId);
Assert.NotEmpty(getRecommendationOutput.Recommendations);
var fargateRecommendation = getRecommendationOutput.Recommendations.FirstOrDefault(x => string.Equals(x.RecipeId, "AspNetAppEcsFargate"));
Assert.NotNull(fargateRecommendation);
await restClient.SetDeploymentTargetAsync(sessionId, new SetDeploymentTargetInput
{
NewDeploymentName = _stackName,
NewDeploymentRecipeId = fargateRecommendation.RecipeId
});
await restClient.StartDeploymentAsync(sessionId);
await restClient.WaitForDeployment(sessionId);
var stackStatus = await _cloudFormationHelper.GetStackStatus(_stackName);
Assert.Equal(StackStatus.CREATE_COMPLETE, stackStatus);
Assert.True(logOutput.Length > 0);
// Check to make sure the task memory setting was copied to the container defintion memory limit;
var taskDefinitionId = await _cloudFormationHelper.GetResourceId(_stackName, "RecipeAppTaskDefinitionAC7F53DB");
using var ecsClient = new AmazonECSClient(Amazon.RegionEndpoint.GetBySystemName(_awsRegion));
var taskDefinition = (await ecsClient.DescribeTaskDefinitionAsync(new Amazon.ECS.Model.DescribeTaskDefinitionRequest { TaskDefinition = taskDefinitionId })).TaskDefinition;
var containerDefinition = taskDefinition.ContainerDefinitions[0];
Assert.Equal(int.Parse(taskDefinition.Memory), containerDefinition.Memory);
// Make sure section header is return to output log
Assert.Contains("Creating deployment image", logOutput.ToString());
// Make sure normal log messages are returned to output log
Assert.Contains("Pushing container image", logOutput.ToString());
var redeploymentSessionOutput = await restClient.StartDeploymentSessionAsync(new StartDeploymentSessionInput
{
AwsRegion = _awsRegion,
ProjectPath = projectPath
});
var redeploymentSessionId = redeploymentSessionOutput.SessionId;
var existingDeployments = await restClient.GetExistingDeploymentsAsync(redeploymentSessionId);
var existingDeployment = existingDeployments.ExistingDeployments.First(x => string.Equals(_stackName, x.Name));
Assert.Equal(_stackName, existingDeployment.Name);
Assert.Equal(fargateRecommendation.RecipeId, existingDeployment.RecipeId);
Assert.Null(fargateRecommendation.BaseRecipeId);
Assert.False(fargateRecommendation.IsPersistedDeploymentProject);
Assert.Equal(fargateRecommendation.Name, existingDeployment.RecipeName);
Assert.Equal(fargateRecommendation.ShortDescription, existingDeployment.ShortDescription);
Assert.Equal(fargateRecommendation.Description, existingDeployment.Description);
Assert.Equal(fargateRecommendation.TargetService, existingDeployment.TargetService);
Assert.Equal(DeploymentTypes.CloudFormationStack, existingDeployment.DeploymentType);
Assert.NotEmpty(existingDeployment.SettingsCategories);
Assert.Contains(existingDeployment.SettingsCategories, x => string.Equals(x.Id, AWS.Deploy.Common.Recipes.Category.DeploymentBundle.Id));
Assert.DoesNotContain(existingDeployment.SettingsCategories, x => string.IsNullOrEmpty(x.Id));
Assert.DoesNotContain(existingDeployment.SettingsCategories, x => string.IsNullOrEmpty(x.DisplayName));
// The below tests will check if updating readonly settings will properly get rejected.
// These tests need to be performed on a redeployment
await restClient.SetDeploymentTargetAsync(redeploymentSessionId, new SetDeploymentTargetInput
{
ExistingDeploymentId = await _cloudFormationHelper.GetStackArn(_stackName)
});
var settings = await restClient.GetConfigSettingsAsync(sessionId);
// Try to update a list of settings containing readonly settings which we expect to fail
var updatedSettings = (IDictionary)settings.OptionSettings.Where(x => !string.IsNullOrEmpty(x.FullyQualifiedId)).ToDictionary(k => k.FullyQualifiedId, v => "test");
var exceptionThrown = await Assert.ThrowsAsync(async () => await restClient.ApplyConfigSettingsAsync(redeploymentSessionId, new ApplyConfigSettingsInput()
{
UpdatedSettings = updatedSettings
}));
Assert.Equal(400, exceptionThrown.StatusCode);
// Try to update an updatable setting which should be successful
var applyConfigResponse = await restClient.ApplyConfigSettingsAsync(redeploymentSessionId,
new ApplyConfigSettingsInput
{
UpdatedSettings = new Dictionary {
{ "DesiredCount", "4" }
}
});
Assert.Empty(applyConfigResponse.FailedConfigUpdates);
}
finally
{
cancelSource.Cancel();
await _cloudFormationHelper.DeleteStack(_stackName);
_stackName = null;
}
}
[Fact]
public async Task RecommendationsForNewDeployments_DoesNotIncludeExistingBeanstalkEnvironmentRecipe()
{
var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppWithDockerFile", "WebAppWithDockerFile.csproj"));
var portNumber = 4002;
using var httpClient = ServerModeHttpClientFactory.ConstructHttpClient(ServerModeUtilities.ResolveDefaultCredentials);
var serverCommand = new ServerModeCommand(_serviceProvider.GetRequiredService(), portNumber, null, true);
var cancelSource = new CancellationTokenSource();
var serverTask = serverCommand.ExecuteAsync(cancelSource.Token);
try
{
var baseUrl = $"http://localhost:{portNumber}/";
var restClient = new RestAPIClient(baseUrl, httpClient);
await restClient.WaitUntilServerModeReady();
var startSessionOutput = await restClient.StartDeploymentSessionAsync(new StartDeploymentSessionInput
{
AwsRegion = _awsRegion,
ProjectPath = projectPath
});
var sessionId = startSessionOutput.SessionId;
Assert.NotNull(sessionId);
var getRecommendationOutput = await restClient.GetRecommendationsAsync(sessionId);
Assert.NotEmpty(getRecommendationOutput.Recommendations);
var recommendations = getRecommendationOutput.Recommendations;
Assert.DoesNotContain(recommendations, x => x.DeploymentType == DeploymentTypes.BeanstalkEnvironment);
}
finally
{
cancelSource.Cancel();
}
}
[Fact]
public async Task ShutdownViaRestClient()
{
var portNumber = 4003;
var cancelSource = new CancellationTokenSource();
using var httpClient = ServerModeHttpClientFactory.ConstructHttpClient(ServerModeUtilities.ResolveDefaultCredentials);
var serverCommand = new ServerModeCommand(_serviceProvider.GetRequiredService(), portNumber, null, true);
var serverTask = serverCommand.ExecuteAsync(cancelSource.Token);
try
{
var baseUrl = $"http://localhost:{portNumber}/";
var restClient = new RestAPIClient(baseUrl, httpClient);
await restClient.WaitUntilServerModeReady();
await restClient.ShutdownAsync();
Thread.Sleep(100);
// Expecting System.Net.Http.HttpRequestException : No connection could be made because the target machine actively refused it.
await Assert.ThrowsAsync(async () => await restClient.HealthAsync());
}
finally
{
cancelSource.Cancel();
}
}
[Theory]
[InlineData("1234MyAppStack")] // cannot start with a number
[InlineData("MyApp@Stack/123")] // cannot contain special characters
[InlineData("")] // cannot be empty
[InlineData("stackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstackstack")] // cannot contain more than 128 characters
public async Task InvalidStackName_ThrowsException(string invalidStackName)
{
var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppWithDockerFile", "WebAppWithDockerFile.csproj"));
var portNumber = 4012;
using var httpClient = ServerModeHttpClientFactory.ConstructHttpClient(ServerModeUtilities.ResolveDefaultCredentials);
var serverCommand = new ServerModeCommand(_serviceProvider.GetRequiredService(), portNumber, null, true);
var cancelSource = new CancellationTokenSource();
var serverTask = serverCommand.ExecuteAsync(cancelSource.Token);
try
{
var baseUrl = $"http://localhost:{portNumber}/";
var restClient = new RestAPIClient(baseUrl, httpClient);
await restClient.WaitUntilServerModeReady();
var startSessionOutput = await restClient.StartDeploymentSessionAsync(new StartDeploymentSessionInput
{
AwsRegion = _awsRegion,
ProjectPath = projectPath
});
var sessionId = startSessionOutput.SessionId;
var getRecommendationOutput = await restClient.GetRecommendationsAsync(sessionId);
var fargateRecommendation = getRecommendationOutput.Recommendations.FirstOrDefault(x => string.Equals(x.RecipeId, "AspNetAppEcsFargate"));
var exception = await Assert.ThrowsAsync>(() => restClient.SetDeploymentTargetAsync(sessionId, new SetDeploymentTargetInput
{
NewDeploymentName = invalidStackName,
NewDeploymentRecipeId = fargateRecommendation.RecipeId
}));
Assert.Equal(400, exception.StatusCode);
var errorMessage = $"Invalid cloud application name: {invalidStackName}";
Assert.Contains(errorMessage, exception.Result.Detail);
}
finally
{
cancelSource.Cancel();
}
}
[Fact]
public async Task CheckCategories()
{
var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppNoDockerFile", "WebAppNoDockerFile.csproj"));
var portNumber = 4200;
using var httpClient = ServerModeHttpClientFactory.ConstructHttpClient(ServerModeUtilities.ResolveDefaultCredentials);
var serverCommand = new ServerModeCommand(_serviceProvider.GetRequiredService(), portNumber, null, true);
var cancelSource = new CancellationTokenSource();
var serverTask = serverCommand.ExecuteAsync(cancelSource.Token);
try
{
var restClient = new RestAPIClient($"http://localhost:{portNumber}/", httpClient);
await restClient.WaitUntilServerModeReady();
var startSessionOutput = await restClient.StartDeploymentSessionAsync(new StartDeploymentSessionInput
{
AwsRegion = _awsRegion,
ProjectPath = projectPath
});
var sessionId = startSessionOutput.SessionId;
Assert.NotNull(sessionId);
var getRecommendationOutput = await restClient.GetRecommendationsAsync(sessionId);
foreach(var recommendation in getRecommendationOutput.Recommendations)
{
Assert.NotEmpty(recommendation.SettingsCategories);
Assert.Contains(recommendation.SettingsCategories, x => string.Equals(x.Id, AWS.Deploy.Common.Recipes.Category.DeploymentBundle.Id));
Assert.DoesNotContain(recommendation.SettingsCategories, x => string.IsNullOrEmpty(x.Id));
Assert.DoesNotContain(recommendation.SettingsCategories, x => string.IsNullOrEmpty(x.DisplayName));
}
var selectedRecommendation = getRecommendationOutput.Recommendations.First();
await restClient.SetDeploymentTargetAsync(sessionId, new SetDeploymentTargetInput
{
NewDeploymentRecipeId = selectedRecommendation.RecipeId,
NewDeploymentName = "TestStack-" + DateTime.UtcNow.Ticks
});
var getConfigSettingsResponse = await restClient.GetConfigSettingsAsync(sessionId);
// Make sure all top level settings have a category
Assert.DoesNotContain(getConfigSettingsResponse.OptionSettings, x => string.IsNullOrEmpty(x.Category));
// Make sure build settings have been applied a category.
var buildSetting = getConfigSettingsResponse.OptionSettings.FirstOrDefault(x => string.Equals(x.Id, "DotnetBuildConfiguration"));
Assert.NotNull(buildSetting);
Assert.Equal(AWS.Deploy.Common.Recipes.Category.DeploymentBundle.Id, buildSetting.Category);
}
finally
{
cancelSource.Cancel();
}
}
internal static void RegisterSignalRMessageCallbacks(IDeploymentCommunicationClient signalRClient, StringBuilder logOutput)
{
signalRClient.ReceiveLogSectionStart = (message, description) =>
{
logOutput.AppendLine(new string('*', message.Length));
logOutput.AppendLine(message);
logOutput.AppendLine(new string('*', message.Length));
};
signalRClient.ReceiveLogInfoMessage = (message) =>
{
logOutput.AppendLine(message);
};
signalRClient.ReceiveLogErrorMessage = (message) =>
{
logOutput.AppendLine(message);
};
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_isDisposed) return;
if (disposing)
{
if(!string.IsNullOrEmpty(_stackName))
{
var isStackDeleted = _cloudFormationHelper.IsStackDeleted(_stackName).GetAwaiter().GetResult();
if (!isStackDeleted)
{
_cloudFormationHelper.DeleteStack(_stackName).GetAwaiter().GetResult();
}
_interactiveService.ReadStdOutStartToEnd();
}
}
_isDisposed = true;
}
~ServerModeTests()
{
Dispose(false);
}
}
}