diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index a7310cd77..a080bc445 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -21,6 +21,8 @@ ** Amazon.JSII.Runtime; version 1.54.0 -- https://www.nuget.org/packages/Amazon.JSII.Runtime ** AWSSDK.CloudControlApi; version 3.7.2 -- https://www.nuget.org/packages/AWSSDK.CloudControlApi/ ** AWSSDK.SimpleSystemsManagement; version 3.7.16 -- https://www.nuget.org/packages/AWSSDK.SimpleSystemsManagement/ +** AWSSDK.SSO; version 3.7.0.201 -- https://www.nuget.org/packages/AWSSDK.SSO +** AWSSDK.SSOOIDC; version 3.7.1.4 -- https://www.nuget.org/packages/AWSSDK.SSOOIDC Apache License Version 2.0, January 2004 @@ -253,6 +255,10 @@ limitations under the License. Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * For AWSSDK.SimpleSystemsManagement see also this required NOTICE: Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +* For AWSSDK.SSO see also this required NOTICE: + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +* For AWSSDK.SSOOIDC see also this required NOTICE: + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. ------ @@ -334,8 +340,6 @@ Copyright (c) 2016 .NET Foundation Copyright (c) 2016 .NET Foundation ** Microsoft.Extensions.Configuration.Json; version 3.1.7 -- https://www.nuget.org/packages/Microsoft.Bcl.AsyncInterhttps://www.nuget.org/packages/Microsoft.Extensions.Configuration.Jsonfaces Copyright (c) .NET Foundation and Contributors -** System.Text.Json; version 6.0.4 -- https://www.nuget.org/packages/System.Text.Json -Copyright (c) .NET Foundation and Contributors ** Microsoft.Extensions.DependencyInjection; version 6.0.0 -- https://www.nuget.org/packages/Microsoft.Extensions.DependencyInjection Copyright (c) .NET Foundation and Contributors ** Microsoft.Extensions.DependencyInjection.Abstractions; version 6.0.0 -- https://www.nuget.org/packages/Microsoft.Extensions.DependencyInjection.Abstractions @@ -344,7 +348,9 @@ Copyright (c) .NET Foundation and Contributors Copyright (c) .NET Foundation and Contributors ** Microsoft.OpenApi; version 1.2.3 -- https://www.nuget.org/packages/Microsoft.OpenApi/ Copyright (c) Microsoft Corporation. - +** System.Text.Json; version 6.0.4 -- https://www.nuget.org/packages/System.Text.Json +Copyright (c) .NET Foundation and Contributors + All rights reserved. Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/site/content/docs/cicd.md b/site/content/docs/cicd.md index e49292bf3..8f4513d5d 100644 --- a/site/content/docs/cicd.md +++ b/site/content/docs/cicd.md @@ -8,17 +8,68 @@ To turn off the interactive features, use the `-s (--silent)` switch. This will dotnet aws deploy --silent -### Creating a deployment setting file +### Creating a deployment settings file -To specify the services to deploy and their configurations for your environment, you need to create deployment settings file. The deployment settings file is a JSON configuration file that contains all of the settings that the deployment tool uses to drive the experience. Here is the [JSON file definition](https://github.com/aws/aws-dotnet-deploy/tree/main/src/AWS.Deploy.Recipes/RecipeDefinitions). +You can persist the deployment configuration to a JSON file using the `--save-settings ` switch. This JSON file can be version controlled and plugged into your CI/CD system for future deployments. -Storing deployment settings in a JSON file also allows those settings to be version controlled. +**Note** - The `--save-settings` switch will only persist settings that have been modified (which means they hold a non-default value). To persist all settings use the `--save-all-settings` switch. + +``` +dotnet aws deploy --project-path [--save-settings|--save-all-settings] +``` + +**Note** - The `SETTINGS_FILE_PATH` can be an absolute path or relative to the `PROJECT_PATH`. + +Here's an example of a web application with the following directory structure: + + MyWebApplication/ + ┣ MyClassLibrary/ + ┃ ┣ Class1.cs + ┃ ┗ MyClassLibrary.csproj + ┣ MyWebApplication/ + ┃ ┣ Controllers/ + ┃ ┃ ┗ WeatherForecastController.cs + ┃ ┣ appsettings.Development.json + ┃ ┣ appsettings.json + ┃ ┣ Dockerfile + ┃ ┣ MyWebApplication.csproj + ┃ ┣ Program.cs + ┃ ┣ WeatherForecast.cs + ┗ MyWebApplication.sln + +To perform a deployment and also persist the deployment configuration to a JSON file, use the following command: +``` +dotnet aws deploy --project-path MyWebApplication/MyWebApplication/MyWebApplication.csproj --save-settings deploymentsettings.json +``` + +This will create a JSON file at `MyWebApplication/MyWebApplication/deploymentsettings.json` with the following structure: +``` +{ + "AWSProfile": + "AWSRegion": , + "ApplicationName": , + "RecipeId": + "Settings": +} + +``` +* _**AWSProfile**_: The name of the AWS profile that was used during deployment. + +* _**AWSRegion**_: The name of the AWS region where the deployed application is hosted. + +* _**ApplicationName**_: The name that is used to identify your cloud application within AWS. If the application is deployed via AWS CDK, then this name points to the CloudFormation stack. + +* _**RecipeId**_: The recipe identifier that was used to deploy your application to AWS. + +* _**Settings**_: This is a JSON blob that stores the values of all available settings that can be tweaked to adjust the deployment configuration. ### Invoking from CI/CD -The `--apply` switch on deploy command allows you to specify a deployment settings file. +The `--apply` switch on the deploy command allows you to specify a deployment settings file. -Deployment settings file path is always relative to the `--project-path`. Here's an example of a web application with the following directory structure: +``` +dotnet aws deploy --project-path --apply +``` MyWebApplication/ ┣ MyClassLibrary/ @@ -36,7 +87,9 @@ Deployment settings file path is always relative to the `--project-path`. Here's ┃ ┗ WeatherForecast.cs ┗ MyWebApplication.sln -To deploy the application with above directory structure in CI/CD pipeline without any prompts, use the following command: +To deploy the application with the above directory structure in CI/CD pipeline without any prompts, use the following command: dotnet aws deploy --silent --project-path MyWebApplication/MyWebApplication/MyWebApplication.csproj --apply deploymentsettings.json + + diff --git a/site/content/docs/commands/deploy.md b/site/content/docs/commands/deploy.md index 45632f2b6..7aaa6ed9d 100644 --- a/site/content/docs/commands/deploy.md +++ b/site/content/docs/commands/deploy.md @@ -4,7 +4,7 @@ dotnet aws deploy - Inspect, build, and deploy the .NET project to AWS using the chosen AWS compute. ### Synopsis - dotnet aws deploy [-d|—-diagnostics] [-s|--silent] [--profile ] [--region ] [--project-path ] [--application-name ] [--apply ] [--deployment-project ] [-?|-h|--help] + dotnet aws deploy [-d|—-diagnostics] [-s|--silent] [--profile ] [--region ] [--project-path ] [[--save-settings|--save-all-settings] ] [--application-name ] [--apply ] [--deployment-project ] [-?|-h|--help] ### Description Inspects the project and recommends AWS compute that is most suited to the type of deployed application. Then builds the project, generates a deployment CDK project to provision the required infrastructure, and deploys the .NET project to AWS using the chosen AWS compute. diff --git a/site/content/docs/getting-started/setup-creds.md b/site/content/docs/getting-started/setup-creds.md index 780c1f7e8..dca9c94e7 100644 --- a/site/content/docs/getting-started/setup-creds.md +++ b/site/content/docs/getting-started/setup-creds.md @@ -10,10 +10,10 @@ The following are some examples of the typical permissions that are required. |Command| Task | Recommended AWS Managed Policies | | --- | --- |--- | -|deploy | Deploying to Amazon ECS | AWSCloudFormationFullAccess, AmazonECS_FullAccess, AmazonEC2ContainerRegistryFullAccess, IAMFullAccess | -|deploy | Deploying to AWS App Runner| AWSCloudFormationFullAccess, AWSAppRunnerFullAccess, AmazonEC2ContainerRegistryFullAccess, IAMFullAccess| -|deploy | Deploying to Elastic Beanstalk (deploy) | AWSCloudFormationFullAccess, AdministratorAccess-AWSElasticBeanstalk', AmazonS3FullAccess (*required to upload the application bundle*), IAMFullAccess | -|deploy | Hosting WebAssembly Blazor App in S3 & CloudFront | AmazonS3FullAccess, CloudFrontFullAccess, IAMFullAccess, AWSLambda_FullAccess (*required to copy from CDKBootstrap bucket to S3 bucket*)| +|deploy | Deploying to Amazon ECS | AWSCloudFormationFullAccess, AmazonECS_FullAccess, AmazonEC2ContainerRegistryFullAccess, AmazonSSMFullAccess, IAMFullAccess | +|deploy | Deploying to AWS App Runner| AWSCloudFormationFullAccess, AWSAppRunnerFullAccess, AmazonEC2ContainerRegistryFullAccess, AmazonSSMFullAccess, IAMFullAccess| +|deploy | Deploying to Elastic Beanstalk (deploy) | AWSCloudFormationFullAccess, AdministratorAccess-AWSElasticBeanstalk, AmazonSSMFullAccess, AmazonS3FullAccess (*required to upload the application bundle*), IAMFullAccess | +|deploy | Hosting WebAssembly Blazor App in S3 & CloudFront | AmazonS3FullAccess, CloudFrontFullAccess, IAMFullAccess, AmazonSSMFullAccess, AWSLambda_FullAccess (*required to copy from CDKBootstrap bucket to S3 bucket*)| | list-deployments | List CF stacks| AWSCloudFormationReadOnlyAccess | | delete-deployment | Delete a CF stack | AWSCloudFormationFullAccess + permissions for resources being deleted | diff --git a/site/content/troubleshooting-guide/index.md b/site/content/troubleshooting-guide/index.md index 4765b07cb..c69df7efb 100644 --- a/site/content/troubleshooting-guide/index.md +++ b/site/content/troubleshooting-guide/index.md @@ -96,3 +96,13 @@ StagingBucket cdk-hnb659fds-assets-123456789101-us-west-2 already exists ``` **Resolution**: Open the AWS Console, go to S3 service, and manually delete the 'CDKToolkit' S3 bucket. Once the bucket is deleted, go ahead and deploy your application. + +## MemorySize Constraint for Blazor WebAssembly +When attempting to deploy using the Blazor WebAssembly App recipe, you may see a deployment failure such as: +``` +Resource handler returned message: "'MemorySize' value failed to satisfy constraint: Member must have value less than or equal to 3008 +``` + +**Why this is happening:** The [BucketDeployment](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3_deployment.BucketDeployment.html) CDK Construct used to deploy the Blazor recipe uses an AWS Lambda function to replicate the application files from the CDK bucket to the deployment bucket. In some versions of the deploy tool the default memory limit for this Lambda function exceeded the 3008MB quota placed on new AWS accounts. + +**Resolution:** See [Lambda: Concurrency and memory quotas](https://docs.aws.amazon.com/lambda/latest/dg/troubleshooting-deployment.html#troubleshooting-deployment-quotas) for how to request a quota increase. diff --git a/src/AWS.Deploy.CLI/AWS.Deploy.CLI.csproj b/src/AWS.Deploy.CLI/AWS.Deploy.CLI.csproj index 11455a27e..a806942b6 100644 --- a/src/AWS.Deploy.CLI/AWS.Deploy.CLI.csproj +++ b/src/AWS.Deploy.CLI/AWS.Deploy.CLI.csproj @@ -24,6 +24,8 @@ + + diff --git a/src/AWS.Deploy.CLI/Commands/CommandFactory.cs b/src/AWS.Deploy.CLI/Commands/CommandFactory.cs index 1ccb823af..a92a56553 100644 --- a/src/AWS.Deploy.CLI/Commands/CommandFactory.cs +++ b/src/AWS.Deploy.CLI/Commands/CommandFactory.cs @@ -47,6 +47,8 @@ public class CommandFactory : ICommandFactory private static readonly Option _optionOutputDirectory = new(new[] { "-o", "--output" }, "Directory path in which the CDK deployment project will be saved."); private static readonly Option _optionProjectDisplayName = new(new[] { "--project-display-name" }, "The name of the deployment project that will be displayed in the list of available deployment options."); private static readonly Option _optionDeploymentProject = new(new[] { "--deployment-project" }, "The absolute or relative path of the CDK project that will be used for deployment"); + private static readonly Option _optionSaveSettings = new(new[] { "--save-settings" }, "The absolute or the relative JSON file path where the deployment settings will be saved. Only the settings modified by the user will be persisted"); + private static readonly Option _optionSaveAllSettings = new(new[] { "--save-all-settings" }, "The absolute or the relative JSON file path where the deployment settings will be saved. All deployment settings will be persisted"); private static readonly object s_root_command_lock = new(); private static readonly object s_child_command_lock = new(); @@ -180,6 +182,8 @@ private Command BuildDeployCommand() deployCommand.Add(_optionDiagnosticLogging); deployCommand.Add(_optionDisableInteractive); deployCommand.Add(_optionDeploymentProject); + deployCommand.Add(_optionSaveSettings); + deployCommand.Add(_optionSaveAllSettings); } deployCommand.Handler = CommandHandler.Create(async (DeployCommandHandlerInput input) => @@ -252,7 +256,9 @@ private Command BuildDeployCommand() deploymentProjectPath = Path.GetFullPath(deploymentProjectPath, targetApplicationDirectoryPath); } - await deploy.ExecuteAsync(input.ApplicationName ?? string.Empty, deploymentProjectPath, deploymentSettings); + var saveSettingsConfig = Helpers.GetSaveSettingsConfiguration(input.SaveSettings, input.SaveAllSettings, targetApplicationDirectoryPath, _fileManager); + + await deploy.ExecuteAsync(input.ApplicationName ?? string.Empty, deploymentProjectPath, saveSettingsConfig, deploymentSettings); return CommandReturnCodes.SUCCESS; } diff --git a/src/AWS.Deploy.CLI/Commands/CommandHandlerInput/DeployCommandHandlerInput.cs b/src/AWS.Deploy.CLI/Commands/CommandHandlerInput/DeployCommandHandlerInput.cs index c7a8837ba..8dfbb8038 100644 --- a/src/AWS.Deploy.CLI/Commands/CommandHandlerInput/DeployCommandHandlerInput.cs +++ b/src/AWS.Deploy.CLI/Commands/CommandHandlerInput/DeployCommandHandlerInput.cs @@ -10,13 +10,54 @@ namespace AWS.Deploy.CLI.Commands.CommandHandlerInput { public class DeployCommandHandlerInput { + /// + /// AWS credential profile used to make calls to AWS. + /// public string? Profile { get; set; } + + /// + /// AWS region to deploy the application to. For example, us-west-2. + /// public string? Region { get; set; } + + /// + /// Path to the project to deploy. + /// public string? ProjectPath { get; set; } + + /// + /// Name of the cloud application. + /// public string? ApplicationName { get; set; } + + /// + /// Path to the deployment settings file to be applied. + /// public string? Apply { get; set; } + + /// + /// Flag to enable diagnostic output. + /// public bool Diagnostics { get; set; } + + /// + /// Flag to disable interactivity to execute commands without any prompts. + /// public bool Silent { get; set; } + + /// + /// The absolute or relative path of the CDK project that will be used for deployment. + /// public string? DeploymentProject { get; set; } + + /// + /// The absolute or the relative JSON file path where the deployment settings will be saved. Only the settings modified by the user are persisted. + /// + public string? SaveSettings { get; set; } + + /// + /// The absolute or the relative JSON file path where the deployment settings will be saved. All deployment settings are persisted. + /// + public string? SaveAllSettings { get; set; } } } diff --git a/src/AWS.Deploy.CLI/Commands/DeployCommand.cs b/src/AWS.Deploy.CLI/Commands/DeployCommand.cs index 94010ba5c..2a195f7c8 100644 --- a/src/AWS.Deploy.CLI/Commands/DeployCommand.cs +++ b/src/AWS.Deploy.CLI/Commands/DeployCommand.cs @@ -113,7 +113,7 @@ public DeployCommand( _deploymentSettingsHandler = deploymentSettingsHandler; } - public async Task ExecuteAsync(string applicationName, string deploymentProjectPath, DeploymentSettings? deploymentSettings = null) + public async Task ExecuteAsync(string applicationName, string deploymentProjectPath, SaveSettingsConfiguration saveSettingsConfig, DeploymentSettings? deploymentSettings = null) { var (orchestrator, selectedRecommendation, cloudApplication) = await InitializeDeployment(applicationName, deploymentSettings, deploymentProjectPath); @@ -130,6 +130,12 @@ public async Task ExecuteAsync(string applicationName, string deploymentProjectP await CreateDeploymentBundle(orchestrator, selectedRecommendation, cloudApplication); + if (saveSettingsConfig.SettingsType != SaveSettingsType.None) + { + await _deploymentSettingsHandler.SaveSettings(saveSettingsConfig, selectedRecommendation, cloudApplication, _session); + _toolInteractiveService.WriteLine($"{Environment.NewLine}Successfully saved the deployment settings at {saveSettingsConfig.FilePath}"); + } + await orchestrator.DeployRecommendation(cloudApplication, selectedRecommendation); var displayedResources = await _displayedResourcesHandler.GetDeploymentOutputs(cloudApplication, selectedRecommendation); @@ -193,11 +199,27 @@ private void DisplayOutputResources(List displayedResourc cloudApplicationName = deploymentSettings?.ApplicationName ?? string.Empty; // Prompt the user with a choice to re-deploy to existing targets or deploy to a new cloud application. - if (string.IsNullOrEmpty(cloudApplicationName)) + // This prompt is NOT needed if the user is just pushing the docker image to ECR. + if (string.IsNullOrEmpty(cloudApplicationName) && !string.Equals(deploymentSettings?.RecipeId, Constants.RecipeIdentifier.PUSH_TO_ECR_RECIPE_ID)) cloudApplicationName = AskForCloudApplicationNameFromDeployedApplications(compatibleApplications); // Find existing application with the same CloudApplication name. - var deployedApplication = allDeployedApplications.FirstOrDefault(x => string.Equals(x.Name, cloudApplicationName)); + CloudApplication? deployedApplication = null; + if (!string.IsNullOrEmpty(deploymentSettings?.RecipeId)) + { + // if the recommendation is specified via a config file then find the deployed application by matching the deployment type along with the cloudApplicationName + var recommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, deploymentSettings.RecipeId)); + if (recommendation == null) + { + var errorMsg = "The recipe ID specified in the deployment settings file does not match any compatible deployment recipes."; + throw new InvalidDeploymentSettingsException(DeployToolErrorCode.DeploymentConfigurationNeedsAdjusting, errorMsg); + } + deployedApplication = allDeployedApplications.FirstOrDefault(x => string.Equals(x.Name, cloudApplicationName) && x.DeploymentType == recommendation.Recipe.DeploymentType); + } + else + { + deployedApplication = allDeployedApplications.FirstOrDefault(x => string.Equals(x.Name, cloudApplicationName)); + } Recommendation? selectedRecommendation = null; if (deployedApplication != null) diff --git a/src/AWS.Deploy.Common/Exceptions.cs b/src/AWS.Deploy.Common/Exceptions.cs index 085a69f7e..3e18a207e 100644 --- a/src/AWS.Deploy.Common/Exceptions.cs +++ b/src/AWS.Deploy.Common/Exceptions.cs @@ -124,7 +124,8 @@ public enum DeployToolErrorCode FailedToGetOptionSettingValue = 10010200, ECRRepositoryNameIsNull = 10010300, FailedToReadCdkBootstrapVersion = 10010400, - UnsupportedOptionSettingType = 10010500 + UnsupportedOptionSettingType = 10010500, + FailedToSaveDeploymentSettings = 10010600 } public class ProjectFileNotFoundException : DeployToolException @@ -293,6 +294,14 @@ public class InvalidFilePath : DeployToolException public InvalidFilePath(DeployToolErrorCode errorCode, string message, Exception? innerException = null) : base(errorCode, message, innerException) { } } + /// + /// Throw if an error occurred while saving the deployment settings to a config file + /// + public class FailedToSaveDeploymentSettingsException : DeployToolException + { + public FailedToSaveDeploymentSettingsException(DeployToolErrorCode errorCode, string message, Exception? innerException = null) : base(errorCode, message, innerException) { } + } + public static class ExceptionExtensions { /// diff --git a/src/AWS.Deploy.Common/IO/FileManager.cs b/src/AWS.Deploy.Common/IO/FileManager.cs index df85a5393..c84ed773d 100644 --- a/src/AWS.Deploy.Common/IO/FileManager.cs +++ b/src/AWS.Deploy.Common/IO/FileManager.cs @@ -34,6 +34,15 @@ public interface IFileManager /// bool Exists(string path, string directory); + /// + /// Determines that the specified file path is structurally valid and its parent directory exists on disk. + /// This file path can be absolute or relative to the current working directory. + /// Note - This method does not check for the existence of a file at the specified path. Use or to check for existence of a file. + /// + /// + /// + bool IsFileValidPath(string filePath); + Task ReadAllTextAsync(string path); Task ReadAllLinesAsync(string path); Task WriteAllTextAsync(string filePath, string contents, CancellationToken cancellationToken = default); @@ -47,7 +56,13 @@ public interface IFileManager /// public class FileManager : IFileManager { - public bool Exists(string path) => IsFileValid(path); + public bool Exists(string path) + { + if (!PathUtilities.IsPathValid(path)) + return false; + + return File.Exists(path); + } public bool Exists(string path, string directory) { @@ -74,15 +89,17 @@ public Task WriteAllTextAsync(string filePath, string contents, CancellationToke public long GetSizeInBytes(string filePath) => new FileInfo(filePath).Length; - private bool IsFileValid(string filePath) + public bool IsFileValidPath(string filePath) { if (!PathUtilities.IsPathValid(filePath)) return false; - if (!File.Exists(filePath)) + var parentDirectory = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(parentDirectory)) + { return false; - - return true; + } + return PathUtilities.IsPathValid(parentDirectory) && Directory.Exists(parentDirectory); } } } diff --git a/src/AWS.Deploy.Common/Recipes/IOptionSettingHandler.cs b/src/AWS.Deploy.Common/Recipes/IOptionSettingHandler.cs index 88af3dd84..919a1ff05 100644 --- a/src/AWS.Deploy.Common/Recipes/IOptionSettingHandler.cs +++ b/src/AWS.Deploy.Common/Recipes/IOptionSettingHandler.cs @@ -26,6 +26,13 @@ public interface IOptionSettingHandler /// Task SetOptionSettingValue(Recommendation recommendation, OptionSettingItem optionSettingItem, object value, bool skipValidation = false); + /// + /// This method is used to set values for bases on the fullyQualifiedId of the option setting. + /// Due to different validations that could be put in place, access to other services may be needed. + /// This method is meant to control access to those services and determine the value to be set. + /// + Task SetOptionSettingValue(Recommendation recommendation, string fullyQualifiedId, object value, bool skipValidation = false); + /// /// This method retrieves the related to a specific . /// @@ -71,6 +78,11 @@ public interface IOptionSettingHandler /// Checks whether the Option Setting Item can be displayed as part of the settings summary of the previous deployment. /// bool IsSummaryDisplayable(Recommendation recommendation, OptionSettingItem optionSettingItem); - } + /// + /// Checks whether the option setting item has been modified by the user. If it has been modified, then it will hold a non-default value + /// + /// true if the option setting item has been modified or false otherwise + bool IsOptionSettingModified(Recommendation recommendation, OptionSettingItem optionSetting); + } } diff --git a/src/AWS.Deploy.Common/Recipes/OptionSettingValueType.cs b/src/AWS.Deploy.Common/Recipes/OptionSettingValueType.cs index fe637f103..fd129e440 100644 --- a/src/AWS.Deploy.Common/Recipes/OptionSettingValueType.cs +++ b/src/AWS.Deploy.Common/Recipes/OptionSettingValueType.cs @@ -3,6 +3,14 @@ namespace AWS.Deploy.Common.Recipes { + /// + /// Specifies the type of value held by the OptionSettingItem. + /// The following peices will also need to be updated when adding a new OptionSettingValueType + /// 1. DeployCommand.ConfigureDeploymentFromCli(Recommendation recommendation, OptionSettingItem setting) + /// 2. OptionSettingItem.SetValue(IOptionSettingHandler optionSettingHandler, object valueOverride, IOptionSettingItemValidator[] validators, Recommendation recommendation, bool skipValidation) + /// 3. OptionSettingHandler.IsOptionSettingDisplayable(Recommendation recommendation, OptionSettingItem optionSetting) + /// 4. OptionSettingHandler.IsOptionSettingModified(Recommendation recommendation, OptionSettingItem optionSetting) + /// public enum OptionSettingValueType { String, diff --git a/src/AWS.Deploy.Constants/RecipeIdentifier.cs b/src/AWS.Deploy.Constants/RecipeIdentifier.cs index 5bf175713..4eb7760b1 100644 --- a/src/AWS.Deploy.Constants/RecipeIdentifier.cs +++ b/src/AWS.Deploy.Constants/RecipeIdentifier.cs @@ -8,6 +8,7 @@ internal static class RecipeIdentifier { // Recipe IDs public const string EXISTING_BEANSTALK_ENVIRONMENT_RECIPE_ID = "AspNetAppExistingBeanstalkEnvironment"; + public const string PUSH_TO_ECR_RECIPE_ID = "PushContainerImageEcr"; // Replacement Tokens public const string REPLACE_TOKEN_STACK_NAME = "{StackName}"; diff --git a/src/AWS.Deploy.Orchestration/DeploymentBundleHandler.cs b/src/AWS.Deploy.Orchestration/DeploymentBundleHandler.cs index c2f5e5b87..2c4683e83 100644 --- a/src/AWS.Deploy.Orchestration/DeploymentBundleHandler.cs +++ b/src/AWS.Deploy.Orchestration/DeploymentBundleHandler.cs @@ -171,6 +171,8 @@ private string GetDockerExecutionDirectory(Recommendation recommendation) } } + // The docker build command will fail if a relative path is provided + dockerExecutionDirectory = _directoryManager.GetAbsolutePath(projectDirectory, dockerExecutionDirectory); return dockerExecutionDirectory; } diff --git a/src/AWS.Deploy.Orchestration/DeploymentSettingsHandler.cs b/src/AWS.Deploy.Orchestration/DeploymentSettingsHandler.cs index 53969b085..c4a6c4eee 100644 --- a/src/AWS.Deploy.Orchestration/DeploymentSettingsHandler.cs +++ b/src/AWS.Deploy.Orchestration/DeploymentSettingsHandler.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; @@ -25,17 +26,25 @@ public interface IDeploymentSettingsHandler /// Iterates over the option setting values found at and applies them to the selected recommendation /// Task ApplySettings(DeploymentSettings deploymentSettings, Recommendation recommendation, IDeployToolValidationContext deployToolValidationContext); + + /// + /// Save the deployment settings at the specified file path. + /// + /// Thrown if this operation fails. + Task SaveSettings(SaveSettingsConfiguration saveSettingsConfig, Recommendation recommendation, CloudApplication cloudApplication, OrchestratorSession orchestratorSession); } public class DeploymentSettingsHandler : IDeploymentSettingsHandler { - private readonly IFileManager _filemanager; + private readonly IFileManager _fileManager; + private readonly IDirectoryManager _directoryManager; private readonly IOptionSettingHandler _optionSettingHandler; private readonly IRecipeHandler _recipeHandler; - public DeploymentSettingsHandler(IFileManager fileManager, IOptionSettingHandler optionSettingHandler, IRecipeHandler recipeHandler) + public DeploymentSettingsHandler(IFileManager fileManager, IDirectoryManager directoryManager, IOptionSettingHandler optionSettingHandler, IRecipeHandler recipeHandler) { - _filemanager = fileManager; + _fileManager = fileManager; + _directoryManager = directoryManager; _optionSettingHandler = optionSettingHandler; _recipeHandler = recipeHandler; } @@ -44,7 +53,7 @@ public DeploymentSettingsHandler(IFileManager fileManager, IOptionSettingHandler { try { - var contents = await _filemanager.ReadAllTextAsync(filePath); + var contents = await _fileManager.ReadAllTextAsync(filePath); var userDeploymentSettings = JsonConvert.DeserializeObject(contents); return userDeploymentSettings; } @@ -91,5 +100,81 @@ public async Task ApplySettings(DeploymentSettings deploymentSettings, Recommend throw new InvalidDeploymentSettingsException(DeployToolErrorCode.DeploymentConfigurationNeedsAdjusting, errorMessage.Trim()); } + + public async Task SaveSettings(SaveSettingsConfiguration saveSettingsConfig, Recommendation recommendation, CloudApplication cloudApplication, OrchestratorSession orchestratorSession) + { + if (saveSettingsConfig.SettingsType == SaveSettingsType.None) + { + // We are not throwing an expected exception here as this issue is not caused by the user. + throw new InvalidOperationException($"Cannot persist settings with {SaveSettingsType.None}"); + } + + if (!_fileManager.IsFileValidPath(saveSettingsConfig.FilePath)) + { + var message = $"Failed to save deployment settings because {saveSettingsConfig.FilePath} is invalid or its parent directory does not exist on disk."; + throw new FailedToSaveDeploymentSettingsException(DeployToolErrorCode.FailedToSaveDeploymentSettings, message); + } + + var projectDirectory = Path.GetDirectoryName(orchestratorSession.ProjectDefinition.ProjectPath); + if (string.IsNullOrEmpty(projectDirectory)) + { + var message = "Failed to save deployment settings because the current deployment session does not have a valid project path"; + throw new FailedToSaveDeploymentSettingsException(DeployToolErrorCode.FailedToSaveDeploymentSettings, message); + } + + var deploymentSettings = new DeploymentSettings + { + AWSProfile = orchestratorSession.AWSProfileName, + AWSRegion = orchestratorSession.AWSRegion, + ApplicationName = recommendation.Recipe.DeploymentType == DeploymentTypes.ElasticContainerRegistryImage ? null : cloudApplication.Name, + RecipeId = cloudApplication.RecipeId, + Settings = new Dictionary() + }; + + var optionSettings = recommendation.GetConfigurableOptionSettingItems(); + foreach (var optionSetting in optionSettings) + { + if (saveSettingsConfig.SettingsType == SaveSettingsType.Modified && !_optionSettingHandler.IsOptionSettingModified(recommendation, optionSetting)) + { + continue; + } + + var id = optionSetting.FullyQualifiedId; + var value = _optionSettingHandler.GetOptionSettingValue(recommendation, optionSetting); + if (optionSetting.TypeHint.HasValue && (optionSetting.TypeHint == OptionSettingTypeHint.FilePath || optionSetting.TypeHint == OptionSettingTypeHint.DockerExecutionDirectory)) + { + var path = value?.ToString(); + if (string.IsNullOrEmpty(path)) + { + continue; + } + + // All file paths or directory paths must be persisted relative the the customers .NET project. + // This is a done to ensure that the resolved paths work correctly across all cloned repos. + // The relative path is also canonicalized to work across Unix and Windows OS. + var absolutePath = _directoryManager.GetAbsolutePath(projectDirectory, path); + value = _directoryManager.GetRelativePath(projectDirectory, absolutePath) + .Replace(Path.DirectorySeparatorChar, '/'); + } + deploymentSettings.Settings[id] = value; + } + + try + { + var content = JsonConvert.SerializeObject(deploymentSettings, new JsonSerializerSettings + { + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore, + ContractResolver = new SerializeModelContractResolver() + }); + + await _fileManager.WriteAllTextAsync(saveSettingsConfig.FilePath, content); + } + catch (Exception ex) + { + var message = $"Failed to save the deployment settings at {saveSettingsConfig.FilePath} due to the following error: {Environment.NewLine}{ex.Message}"; + throw new FailedToSaveDeploymentSettingsException(DeployToolErrorCode.FailedToSaveDeploymentSettings, message, ex); + } + } } } diff --git a/src/AWS.Deploy.Orchestration/OptionSettingHandler.cs b/src/AWS.Deploy.Orchestration/OptionSettingHandler.cs index 7b427d06f..de2582acc 100644 --- a/src/AWS.Deploy.Orchestration/OptionSettingHandler.cs +++ b/src/AWS.Deploy.Orchestration/OptionSettingHandler.cs @@ -91,6 +91,22 @@ public async Task SetOptionSettingValue(Recommendation recommendation, OptionSet SetDeploymentBundleProperty(recommendation, optionSettingItem, value); } + /// + /// Assigns a value to the OptionSettingItem based on the fullyQualifiedId + /// + /// + /// Thrown if one or more determine + /// is not valid. + /// + /// + /// Thrown if there doesn't exist an option setting with the given fullyQualifiedId + /// + public async Task SetOptionSettingValue(Recommendation recommendation, string fullyQualifiedId, object value, bool skipValidation = false) + { + var optionSetting = GetOptionSetting(recommendation, fullyQualifiedId); + await SetOptionSettingValue(recommendation, optionSetting, value, skipValidation); + } + /// /// Sets the corresponding value in when the /// corresponding was just set @@ -333,5 +349,79 @@ public bool IsSummaryDisplayable(Recommendation recommendation, OptionSettingIte return true; } + + /// + /// Checks whether the option setting item has been modified by the user. If it has been modified, then it will hold a non-default value + /// + /// true if the option setting item has been modified or false otherwise + public bool IsOptionSettingModified(Recommendation recommendation, OptionSettingItem optionSetting) + { + // If the option setting is not displayable, that means its dependencies are not satisfied and it does not play any role in the deployment. + // We do not need to evaluate whether it has been modified or not. + if (!IsOptionSettingDisplayable(recommendation, optionSetting)) + { + return false; + } + + if (optionSetting.Type.Equals(OptionSettingValueType.List)) + { + var currentSet = GetOptionSettingValue>(recommendation, optionSetting) ?? new SortedSet(); + var defaultSet = GetOptionSettingDefaultValue>(recommendation, optionSetting) ?? new SortedSet(); + + // return true if both have different lengths or all elements in currentSet are not present in defaultSet + return defaultSet.Count != currentSet.Count || !currentSet.All(x => defaultSet.Contains(x)); + } + + if (optionSetting.Type.Equals(OptionSettingValueType.KeyValue)) + { + var currentDict = GetOptionSettingValue>(recommendation, optionSetting) ?? new Dictionary(); + var defaultDict = GetOptionSettingDefaultValue>(recommendation, optionSetting) ?? new Dictionary(); + + // return true if both have different lengths or all keyValue pairs are not equal between currentDict and defaultDict + return defaultDict.Count != currentDict.Count || + !currentDict.All(keyPair => defaultDict.ContainsKey(keyPair.Key) && string.Equals(defaultDict[keyPair.Key], currentDict[keyPair.Key])); + } + + if (optionSetting.Type.Equals(OptionSettingValueType.Int)) + { + var currentValue = GetOptionSettingValue(recommendation, optionSetting); + var defaultValue = GetOptionSettingDefaultValue(recommendation, optionSetting); + return defaultValue != currentValue; + } + + if (optionSetting.Type.Equals(OptionSettingValueType.Double)) + { + var currentValue = GetOptionSettingValue(recommendation, optionSetting); + var defaultValue = GetOptionSettingDefaultValue(recommendation, optionSetting); + return defaultValue != currentValue; + } + + if (optionSetting.Type.Equals(OptionSettingValueType.Bool)) + { + var currentValue = GetOptionSettingValue(recommendation, optionSetting); + var defaultValue = GetOptionSettingDefaultValue(recommendation, optionSetting); + return defaultValue != currentValue; + } + + if (optionSetting.Type.Equals(OptionSettingValueType.String)) + { + var currentValue = GetOptionSettingValue(recommendation, optionSetting); + var defaultValue = GetOptionSettingDefaultValue(recommendation, optionSetting); + + if (string.IsNullOrEmpty(currentValue) && string.IsNullOrEmpty(defaultValue)) + return false; + + return !string.Equals(currentValue, defaultValue); + } + + // The option setting is of type Object and it has nested child settings. + // return true is any of the child settings are modified. + foreach (var childSetting in optionSetting.ChildOptionSettings) + { + if (IsOptionSettingModified(recommendation, childSetting)) + return true; + } + return false; + } } } diff --git a/src/AWS.Deploy.Orchestration/Orchestrator.cs b/src/AWS.Deploy.Orchestration/Orchestrator.cs index f7a2a3a8a..590a532ea 100644 --- a/src/AWS.Deploy.Orchestration/Orchestrator.cs +++ b/src/AWS.Deploy.Orchestration/Orchestrator.cs @@ -327,6 +327,10 @@ private async Task CreateContainerDeploymentBundle(CloudApplication cloudApplica await _deploymentBundleHandler.BuildDockerImage(cloudApplication, recommendation, imageTag); + // These option settings need to be persisted back as they are not always provided by the user and we have custom logic to determine their values + await _optionSettingHandler.SetOptionSettingValue(recommendation, Constants.Docker.DockerExecutionDirectoryOptionId, recommendation.DeploymentBundle.DockerExecutionDirectory); + await _optionSettingHandler.SetOptionSettingValue(recommendation, Constants.Docker.DockerfileOptionId, recommendation.DeploymentBundle.DockerfilePath); + _interactiveService.LogSectionStart("Pushing container image to Elastic Container Registry (ECR)", "Using the docker CLI to log on to ECR and push the local image to ECR."); await _deploymentBundleHandler.PushDockerImageToECR(recommendation, respositoryName, imageTag); } diff --git a/src/AWS.Deploy.Orchestration/SaveSettingsConfiguration.cs b/src/AWS.Deploy.Orchestration/SaveSettingsConfiguration.cs new file mode 100644 index 000000000..e79e8fa1d --- /dev/null +++ b/src/AWS.Deploy.Orchestration/SaveSettingsConfiguration.cs @@ -0,0 +1,36 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using AWS.Deploy.Common; + +namespace AWS.Deploy.Orchestration +{ + /// + /// This enum controls which settings are persisted when is invoked + /// + public enum SaveSettingsType + { + None, + Modified, + All + } + + public class SaveSettingsConfiguration + { + /// + /// Specifies which settings are persisted when is invoked + /// + public readonly SaveSettingsType SettingsType; + + /// + /// The absolute file path where deployment settings will be persisted + /// + public readonly string FilePath; + + public SaveSettingsConfiguration(SaveSettingsType settingsType, string filePath) + { + SettingsType = settingsType; + FilePath = filePath; + } + } +} diff --git a/src/AWS.Deploy.Orchestration/Utilities/Helpers.cs b/src/AWS.Deploy.Orchestration/Utilities/Helpers.cs index 90997b451..dcb719e15 100644 --- a/src/AWS.Deploy.Orchestration/Utilities/Helpers.cs +++ b/src/AWS.Deploy.Orchestration/Utilities/Helpers.cs @@ -87,5 +87,47 @@ public static string GetDeployToolWorkspaceDirectoryRoot(string userProfilePath, return overridenWorkspace; } + + /// + /// Creates a + /// + /// Absolute or relative JSON file path where the deployment settings will be saved. Only the settings modified by the user are persisted. + /// Absolute or relative JSON file path where the deployment settings will be saved. All deployment settings are persisted. + /// Absolute path to the user's .NET project directory + /// + /// + public static SaveSettingsConfiguration GetSaveSettingsConfiguration(string? saveSettingsPath, string? saveAllSettingsPath, string projectDirectoryPath, IFileManager fileManager) + { + if (!string.IsNullOrEmpty(saveSettingsPath) && !string.IsNullOrEmpty(saveAllSettingsPath)) + { + throw new FailedToSaveDeploymentSettingsException(DeployToolErrorCode.FailedToSaveDeploymentSettings, "Cannot save deployment settings because invalid arguments were provided. Cannot use --save-settings along with --save-all-settings"); + } + + var filePath = string.Empty; + var saveSettingsType = SaveSettingsType.None; + + if (!string.IsNullOrEmpty(saveSettingsPath)) + { + filePath = saveSettingsPath; + saveSettingsType = SaveSettingsType.Modified; + } + else if (!string.IsNullOrEmpty(saveAllSettingsPath)) + { + filePath = saveAllSettingsPath; + saveSettingsType = SaveSettingsType.All; + } + + if (!string.IsNullOrEmpty(filePath)) + { + filePath = Path.GetFullPath(filePath, projectDirectoryPath); + if (!fileManager.IsFileValidPath(filePath)) + { + var message = $"Failed to save deployment settings because {filePath} is invalid or its parent directory does not exist on disk."; + throw new FailedToSaveDeploymentSettingsException(DeployToolErrorCode.FailedToSaveDeploymentSettings, message); + } + } + + return new SaveSettingsConfiguration(saveSettingsType, filePath); + } } } diff --git a/src/AWS.Deploy.Recipes/CdkTemplates/BlazorWasm/Generated/Recipe.cs b/src/AWS.Deploy.Recipes/CdkTemplates/BlazorWasm/Generated/Recipe.cs index 43b9cc0a1..b207527f1 100644 --- a/src/AWS.Deploy.Recipes/CdkTemplates/BlazorWasm/Generated/Recipe.cs +++ b/src/AWS.Deploy.Recipes/CdkTemplates/BlazorWasm/Generated/Recipe.cs @@ -214,7 +214,7 @@ private void ConfigureS3Deployment(IRecipeProps props) { Sources = new ISource[] { Source.Asset(Path.Combine(props.DotnetPublishOutputDirectory, "wwwroot")) }, DestinationBucket = ContentS3Bucket, - MemoryLimit = 4096, + MemoryLimit = 3008, Distribution = CloudFrontDistribution, DistributionPaths = new string[] { "/*" } diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/DeploymentSettingsHandlerTests.cs b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/DeploymentSettingsHandlerTests.cs index 68e3a8b19..d1ba98d4f 100644 --- a/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/DeploymentSettingsHandlerTests.cs +++ b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/DeploymentSettingsHandlerTests.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection; -using System.Text; using System.Threading.Tasks; using Amazon.Runtime; using AWS.Deploy.CLI.Common.UnitTests.Utilities; @@ -21,6 +19,7 @@ namespace AWS.Deploy.CLI.Common.UnitTests.ConfigFileDeployment { public class DeploymentSettingsHandlerTests { + private readonly string _projectPath; private readonly IOptionSettingHandler _optionSettingHandler; private readonly IDeploymentManifestEngine _deploymentManifestEngine; private readonly IOrchestratorInteractiveService _orchestratorInteractiveService; @@ -29,10 +28,15 @@ public class DeploymentSettingsHandlerTests private readonly IRecipeHandler _recipeHandler; private readonly IDeploymentSettingsHandler _deploymentSettingsHandler; private readonly RecommendationEngine _recommendationEngine; + private readonly OrchestratorSession _orchestratorSession; + + private const string BEANSTALK_PLATFORM_ARN_TOKEN = "{LatestDotnetBeanstalkPlatformArn}"; + private const string STACK_NAME_TOKEN = "{StackName}"; + private const string DEFAULT_VPC_ID_TOKEN = "{DefaultVpcId}"; public DeploymentSettingsHandlerTests() { - var projectPath = SystemIOUtilities.ResolvePath("WebAppNoDockerFile"); + _projectPath = SystemIOUtilities.ResolvePath("WebAppWithDockerFile"); _directoryManager = new DirectoryManager(); _fileManager = new FileManager(); _deploymentManifestEngine = new DeploymentManifestEngine(_directoryManager, _fileManager); @@ -40,8 +44,8 @@ public DeploymentSettingsHandlerTests() var parser = new ProjectDefinitionParser(_fileManager, _directoryManager); var awsCredentials = new Mock(); - var session = new OrchestratorSession( - parser.Parse(projectPath).Result, + _orchestratorSession = new OrchestratorSession( + parser.Parse(_projectPath).Result, awsCredentials.Object, "us-west-2", "123456789012") @@ -52,15 +56,15 @@ public DeploymentSettingsHandlerTests() var validatorFactory = new TestValidatorFactory(); _optionSettingHandler = new OptionSettingHandler(validatorFactory); _recipeHandler = new RecipeHandler(_deploymentManifestEngine, _orchestratorInteractiveService, _directoryManager, _fileManager, _optionSettingHandler, validatorFactory); - _deploymentSettingsHandler = new DeploymentSettingsHandler(_fileManager, _optionSettingHandler, _recipeHandler); - _recommendationEngine = new RecommendationEngine(session, _recipeHandler); + _deploymentSettingsHandler = new DeploymentSettingsHandler(_fileManager, _directoryManager, _optionSettingHandler, _recipeHandler); + _recommendationEngine = new RecommendationEngine(_orchestratorSession, _recipeHandler); } [Fact] - public async Task AppRunnerTests() + public async Task ApplySettings_AppRunner() { // ARRANGE - var recommendations = _recommendationEngine.ComputeRecommendations().GetAwaiter().GetResult(); + var recommendations = await _recommendationEngine.ComputeRecommendations(); var selectedRecommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, "AspNetAppAppRunner")); var filePath = Path.Combine("ConfigFileDeployment", "TestFiles", "AppRunnerConfigFile.json"); @@ -80,10 +84,10 @@ public async Task AppRunnerTests() } [Fact] - public async Task ECSFargateTests() + public async Task ApplySettings_ECSFargate() { // ARRANGE - var recommendations = _recommendationEngine.ComputeRecommendations().GetAwaiter().GetResult(); + var recommendations = await _recommendationEngine.ComputeRecommendations(); var selectedRecommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, "AspNetAppEcsFargate")); var filePath = Path.Combine("ConfigFileDeployment", "TestFiles", "ECSFargateConfigFile.json"); @@ -109,10 +113,10 @@ public async Task ECSFargateTests() } [Fact] - public async Task ElasticBeanStalkTests() + public async Task ApplySettings_ElasticBeanStalk() { // ARRANGE - var recommendations = _recommendationEngine.ComputeRecommendations().GetAwaiter().GetResult(); + var recommendations = await _recommendationEngine.ComputeRecommendations(); var selectedRecommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, "AspNetAppElasticBeanstalkLinux")); var filePath = Path.Combine("ConfigFileDeployment", "TestFiles", "ElasticBeanStalkConfigFile.json"); @@ -142,6 +146,106 @@ public async Task ElasticBeanStalkTests() Assert.Equal("VarValue", envVars["VarName"]); } + [Theory] + [InlineData(SaveSettingsType.All, "ConfigFileDeployment", "TestFiles", "SettingsSnapshot_NonContainer.json")] + [InlineData(SaveSettingsType.Modified, "ConfigFileDeployment", "TestFiles", "SettingsSnapshot_NonContainer_ModifiedOnly.json")] + public async Task SaveSettings_NonContainerBased(SaveSettingsType saveSettingsType, string path1, string path2, string path3) + { + // ARRANGE + var recommendations = await _recommendationEngine.ComputeRecommendations(); + var selectedRecommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, "AspNetAppElasticBeanstalkLinux")); + var expectedSnapshotfilePath = Path.Combine(path1, path2, path3); + var actualSnapshotFilePath = Path.Combine(Path.GetTempPath(), $"DeploymentSettings-{Guid.NewGuid().ToString().Split('-').Last()}.json"); + var cloudApplication = new CloudApplication("MyAppStack", "", CloudApplicationResourceType.CloudFormationStack, "AspNetAppElasticBeanstalkLinux"); + + // ARRANGE - add replacement tokens + selectedRecommendation.AddReplacementToken(BEANSTALK_PLATFORM_ARN_TOKEN, "Latest-ARN"); + selectedRecommendation.AddReplacementToken(STACK_NAME_TOKEN, "MyAppStack"); + selectedRecommendation.AddReplacementToken(DEFAULT_VPC_ID_TOKEN, "vpc-12345678"); + + // ARRANGE - Modify option setting items + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "BeanstalkApplication", "MyBeanstalkApplication"); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "BeanstalkEnvironment.EnvironmentName", "MyBeanstalkEnvironment"); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "EnvironmentType", "LoadBalanced"); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "XRayTracingSupportEnabled", true); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "ElasticBeanstalkEnvironmentVariables", new Dictionary + { + { "key1", "value1" }, + { "key2", "value2" } + }); + + // ACT + await _deploymentSettingsHandler.SaveSettings(new SaveSettingsConfiguration(saveSettingsType, actualSnapshotFilePath), selectedRecommendation, cloudApplication, _orchestratorSession); + + // ASSERT + var actualSnapshot = await _fileManager.ReadAllTextAsync(actualSnapshotFilePath); + var expectedSnapshot = await _fileManager.ReadAllTextAsync(expectedSnapshotfilePath); + actualSnapshot = SanitizeFileContents(actualSnapshot); + expectedSnapshot = SanitizeFileContents(expectedSnapshot); + Assert.Equal(expectedSnapshot, actualSnapshot); + } + + [Theory] + [InlineData(SaveSettingsType.All, "ConfigFileDeployment", "TestFiles", "SettingsSnapshot_Container.json")] + [InlineData(SaveSettingsType.Modified, "ConfigFileDeployment", "TestFiles", "SettingsSnapshot_Container_ModifiedOnly.json")] + public async Task SaveSettings_ContainerBased(SaveSettingsType saveSettingsType, string path1, string path2, string path3) + { + // ARRANGE + var recommendations = await _recommendationEngine.ComputeRecommendations(); + var selectedRecommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, "AspNetAppAppRunner")); + var expectedSnapshotfilePath = Path.Combine(path1, path2, path3); + var actualSnapshotFilePath = Path.Combine(Path.GetTempPath(), $"DeploymentSettings-{Guid.NewGuid().ToString().Split('-').Last()}.json"); + var cloudApplication = new CloudApplication("MyAppStack", "", CloudApplicationResourceType.CloudFormationStack, "AspNetAppAppRunner"); + + // ARRANGE - add replacement tokens + selectedRecommendation.AddReplacementToken(STACK_NAME_TOKEN, "MyAppStack"); + selectedRecommendation.AddReplacementToken(DEFAULT_VPC_ID_TOKEN, "vpc-12345678"); + + // ARRANGE - Modify option setting items + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "ServiceName", "MyAppRunnerService"); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "Port", "100"); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "ECRRepositoryName", "my-ecr-repository"); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "DockerfilePath", Path.Combine("DockerAssets", "Dockerfile")); // relative path + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "DockerExecutionDirectory", Path.Combine(_projectPath, "DockerAssets")); // absolute path + + // ACT + await _deploymentSettingsHandler.SaveSettings(new SaveSettingsConfiguration(saveSettingsType, actualSnapshotFilePath), selectedRecommendation, cloudApplication, _orchestratorSession); + + // ASSERT + var actualSnapshot = await _fileManager.ReadAllTextAsync(actualSnapshotFilePath); + var expectedSnapshot = await _fileManager.ReadAllTextAsync(expectedSnapshotfilePath); + actualSnapshot = SanitizeFileContents(actualSnapshot); + expectedSnapshot = SanitizeFileContents(expectedSnapshot); + Assert.Equal(expectedSnapshot, actualSnapshot); + } + + [Fact] + public async Task SaveSettings_PushImageToECR() + { + // ARRANGE + var recommendations = await _recommendationEngine.ComputeRecommendations(); + var selectedRecommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, "PushContainerImageEcr")); + var expectedSnapshotfilePath = Path.Combine("ConfigFileDeployment", "TestFiles", "SettingsSnapshot_PushImageECR.json"); + var actualSnapshotFilePath = Path.Combine(Path.GetTempPath(), $"DeploymentSettings-{Guid.NewGuid().ToString().Split('-').Last()}.json"); + var cloudApplication = new CloudApplication("my-ecr-repository", "", CloudApplicationResourceType.ElasticContainerRegistryImage, "PushContainerImageEcr"); + + // ARRANGE - Modify option setting items + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "ImageTag", "123456789"); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "ECRRepositoryName", "my-ecr-repository"); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "DockerfilePath", Path.Combine("DockerAssets", "Dockerfile")); // relative path + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "DockerExecutionDirectory", Path.Combine(_projectPath, "DockerAssets")); // absolute path + + // ACT + await _deploymentSettingsHandler.SaveSettings(new SaveSettingsConfiguration(SaveSettingsType.All, actualSnapshotFilePath), selectedRecommendation, cloudApplication, _orchestratorSession); + + // ASSERT + var actualSnapshot = await _fileManager.ReadAllTextAsync(actualSnapshotFilePath); + var expectedSnapshot = await _fileManager.ReadAllTextAsync(expectedSnapshotfilePath); + actualSnapshot = SanitizeFileContents(actualSnapshot); + expectedSnapshot = SanitizeFileContents(expectedSnapshot); + Assert.Equal(expectedSnapshot, actualSnapshot); + } + private object GetOptionSettingValue(Recommendation recommendation, string fullyQualifiedId) { var optionSetting = _optionSettingHandler.GetOptionSetting(recommendation, fullyQualifiedId); @@ -153,6 +257,14 @@ private T GetOptionSettingValue(Recommendation recommendation, string fullyQu var optionSetting = _optionSettingHandler.GetOptionSetting(recommendation, fullyQualifiedId); return _optionSettingHandler.GetOptionSettingValue(recommendation, optionSetting); } + + private string SanitizeFileContents(string content) + { + return content.Replace("\r\n", Environment.NewLine) + .Replace("\n", Environment.NewLine) + .Replace("\r\r\n", Environment.NewLine) + .Trim(); + } } public class TestValidatorFactory : IValidatorFactory diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_Container.json b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_Container.json new file mode 100644 index 000000000..1717e6e1f --- /dev/null +++ b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_Container.json @@ -0,0 +1,34 @@ +{ + "AWSProfile": "default", + "AWSRegion": "us-west-2", + "ApplicationName": "MyAppStack", + "RecipeId": "AspNetAppAppRunner", + "Settings": { + "ServiceName": "MyAppRunnerService", + "Port": 100, + "StartCommand": "", + "ApplicationIAMRole": { + "CreateNew": true + }, + "ServiceAccessIAMRole": { + "CreateNew": true + }, + "Cpu": "1024", + "Memory": "2048", + "EncryptionKmsKey": "", + "HealthCheckProtocol": "TCP", + "HealthCheckPath": "", + "HealthCheckInterval": 5, + "HealthCheckTimeout": 2, + "HealthCheckHealthyThreshold": 3, + "HealthCheckUnhealthyThreshold": 3, + "VPCConnector": { + "UseVPCConnector": false + }, + "AppRunnerEnvironmentVariables": "", + "DockerBuildArgs": "", + "DockerfilePath": "DockerAssets/Dockerfile", + "DockerExecutionDirectory": "DockerAssets", + "ECRRepositoryName": "my-ecr-repository" + } +} diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_Container_ModifiedOnly.json b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_Container_ModifiedOnly.json new file mode 100644 index 000000000..5ac2c63a6 --- /dev/null +++ b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_Container_ModifiedOnly.json @@ -0,0 +1,13 @@ +{ + "AWSProfile": "default", + "AWSRegion": "us-west-2", + "ApplicationName": "MyAppStack", + "RecipeId": "AspNetAppAppRunner", + "Settings": { + "ServiceName": "MyAppRunnerService", + "Port": 100, + "DockerfilePath": "DockerAssets/Dockerfile", + "DockerExecutionDirectory": "DockerAssets", + "ECRRepositoryName": "my-ecr-repository" + } +} diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_NonContainer.json b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_NonContainer.json new file mode 100644 index 000000000..f8493658f --- /dev/null +++ b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_NonContainer.json @@ -0,0 +1,46 @@ +{ + "AWSProfile": "default", + "AWSRegion": "us-west-2", + "ApplicationName": "MyAppStack", + "RecipeId": "AspNetAppElasticBeanstalkLinux", + "Settings": { + "BeanstalkApplication": "MyBeanstalkApplication", + "BeanstalkEnvironment": { + "EnvironmentName": "MyBeanstalkEnvironment" + }, + "InstanceType": "", + "EnvironmentType": "LoadBalanced", + "LoadBalancerType": "application", + "ApplicationIAMRole": { + "CreateNew": true + }, + "ServiceIAMRole": { + "CreateNew": true + }, + "EC2KeyPair": "", + "ElasticBeanstalkPlatformArn": "Latest-ARN", + "ElasticBeanstalkManagedPlatformUpdates": { + "ManagedActionsEnabled": true, + "PreferredStartTime": "Sun:00:00", + "UpdateLevel": "minor" + }, + "XRayTracingSupportEnabled": true, + "ReverseProxy": "nginx", + "EnhancedHealthReporting": "enhanced", + "HealthCheckURL": "/", + "ElasticBeanstalkRollingUpdates": { + "RollingUpdatesEnabled": false + }, + "CNamePrefix": "", + "ElasticBeanstalkEnvironmentVariables": { + "key1": "value1", + "key2": "value2" + }, + "VPC": { + "UseVPC": false + }, + "DotnetBuildConfiguration": "Release", + "DotnetPublishArgs": "", + "SelfContainedBuild": false + } +} diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_NonContainer_ModifiedOnly.json b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_NonContainer_ModifiedOnly.json new file mode 100644 index 000000000..87163d2f6 --- /dev/null +++ b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_NonContainer_ModifiedOnly.json @@ -0,0 +1,17 @@ +{ + "AWSProfile": "default", + "AWSRegion": "us-west-2", + "ApplicationName": "MyAppStack", + "RecipeId": "AspNetAppElasticBeanstalkLinux", + "Settings": { + "BeanstalkEnvironment": { + "EnvironmentName": "MyBeanstalkEnvironment" + }, + "EnvironmentType": "LoadBalanced", + "XRayTracingSupportEnabled": true, + "ElasticBeanstalkEnvironmentVariables": { + "key1": "value1", + "key2": "value2" + } + } +} diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_PushImageECR.json b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_PushImageECR.json new file mode 100644 index 000000000..93a75a98b --- /dev/null +++ b/test/AWS.Deploy.CLI.Common.UnitTests/ConfigFileDeployment/TestFiles/SettingsSnapshot_PushImageECR.json @@ -0,0 +1,12 @@ +{ + "AWSProfile": "default", + "AWSRegion": "us-west-2", + "RecipeId": "PushContainerImageEcr", + "Settings": { + "ImageTag": 123456789, + "DockerBuildArgs": "", + "DockerfilePath": "DockerAssets/Dockerfile", + "DockerExecutionDirectory": "DockerAssets", + "ECRRepositoryName": "my-ecr-repository" + } +} diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/IO/TestFileManager.cs b/test/AWS.Deploy.CLI.Common.UnitTests/IO/TestFileManager.cs index 26d80d0b5..dfc1df1f1 100644 --- a/test/AWS.Deploy.CLI.Common.UnitTests/IO/TestFileManager.cs +++ b/test/AWS.Deploy.CLI.Common.UnitTests/IO/TestFileManager.cs @@ -51,6 +51,8 @@ public bool Exists(string path, string directory) return Exists(Path.Combine(directory, path)); } } + + public bool IsFileValidPath(string filePath) => throw new NotImplementedException(); } public static class TestFileManagerExtensions diff --git a/test/AWS.Deploy.CLI.IntegrationTests/ConfigFileDeployment/ECSFargateDeploymentTest.cs b/test/AWS.Deploy.CLI.IntegrationTests/ConfigFileDeployment/ECSFargateDeploymentTest.cs index 7c5ed3509..e5e6401d9 100644 --- a/test/AWS.Deploy.CLI.IntegrationTests/ConfigFileDeployment/ECSFargateDeploymentTest.cs +++ b/test/AWS.Deploy.CLI.IntegrationTests/ConfigFileDeployment/ECSFargateDeploymentTest.cs @@ -4,6 +4,7 @@ using System; using System.IO; using System.Linq; +using System.Collections.Generic; using Amazon.CloudFormation; using Amazon.ECS; using Amazon.ECS.Model; @@ -59,14 +60,14 @@ public ECSFargateDeploymentTest() [Fact] public async Task PerformDeployment() { - // Deploy + var stackNamePlaceholder = "{StackName}"; + _stackName = $"WebAppWithDockerFile{Guid.NewGuid().ToString().Split('-').Last()}"; + _clusterName = $"{_stackName}-cluster"; var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppWithDockerFile", "WebAppWithDockerFile.csproj")); var configFilePath = Path.Combine(Directory.GetParent(projectPath).FullName, "ECSFargateConfigFile.json"); - var suffix = ConfigFileHelper.ReplacePlaceholders(configFilePath); - - _stackName = $"EcsFargate{suffix}"; - _clusterName = $"MyNewCluster{suffix}"; + ConfigFileHelper.ApplyReplacementTokens(new Dictionary { { stackNamePlaceholder, _stackName } }, configFilePath); + // Deploy var deployArgs = new[] { "deploy", "--project-path", projectPath, "--apply", configFilePath, "--silent", "--diagnostics" }; Assert.Equal(CommandReturnCodes.SUCCESS, await _app.Run(deployArgs)); diff --git a/test/AWS.Deploy.CLI.IntegrationTests/ConfigFileDeployment/ElasticBeanStalkDeploymentTest.cs b/test/AWS.Deploy.CLI.IntegrationTests/ConfigFileDeployment/ElasticBeanStalkDeploymentTest.cs index 4fbd14b5c..c796fc4c2 100644 --- a/test/AWS.Deploy.CLI.IntegrationTests/ConfigFileDeployment/ElasticBeanStalkDeploymentTest.cs +++ b/test/AWS.Deploy.CLI.IntegrationTests/ConfigFileDeployment/ElasticBeanStalkDeploymentTest.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -11,10 +12,9 @@ using AWS.Deploy.CLI.IntegrationTests.Extensions; using AWS.Deploy.CLI.IntegrationTests.Helpers; using AWS.Deploy.CLI.IntegrationTests.Services; -using AWS.Deploy.Common; +using AWS.Deploy.Orchestration; using Microsoft.Extensions.DependencyInjection; using Xunit; -using Environment = System.Environment; namespace AWS.Deploy.CLI.IntegrationTests.ConfigFileDeployment { @@ -27,6 +27,7 @@ public class ElasticBeanStalkDeploymentTest : IDisposable private bool _isDisposed; private string _stackName; private readonly TestAppManager _testAppManager; + private readonly IServiceProvider _serviceProvider; public ElasticBeanStalkDeploymentTest() { @@ -35,12 +36,12 @@ public ElasticBeanStalkDeploymentTest() serviceCollection.AddCustomServices(); serviceCollection.AddTestServices(); - var serviceProvider = serviceCollection.BuildServiceProvider(); + _serviceProvider = serviceCollection.BuildServiceProvider(); - _app = serviceProvider.GetService(); + _app = _serviceProvider.GetService(); Assert.NotNull(_app); - _interactiveService = serviceProvider.GetService(); + _interactiveService = _serviceProvider.GetService(); Assert.NotNull(_interactiveService); _httpHelper = new HttpHelper(_interactiveService); @@ -54,12 +55,27 @@ public ElasticBeanStalkDeploymentTest() [Fact] public async Task PerformDeployment() { - // Deploy + // Create the config file var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppNoDockerFile", "WebAppNoDockerFile.csproj")); - var configFilePath = Path.Combine(Directory.GetParent(projectPath).FullName, "ElasticBeanStalkConfigFile.json"); - var suffix = ConfigFileHelper.ReplacePlaceholders(configFilePath); + var stackNamePlaceholder = "{StackName}"; + var configFilePath = Path.Combine(Path.GetTempPath(), $"DeploymentSettings-{Guid.NewGuid().ToString().Split('-').Last()}.json"); + var expectedConfigFilePath = Path.Combine(Directory.GetParent(projectPath).FullName, "ElasticBeanStalkConfigFile.json"); + var optionSettings = new Dictionary + { + {"BeanstalkApplication.CreateNew", true }, + {"BeanstalkApplication.ApplicationName", $"{stackNamePlaceholder}-app" }, + {"BeanstalkEnvironment.EnvironmentName", $"{stackNamePlaceholder}-dev" }, + {"EnvironmentType", "LoadBalanced" }, + {"LoadBalancerType", "application" }, + {"ApplicationIAMRole.CreateNew", true }, + {"XRayTracingSupportEnabled", true } + }; + await ConfigFileHelper.CreateConfigFile(_serviceProvider, stackNamePlaceholder, "AspNetAppElasticBeanstalkLinux", optionSettings, projectPath, configFilePath, SaveSettingsType.Modified); + Assert.True(await ConfigFileHelper.VerifyConfigFileContents(expectedConfigFilePath, configFilePath)); - _stackName = $"ElasticBeanStalk{suffix}"; + // Deploy + _stackName = $"WebAppNoDockerFile{Guid.NewGuid().ToString().Split('-').Last()}"; + ConfigFileHelper.ApplyReplacementTokens(new Dictionary { {stackNamePlaceholder, _stackName } }, configFilePath); var deployArgs = new[] { "deploy", "--project-path", projectPath, "--apply", configFilePath, "--silent", "--diagnostics" }; Assert.Equal(CommandReturnCodes.SUCCESS, await _app.Run(deployArgs)); diff --git a/test/AWS.Deploy.CLI.IntegrationTests/Helpers/ConfigFileHelper.cs b/test/AWS.Deploy.CLI.IntegrationTests/Helpers/ConfigFileHelper.cs index 8085d0f84..3a52b6c9a 100644 --- a/test/AWS.Deploy.CLI.IntegrationTests/Helpers/ConfigFileHelper.cs +++ b/test/AWS.Deploy.CLI.IntegrationTests/Helpers/ConfigFileHelper.cs @@ -2,20 +2,83 @@ // SPDX-License-Identifier: Apache-2.0 using System; +using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; +using AWS.Deploy.Common; +using AWS.Deploy.Common.Recipes; +using AWS.Deploy.Orchestration; +using AWS.Deploy.Orchestration.RecommendationEngine; +using Microsoft.Extensions.DependencyInjection; +using Xunit; namespace AWS.Deploy.CLI.IntegrationTests.Helpers { public class ConfigFileHelper { - public static string ReplacePlaceholders(string configFilePath) + /// + /// Applies replacement tokens to a file + /// + public static void ApplyReplacementTokens(Dictionary replacements, string filePath) { - var suffix = Guid.NewGuid().ToString().Split('-').Last(); - var json = File.ReadAllText(configFilePath); - json = json.Replace("{Suffix}", suffix); - File.WriteAllText(configFilePath, json); - return suffix; + var content = File.ReadAllText(filePath); + foreach (var replacement in replacements) + { + content = content.Replace(replacement.Key, replacement.Value); + } + File.WriteAllText(filePath, content); + } + + /// + /// This method create a JSON config file from the specified recipeId and option setting values + /// + /// The dependency injection container + /// The cloud application name used to uniquely identify the app within AWS. Ex - CloudFormation stack name + /// The recipeId for the deployment recommendation + /// This is a dictionary with FullyQualifiedId as key and their corresponsing option setting values + /// The path to the .NET application that will be deployed + /// The absolute JSON file path where the deployment settings are persisted + public static async Task CreateConfigFile(IServiceProvider serviceProvider, string applicationName, string recipeId, Dictionary optionSettings, string projectPath, string configFilePath, SaveSettingsType saveSettingsType) + { + var parser = serviceProvider.GetService(); + var optionSettingHandler = serviceProvider.GetRequiredService(); + var deploymentSettingHandler = serviceProvider.GetRequiredService(); + + var orchestratorSession = new OrchestratorSession(parser.Parse(projectPath).Result); + var cloudApplication = new CloudApplication(applicationName, "", CloudApplicationResourceType.CloudFormationStack, recipeId); + + var recommendationEngine = new RecommendationEngine(orchestratorSession, serviceProvider.GetService()); + var recommendations = await recommendationEngine.ComputeRecommendations(); + var selectedRecommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, recipeId)); + + foreach (var item in optionSettings) + { + await optionSettingHandler.SetOptionSettingValue(selectedRecommendation, item.Key, item.Value, skipValidation: true); + } + + await deploymentSettingHandler.SaveSettings(new SaveSettingsConfiguration(saveSettingsType, configFilePath), selectedRecommendation, cloudApplication, orchestratorSession); + } + + /// + /// Verifies that the file contents are the same by accounting for os-specific new line delimiters. + /// + /// true if the contents match, false otherwise + public static async Task VerifyConfigFileContents(string expectedContentPath, string actualContentPath) + { + var expectContent = await File.ReadAllTextAsync(expectedContentPath); + var actualContent = await File.ReadAllTextAsync(actualContentPath); + actualContent = SanitizeFileContents(actualContent); + expectContent = SanitizeFileContents(expectContent); + return string.Equals(expectContent, actualContent); + } + + private static string SanitizeFileContents(string content) + { + return content.Replace("\r\n", Environment.NewLine) + .Replace("\n", Environment.NewLine) + .Replace("\r\r\n", Environment.NewLine) + .Trim(); } } } diff --git a/test/AWS.Deploy.CLI.IntegrationTests/WebAppNoDockerFileTests.cs b/test/AWS.Deploy.CLI.IntegrationTests/WebAppNoDockerFileTests.cs index a52b6a110..aad7c8525 100644 --- a/test/AWS.Deploy.CLI.IntegrationTests/WebAppNoDockerFileTests.cs +++ b/test/AWS.Deploy.CLI.IntegrationTests/WebAppNoDockerFileTests.cs @@ -72,6 +72,7 @@ public WebAppNoDockerFileTests() public async Task DefaultConfigurations() { _stackName = $"WebAppNoDockerFile{Guid.NewGuid().ToString().Split('-').Last()}"; + var saveSettingsFilePath = Path.Combine(Path.GetTempPath(), $"DeploymentSettings-{Guid.NewGuid().ToString().Split('-').Last()}.json"); // Arrange input for deploy await _interactiveService.StdInWriter.WriteAsync(Environment.NewLine); // Select default recommendation @@ -80,7 +81,7 @@ public async Task DefaultConfigurations() // Deploy var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppNoDockerFile", "WebAppNoDockerFile.csproj")); - var deployArgs = new[] { "deploy", "--project-path", projectPath, "--application-name", _stackName, "--diagnostics" }; + var deployArgs = new[] { "deploy", "--project-path", projectPath, "--application-name", _stackName, "--diagnostics", "--save-settings", saveSettingsFilePath }; Assert.Equal(CommandReturnCodes.SUCCESS, await _app.Run(deployArgs)); // Verify application is deployed and running @@ -105,6 +106,9 @@ public async Task DefaultConfigurations() Assert.True(Directory.Exists(Path.Combine(_customWorkspace, "temp"))); Assert.True(Directory.Exists(Path.Combine(_customWorkspace, "Projects"))); + // Verify existence of the saved deployment settings file + Assert.True(File.Exists(saveSettingsFilePath)); + // list var listArgs = new[] { "list-deployments", "--diagnostics" }; Assert.Equal(CommandReturnCodes.SUCCESS, await _app.Run(listArgs));; diff --git a/test/AWS.Deploy.CLI.IntegrationTests/WebAppWithDockerFileTests.cs b/test/AWS.Deploy.CLI.IntegrationTests/WebAppWithDockerFileTests.cs index ee6eaafdd..22ed682f9 100644 --- a/test/AWS.Deploy.CLI.IntegrationTests/WebAppWithDockerFileTests.cs +++ b/test/AWS.Deploy.CLI.IntegrationTests/WebAppWithDockerFileTests.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Collections.Generic; using Amazon.CloudFormation; using Amazon.ECS; using Amazon.ECS.Model; @@ -123,13 +124,13 @@ public async Task DefaultConfigurations() [Fact] public async Task AppRunnerDeployment() { + var stackNamePlaceholder = "{StackName}"; _stackName = $"WebAppWithDockerFile{Guid.NewGuid().ToString().Split('-').Last()}"; - - // Deploy var projectPath = _testAppManager.GetProjectPath(Path.Combine("testapps", "WebAppWithDockerFile", "WebAppWithDockerFile.csproj")); var configFilePath = Path.Combine(Directory.GetParent(projectPath).FullName, "AppRunnerConfigFile.json"); - ConfigFileHelper.ReplacePlaceholders(configFilePath); + ConfigFileHelper.ApplyReplacementTokens(new Dictionary { { stackNamePlaceholder, _stackName } }, configFilePath); + // Deploy var deployArgs = new[] { "deploy", "--project-path", projectPath, "--application-name", _stackName, "--diagnostics", "--apply", configFilePath, "--silent" }; Assert.Equal(CommandReturnCodes.SUCCESS, await _app.Run(deployArgs)); diff --git a/test/AWS.Deploy.CLI.UnitTests/DeploymentBundleHandlerTests.cs b/test/AWS.Deploy.CLI.UnitTests/DeploymentBundleHandlerTests.cs index 5e58aec60..59f95e83b 100644 --- a/test/AWS.Deploy.CLI.UnitTests/DeploymentBundleHandlerTests.cs +++ b/test/AWS.Deploy.CLI.UnitTests/DeploymentBundleHandlerTests.cs @@ -97,7 +97,7 @@ public async Task BuildDockerImage_DockerExecutionDirectoryNotSet() [Fact] public async Task BuildDockerImage_DockerExecutionDirectorySet() { - var projectPath = SystemIOUtilities.ResolvePath("ConsoleAppTask"); + var projectPath = new DirectoryInfo(SystemIOUtilities.ResolvePath("ConsoleAppTask")).FullName; var project = await _projectDefinitionParser.Parse(projectPath); var options = new List() { diff --git a/test/AWS.Deploy.CLI.UnitTests/IsOptionSettingModifiedTests.cs b/test/AWS.Deploy.CLI.UnitTests/IsOptionSettingModifiedTests.cs new file mode 100644 index 000000000..15b6916fa --- /dev/null +++ b/test/AWS.Deploy.CLI.UnitTests/IsOptionSettingModifiedTests.cs @@ -0,0 +1,121 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AWS.Deploy.CLI.UnitTests.Utilities; +using AWS.Deploy.Common.IO; +using AWS.Deploy.Common.Recipes; +using AWS.Deploy.Common.Recipes.Validation; +using AWS.Deploy.Orchestration; +using AWS.Deploy.Constants; +using Moq; +using Xunit; + +namespace AWS.Deploy.CLI.UnitTests +{ + public class IsOptionSettingModifiedTests + { + private readonly IOptionSettingHandler _optionSettingHandler; + private readonly Mock _serviceProvider; + + public IsOptionSettingModifiedTests() + { + _serviceProvider = new Mock(); + _optionSettingHandler = new OptionSettingHandler(new ValidatorFactory(_serviceProvider.Object)); + } + + [Fact] + public async Task IsOptionSettingModified_ElasticBeanstalk() + { + // ARRANGE - select recommendation + var engine = await HelperFunctions.BuildRecommendationEngine( + "WebAppWithDockerFile", + new FileManager(), + new DirectoryManager(), + "us-west-2", + "123456789012", + "default" + ); + var recommendations = await engine.ComputeRecommendations(); + var selectedRecommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, "AspNetAppElasticBeanstalkLinux")); + + // ARRANGE - add replacement tokens + selectedRecommendation.AddReplacementToken(RecipeIdentifier.REPLACE_TOKEN_LATEST_DOTNET_BEANSTALK_PLATFORM_ARN, "Latest-ARN"); + selectedRecommendation.AddReplacementToken(RecipeIdentifier.REPLACE_TOKEN_STACK_NAME, "MyAppStack"); + selectedRecommendation.AddReplacementToken(RecipeIdentifier.REPLACE_TOKEN_DEFAULT_VPC_ID, "vpc-12345678"); + + // ARRANGE - modify settings so that they are different from their default values + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "BeanstalkEnvironment.EnvironmentName", "MyEnvironment", skipValidation: true); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "EnvironmentType", "LoadBalanced", skipValidation: true); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "ApplicationIAMRole.CreateNew", false, skipValidation: true); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "ApplicationIAMRole.RoleArn", "MyRoleArn", skipValidation: true); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "XRayTracingSupportEnabled", true, skipValidation: true); + var modifiedSettingsId = new HashSet + { + "BeanstalkEnvironment", "EnvironmentType", "ApplicationIAMRole", "XRayTracingSupportEnabled" + }; + + // ACT and ASSERT + foreach (var optionSetting in selectedRecommendation.GetConfigurableOptionSettingItems()) + { + if (modifiedSettingsId.Contains(optionSetting.FullyQualifiedId)) + { + Assert.True(_optionSettingHandler.IsOptionSettingModified(selectedRecommendation, optionSetting)); + } + else + { + Assert.False(_optionSettingHandler.IsOptionSettingModified(selectedRecommendation, optionSetting)); + } + } + } + + [Fact] + public async Task IsOptionSettingModified_ECSFargate() + { + // ARRANGE - select recommendation + var engine = await HelperFunctions.BuildRecommendationEngine( + "WebAppWithDockerFile", + new FileManager(), + new DirectoryManager(), + "us-west-2", + "123456789012", + "default" + ); + var recommendations = await engine.ComputeRecommendations(); + var selectedRecommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, "AspNetAppEcsFargate")); + + // ARRANGE - add replacement tokens + selectedRecommendation.AddReplacementToken(RecipeIdentifier.REPLACE_TOKEN_STACK_NAME, "MyAppStack"); + selectedRecommendation.AddReplacementToken(RecipeIdentifier.REPLACE_TOKEN_DEFAULT_VPC_ID, "vpc-12345678"); + selectedRecommendation.AddReplacementToken(RecipeIdentifier.REPLACE_TOKEN_HAS_DEFAULT_VPC, true); + + // ARRANGE - modify settings so that they are different from their default values + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "ECSServiceName", "MyECSService", skipValidation: true); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "DesiredCount", 10, skipValidation: true); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "ECSCluster.CreateNew", false, skipValidation: true); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "ECSCluster.ClusterArn", "MyClusterArn", skipValidation: true); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "DockerfilePath", "Path/To/DockerFile", skipValidation: true); + await _optionSettingHandler.SetOptionSettingValue(selectedRecommendation, "DockerExecutionDirectory", "Path/To/ExecutionDirectory", skipValidation: true); + var modifiedSettingsId = new HashSet + { + "ECSServiceName", "DesiredCount", "ECSCluster", "DockerfilePath", "DockerExecutionDirectory" + }; + + // ACT and ASSERT + foreach (var optionSetting in selectedRecommendation.GetConfigurableOptionSettingItems()) + { + if (modifiedSettingsId.Contains(optionSetting.FullyQualifiedId)) + { + Assert.True(_optionSettingHandler.IsOptionSettingModified(selectedRecommendation, optionSetting)); + } + else + { + Assert.False(_optionSettingHandler.IsOptionSettingModified(selectedRecommendation, optionSetting)); + } + } + } + } +} diff --git a/test/AWS.Deploy.Orchestration.UnitTests/GetSaveSettingsConfigurationTests.cs b/test/AWS.Deploy.Orchestration.UnitTests/GetSaveSettingsConfigurationTests.cs new file mode 100644 index 000000000..5a93b96ba --- /dev/null +++ b/test/AWS.Deploy.Orchestration.UnitTests/GetSaveSettingsConfigurationTests.cs @@ -0,0 +1,83 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using AWS.Deploy.Orchestration.Utilities; +using AWS.Deploy.Common; +using Moq; +using Xunit; +using System.IO; +using AWS.Deploy.Common.IO; + +namespace AWS.Deploy.Orchestration.UnitTests +{ + public class GetSaveSettingsConfigurationTests + { + [Fact] + public void GetSaveSettingsConfiguration_InvalidConfiguration_ThrowsException() + { + // ARRANGE + var saveSettingsPath = "Path/To/JSONFile/1"; + var saveAllSettingsPath = "Path/To/JSONFile/2"; + var projectDirectory = "Path/To/ProjectDirectory"; + var fileManager = new Mock(); + fileManager.Setup(x => x.IsFileValidPath(It.IsAny())).Returns(true); + + //ACT and ASSERT + // Its throws an exception because saveSettings and saveSettingsAll both hold a non-null value + Assert.Throws(() => Helpers.GetSaveSettingsConfiguration(saveSettingsPath, saveAllSettingsPath, projectDirectory, fileManager.Object)); + } + + [Fact] + public void GetSaveSettingsConfiguration_ModifiedSettings() + { + // ARRANGE + var temp = Path.GetTempPath(); + var saveSettingsPath = Path.Combine(temp, "Path", "To", "JSONFile"); + var projectDirectory = Path.Combine(temp, "Path", "To", "ProjectDirectory"); + var fileManager = new Mock(); + fileManager.Setup(x => x.IsFileValidPath(It.IsAny())).Returns(true); + + // ACT + var saveSettingsConfiguration = Helpers.GetSaveSettingsConfiguration(saveSettingsPath, null, projectDirectory, fileManager.Object); + + // ASSERT + Assert.Equal(SaveSettingsType.Modified, saveSettingsConfiguration.SettingsType); + Assert.Equal(saveSettingsPath, saveSettingsConfiguration.FilePath); + } + + [Fact] + public void GetSaveSettingsConfiguration_AllSettings() + { + // ARRANGE + var temp = Path.GetTempPath(); + var saveAllSettingsPath = Path.Combine(temp, "Path", "To", "JSONFile"); + var projectDirectory = Path.Combine(temp, "Path", "To", "ProjectDirectory"); + var fileManager = new Mock(); + fileManager.Setup(x => x.IsFileValidPath(It.IsAny())).Returns(true); + + // ACT + var saveSettingsConfiguration = Helpers.GetSaveSettingsConfiguration(null, saveAllSettingsPath, projectDirectory, fileManager.Object); + + // ASSERT + Assert.Equal(SaveSettingsType.All, saveSettingsConfiguration.SettingsType); + Assert.Equal(saveAllSettingsPath, saveSettingsConfiguration.FilePath); + } + + [Fact] + public void GetSaveSettingsConfiguration_None() + { + // ARRANGE + var temp = Path.GetTempPath(); + var projectDirectory = Path.Combine(temp, "Path", "To", "ProjectDirectory"); + var fileManager = new Mock(); + fileManager.Setup(x => x.IsFileValidPath(It.IsAny())).Returns(true); + + // ACT + var saveSettingsConfiguration = Helpers.GetSaveSettingsConfiguration(null, null, projectDirectory, fileManager.Object); + + // ASSERT + Assert.Equal(SaveSettingsType.None, saveSettingsConfiguration.SettingsType); + Assert.Equal(string.Empty, saveSettingsConfiguration.FilePath); + } + } +} diff --git a/test/AWS.Deploy.Orchestration.UnitTests/TestFileManager.cs b/test/AWS.Deploy.Orchestration.UnitTests/TestFileManager.cs index 7afa30b0d..5d4ac8954 100644 --- a/test/AWS.Deploy.Orchestration.UnitTests/TestFileManager.cs +++ b/test/AWS.Deploy.Orchestration.UnitTests/TestFileManager.cs @@ -41,6 +41,7 @@ public Task WriteAllTextAsync(string filePath, string contents, CancellationToke public FileStream OpenRead(string filePath) => throw new NotImplementedException(); public string GetExtension(string filePath) => throw new NotImplementedException(); public long GetSizeInBytes(string filePath) => throw new NotImplementedException(); + public bool IsFileValidPath(string filePath) => throw new NotImplementedException(); } public static class TestFileManagerExtensions diff --git a/testapps/WebAppNoDockerFile/ElasticBeanStalkConfigFile.json b/testapps/WebAppNoDockerFile/ElasticBeanStalkConfigFile.json index 8186b57d1..7e8df1ac5 100644 --- a/testapps/WebAppNoDockerFile/ElasticBeanStalkConfigFile.json +++ b/testapps/WebAppNoDockerFile/ElasticBeanStalkConfigFile.json @@ -1,18 +1,15 @@ { - "ApplicationName": "ElasticBeanStalk{Suffix}", - "RecipeId": "AspNetAppElasticBeanstalkLinux", - "Settings": { - "BeanstalkApplication": { - "CreateNew": true, - "ApplicationName": "MyApplication{Suffix}" - }, - "BeanstalkEnvironment": { - "EnvironmentName": "MyEnvironment{Suffix}" - }, - "EnvironmentType": "LoadBalanced", - "LoadBalancerType": "application", - "ApplicationIAMRole": { - "CreateNew": true - } - } + "ApplicationName": "{StackName}", + "RecipeId": "AspNetAppElasticBeanstalkLinux", + "Settings": { + "BeanstalkApplication": { + "CreateNew": true, + "ApplicationName": "{StackName}-app" + }, + "BeanstalkEnvironment": { + "EnvironmentName": "{StackName}-dev" + }, + "EnvironmentType": "LoadBalanced", + "XRayTracingSupportEnabled": true } +} diff --git a/testapps/WebAppWithDockerFile/AppRunnerConfigFile.json b/testapps/WebAppWithDockerFile/AppRunnerConfigFile.json index d474dbe64..d08b48078 100644 --- a/testapps/WebAppWithDockerFile/AppRunnerConfigFile.json +++ b/testapps/WebAppWithDockerFile/AppRunnerConfigFile.json @@ -1,8 +1,8 @@ { - "ApplicationName": "AppAppRunner{Suffix}", + "ApplicationName": "{StackName}", "RecipeId": "AspNetAppAppRunner", "Settings": { - "ServiceName": "MyNewService{Suffix}", + "ServiceName": "{StackName}-service", "AppRunnerEnvironmentVariables": { "TEST_Key1": "Value1", "TEST_Key2": "Value2" diff --git a/testapps/WebAppWithDockerFile/ECSFargateConfigFile.json b/testapps/WebAppWithDockerFile/ECSFargateConfigFile.json index 58c8cae33..7db452bd4 100644 --- a/testapps/WebAppWithDockerFile/ECSFargateConfigFile.json +++ b/testapps/WebAppWithDockerFile/ECSFargateConfigFile.json @@ -1,13 +1,13 @@ { - "ApplicationName": "EcsFargate{Suffix}", + "ApplicationName": "{StackName}", "RecipeId": "AspNetAppEcsFargate", "Settings":{ "ECSCluster": { "CreateNew": true, - "NewClusterName": "MyNewCluster{Suffix}" + "NewClusterName": "{StackName}-cluster" }, - "ECSServiceName": "MyNewService{Suffix}", + "ECSServiceName": "{StackName}-service", "DesiredCount": 3, "ApplicationIAMRole": { diff --git a/version.json b/version.json index d0ce798ac..2f931b9b7 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.2", + "version": "1.3", "publicReleaseRefSpec": [ ".*" ],