diff --git a/AWS.Deploy.sln b/AWS.Deploy.sln index ecfe57f02..975bde8dc 100644 --- a/AWS.Deploy.sln +++ b/AWS.Deploy.sln @@ -68,6 +68,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AWS.Deploy.ServerMode.Clien EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution + src\AWS.Deploy.Constants\AWS.Deploy.Constants.projitems*{3f7a5ca6-7178-4dbf-8dad-6a63684c7a8e}*SharedItemsImports = 5 + src\AWS.Deploy.Constants\AWS.Deploy.Constants.projitems*{5f8ec972-781d-4a82-a73f-36a97281b0d5}*SharedItemsImports = 5 + src\AWS.Deploy.Constants\AWS.Deploy.Constants.projitems*{8a351cc0-70c0-4412-b45e-358606251512}*SharedItemsImports = 5 src\AWS.Deploy.Constants\AWS.Deploy.Constants.projitems*{f2266c44-c8c5-45ad-aa9b-44f8825bdf63}*SharedItemsImports = 13 src\AWS.Deploy.Constants\AWS.Deploy.Constants.projitems*{fae4d4c5-9107-4622-a050-e30cc70be754}*SharedItemsImports = 5 EndGlobalSection diff --git a/docs/OptionSettingsItems-input-validation.md b/docs/OptionSettingsItems-input-validation.md new file mode 100644 index 000000000..50fb2294e --- /dev/null +++ b/docs/OptionSettingsItems-input-validation.md @@ -0,0 +1,229 @@ +# OptionSettingItems Input Validation Design Document +_DOTNET-4952_ + +## Summary +Need mechanism to indicate an `OptionSettingItem` is required (ie non empty value) or has additional input validations: + - less than 64 characters + - no whitespace characters + - no non-ascii characters + - value is from list of acceptable values + - is positive number + - is number within bounds + +Deployment should be blocked if there are any OptionSettingItems with invalid values. + +## Proposed Design + +Unqiue validation classes will be created and will implement a new interface: + +```csharp +public interface IOptionSettingItemValidator +{ + OptionSettingItemValidationResult Validate(object input); +} + +public class OptionSettingItemValidationResult +{ + public bool IsValid { get; set; } + public string ValidationFailedMessage { get;set; } +} +``` + +OptionSettingItems will then contain a collection of 0 or more `IOptionSettingItemValidator`s: + +```csharp +public class OptionSettingItem +{ + public List Validators { get; set; } +} +``` + +### Validation + +`OptionSettingItem.SetValueOverride` will be updated to validate the override value by passing it to each Validator. If any validator indicates the value is not valid, an exception is thrown: + +```csharp +public void SetValueOverride(object valueOverride) +{ + foreach (var validator in this.Validators) + { + var result = validator.Validate(valueOverride); + if (!result.IsValid) + throw new ValidationFailedException + { + ValidationResult = result + }; + } + + // value is saved +} +``` + +### Serialization of Recipes + +`RecipeDefinition`s are stored as JSON and deserialized from _*.recipe_ files. Therefore the Validators must also be JSON serializable. To faciliate polymorphism, recipe JSON will need to define the type of the validator. + +In the example below, the **Example Setting** item has two Validators, `RequiredValidator` and `RegexValidator`: + +```JSON +// Minimal Recipe to highlight OptionSettingsItem.Validators +{ + "Name": "Example Recipe", + "OptionSettings": [ + { + "Name": "Example Setting", + "Type": "String", + "DefaultValue": null, + "Validators": [ + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "[a-zA-Z]{3,20}", + "AllowEmptyString": true, + "ValidationFailedMessage": "Letters only, 3 to 20 characters in length" + } + }, + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "[a-zA-Z]{3,20}", + "AllowEmptyString": true, + "ValidationFailedMessage": "Letters only, 3 to 20 characters in length" + } + } + ], + "AllowedValues": [], + "ValueMapping": {} + } + ], + "RecipePriority": 0 +} +``` + +This requires the recipe to be deserialzied using customized [TypeNameHandling](https://www.newtonsoft.com/JSON/help/html/T_Newtonsoft_JSON_TypeNameHandling.htm): + +```csharp +var settings = new JSONSerializerSettings +{ + TypeNameHandling = TypeNameHandling.Auto +}; +var recipe = JSONConvert.DeserializeObject(JSON, settings); +``` + +### Validator Configuration + +Validator can declare any additional configuration they need in as Properties in the validator class. And, as the validator is deserialized from JSON, any Property can be customized inside the recipe. For examle: + +```csharp +public class RangeValidator : IOptionSettingItemValidator +{ + public int Min { get; set; } = int.MinValue; + public int Max { get;set; } = int.MaxValue; + + public string ValidationFailedMessage { get; set; } = + "Value must be greater than or equal to {{Min}} and less than or equal to {{Max}}"; +} +``` +#### Message Customization + +Each validator is responsible for rendering a validation failed message and can control how it allows recipe authors to customize the message. + +In the `RangeValidator` example above, a recipe author can customize `ValidationFailedMessage`. Additionally, `RangeValidator` suppots two replacement tokens `{{Min}}` and `{{Max}}`. This allows a recipe author greater flexability: + +```JSON +{ + "$type": "AWS.Deploy.Common.Recipes.RangeValidator, AWS.Deploy.Common", + "Min": "2", + "Min": "10", + "ValidationFailedMessage": "Setting can not be more than {{Max}}" +} +``` + +### Dependencies + +Because Validators will be deserialized as part of a `RecipeDefinition` they need to have parameterless constructors and therefore can't use Constructor Injection. + +Validators are currently envisoned to be relatively simple to the point where they shouldn't need any dependencies. If dependencies in are needed in the future, we can explore adding an `Initialize` method that uses the ServiceLocation (anti-)pattern: + +```csharp +public interface IOptionSettingItemValidator +{ + /// + /// One possibile solution if we need to create a Validator that needs + /// dependencies. + /// + void Initialize(IServiceLocator serviceLocator); +} +``` + +### Extensability + +This design would facilitate validators being defined in external code; assuming that the project as a whole allows recipe authors to include 3rd party assemblies and the 3rd party assembly is already loaded. If those preconditions are met, a recipe author can, in JSON, reference any validator type in an assembly that is loaded by the tool. + +#### Recipe Schema + +Any 3rd party Validators will not be added to the `aws-deploy-recipe-schema.JSON` file. + +### Allowed Values / Value Mapping + +`OptionSettingItem` defines: + +```csharp +public class OptionSettingItem +{ + /// + /// The allowed values for the setting. + /// + public IList AllowedValues { get; set; } = new List(); + + /// + /// The value mapping for allowed values. The key of the dictionary is what is sent to services + /// and the value is the display value shown to users. + /// + public IDictionary ValueMapping { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // additional properties not shown +} +``` + +I considered migrating `AllowedValues` to become a Validator, however `AllowedValues` and `ValueMapping` are tightly intergrated into UIs in order to render custom UI prompts, like the one below, that I decided they should remain as is and not be ported to a Validator: + +``` +Task CPU: +The number of CPU units used by the task. See the following for details on CPU values: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/AWS_Fargate.html#fargate-task-defs +1: 256 (.25 vCPU) (default) +2: 512 (.5 vCPU) +3: 1024 (1 vCPU) +4: 2048 (2 vCPU) +5: 4096 (4 vCPU) +Choose option (default 1): +``` + +## WIP + +- Ideally keep validation targeted to just the incoming value. +- Support validating the entire "Recommendation" as a separate level of validation. +- support validation warnings? + +### Validation Scenarios + +#### CPU & Memory Pair + +Certain memory configurations are only available based on certain CPU configurations and vice versa. For example, selecting 0.25GB of memory may only be valid if CPU is below 2vCPU. + +**Design:** +- OptionSettingsItems will use `OptionSettingItem.AllowedValues` to restrict what the user can select for vCPU and Memory. However, it will not have context to enforce limitations based on what has been selected for CPU. +- UI can optionally implement a custom TypeHint that can restrict which value pairs are recommended and has access to the full `Recommendation` object. +- A `MemoryCpuRecommendationValidator` will run after the user has indicated they have no more configuration changes and wishes to deploy. It will be able to access both CPU and Memory `OptionSettingItem` values to ensure they are compatible. +- _Justification:_ The validation system should not force the UX into a state where it is impossible to select a value. For example, say the a 8 vCPU config requires at least 16 GB memory. The 16 GB memory requires at least 8 vCPU. The default values are 0.25 vCPU and 1 GB Memory. If we performed validation at the `OptionSettingItem` level, it would not be possible to change CPU to 8 and Memory to 16GB, as a change to CPU would always be incompatible with the existing Memory selection, and vice versa. + +#### Region limited EC2 Instances + +Not every AWS Region supports EC2 Instance Type. Additionanlly, this list is dynamic as new EC2 instance types are introduced in a subset of Regions, or existing Regions get new capabilities. + +**Design:** + +- InstanceType TypeHint will present users with values valid for selected Region. +- The `InstanceTypeOptionSettingItemValidator` will only validate that the EC2 type selected is in a known list; it will not have the context of which Region has been selected. +- The `InstanceTypeRecommendationValidator` has access to the `OrchestratorSession` and can see which +Region is targetd. It can then provide a validation error if the selected EC2 InstanceType is not available in the current region. \ No newline at end of file diff --git a/src/AWS.Deploy.CLI/AWS.Deploy.CLI.csproj b/src/AWS.Deploy.CLI/AWS.Deploy.CLI.csproj index 0c59e42b9..154e3f4e7 100644 --- a/src/AWS.Deploy.CLI/AWS.Deploy.CLI.csproj +++ b/src/AWS.Deploy.CLI/AWS.Deploy.CLI.csproj @@ -16,6 +16,7 @@ https://github.com/aws/aws-dotnet-deploy true $(NoWarn);1570;1591 + Major @@ -42,4 +43,6 @@ + + diff --git a/src/AWS.Deploy.CLI/Commands/DeployCommand.cs b/src/AWS.Deploy.CLI/Commands/DeployCommand.cs index 60d2bf87c..f00606bc5 100644 --- a/src/AWS.Deploy.CLI/Commands/DeployCommand.cs +++ b/src/AWS.Deploy.CLI/Commands/DeployCommand.cs @@ -10,6 +10,7 @@ using AWS.Deploy.CLI.Commands.TypeHints; using AWS.Deploy.Common; using AWS.Deploy.Common.Recipes; +using AWS.Deploy.Common.Recipes.Validation; using AWS.Deploy.DockerEngine; using AWS.Deploy.Orchestration; using AWS.Deploy.Orchestration.CDK; @@ -112,6 +113,10 @@ public async Task ExecuteAsync(string stackName, bool saveCdkProject) var existingCloudApplicationMetadata = await _templateMetadataReader.LoadCloudApplicationMetadata(deployedApplication.Name); selectedRecommendation = recommendations.FirstOrDefault(x => string.Equals(x.Recipe.Id, deployedApplication.RecipeId, StringComparison.InvariantCultureIgnoreCase)); + + if (selectedRecommendation == null) + throw new FailedToCompatibleRecipeException("A compatible recipe was not found for the deployed application."); + selectedRecommendation.ApplyPreviousSettings(existingCloudApplicationMetadata.Settings); var header = $"Loading {deployedApplication.Name} settings:"; @@ -339,7 +344,27 @@ private async Task ConfigureDeployment(Recommendation recommendation, IEnumerabl // deploy case, nothing more to configure if (string.IsNullOrEmpty(input)) { - return; + var validatorFailedResults = + recommendation.Recipe + .BuildValidators() + .Select(validator => validator.Validate(recommendation.Recipe, _session)) + .Where(x => !x.IsValid) + .ToList(); + + if (!validatorFailedResults.Any()) + { + // validation successful + // deployment configured + return; + } + + _toolInteractiveService.WriteLine(); + _toolInteractiveService.WriteErrorLine("The deployment configuration needs to be adjusted before it can be deployed:"); + foreach (var result in validatorFailedResults) + _toolInteractiveService.WriteErrorLine($" - {result.ValidationFailedMessage}"); + + _toolInteractiveService.WriteLine(); + _toolInteractiveService.WriteErrorLine("Please adjust your settings"); } // configure option setting @@ -350,7 +375,7 @@ private async Task ConfigureDeployment(Recommendation recommendation, IEnumerabl await ConfigureDeployment(recommendation, optionSettings[selectedNumber - 1]); } - _toolInteractiveService.WriteLine(string.Empty); + _toolInteractiveService.WriteLine(); } } @@ -426,7 +451,17 @@ private async Task ConfigureDeployment(Recommendation recommendation, OptionSett if (!Equals(settingValue, currentValue) && settingValue != null) { - setting.SetValueOverride(settingValue); + try + { + setting.SetValueOverride(settingValue); + } + catch (ValidationFailedException ex) + { + _toolInteractiveService.WriteErrorLine( + $"Value [{settingValue}] is not valid: {ex.Message}"); + + await ConfigureDeployment(recommendation, setting); + } } } diff --git a/src/AWS.Deploy.CLI/ConsoleInteractiveServiceImpl.cs b/src/AWS.Deploy.CLI/ConsoleInteractiveServiceImpl.cs index d8c9ce109..50d682f7b 100644 --- a/src/AWS.Deploy.CLI/ConsoleInteractiveServiceImpl.cs +++ b/src/AWS.Deploy.CLI/ConsoleInteractiveServiceImpl.cs @@ -16,18 +16,18 @@ public ConsoleInteractiveServiceImpl(bool diagnosticLoggingEnabled = false) public string ReadLine() { - return Console.ReadLine(); + return Console.ReadLine() ?? string.Empty; } public bool Diagnostics { get; set; } - public void WriteDebugLine(string message) + public void WriteDebugLine(string? message) { if (_diagnosticLoggingEnabled) Console.WriteLine($"DEBUG: {message}"); } - public void WriteErrorLine(string message) + public void WriteErrorLine(string? message) { var color = Console.ForegroundColor; @@ -42,12 +42,12 @@ public void WriteErrorLine(string message) } } - public void WriteLine(string message) + public void WriteLine(string? message) { Console.WriteLine(message); } - public void Write(string message) + public void Write(string? message) { Console.Write(message); } diff --git a/src/AWS.Deploy.CLI/ConsoleOrchestratorLogger.cs b/src/AWS.Deploy.CLI/ConsoleOrchestratorLogger.cs index 202fb7cff..ff8baeabc 100644 --- a/src/AWS.Deploy.CLI/ConsoleOrchestratorLogger.cs +++ b/src/AWS.Deploy.CLI/ConsoleOrchestratorLogger.cs @@ -14,17 +14,17 @@ public ConsoleOrchestratorLogger(IToolInteractiveService interactiveService) _interactiveService = interactiveService; } - public void LogErrorMessageLine(string message) + public void LogErrorMessageLine(string? message) { _interactiveService.WriteErrorLine(message); } - public void LogMessageLine(string message) + public void LogMessageLine(string? message) { _interactiveService.WriteLine(message); } - public void LogDebugLine(string message) + public void LogDebugLine(string? message) { _interactiveService.WriteDebugLine(message); } diff --git a/src/AWS.Deploy.CLI/ConsoleUtilities.cs b/src/AWS.Deploy.CLI/ConsoleUtilities.cs index 79647e987..bd868d64b 100644 --- a/src/AWS.Deploy.CLI/ConsoleUtilities.cs +++ b/src/AWS.Deploy.CLI/ConsoleUtilities.cs @@ -198,7 +198,7 @@ public UserResponse AskUserToChooseOrCreateNew(IEnumerable options, str if (userInputConfiguration.CurrentValue != null && string.IsNullOrEmpty(userInputConfiguration.CurrentValue.ToString())) defaultValue = Constants.CLI.EMPTY_LABEL; else - defaultValue = userInputConfiguration.CreateNew ? Constants.CLI.CREATE_NEW_LABEL : userInputConfiguration.DisplaySelector(options.FirstOrDefault()); + defaultValue = userInputConfiguration.CreateNew || !options.Any() ? Constants.CLI.CREATE_NEW_LABEL : userInputConfiguration.DisplaySelector(options.First()); } if (optionStrings.Any()) @@ -306,12 +306,14 @@ public string AskForEC2KeyPairSaveDirectory(string projectPath) while (true) { var keyPairDirectory = _interactiveService.ReadLine(); - if (Directory.Exists(keyPairDirectory)) + if (keyPairDirectory != null && + Directory.Exists(keyPairDirectory)) { var projectFolder = new FileInfo(projectPath).Directory; var keyPairDirectoryInfo = new DirectoryInfo(keyPairDirectory); - if (projectFolder.FullName.Equals(keyPairDirectoryInfo.FullName)) + if (projectFolder != null && + projectFolder.FullName.Equals(keyPairDirectoryInfo.FullName)) { _interactiveService.WriteLine(string.Empty); _interactiveService.WriteLine("EC2 Key Pair is a private secret key and it is recommended to not save the key in the project directory where it could be checked into source control."); diff --git a/src/AWS.Deploy.CLI/Exceptions.cs b/src/AWS.Deploy.CLI/Exceptions.cs index 4e14399c2..5bb71ae91 100644 --- a/src/AWS.Deploy.CLI/Exceptions.cs +++ b/src/AWS.Deploy.CLI/Exceptions.cs @@ -70,4 +70,13 @@ public class TcpPortInUseException : Exception { public TcpPortInUseException(string message, Exception? innerException = null) : base(message, innerException) { } } + + /// + /// Throw if unable to find a compatible recipe. + /// + [AWSDeploymentExpectedException] + public class FailedToCompatibleRecipeException : Exception + { + public FailedToCompatibleRecipeException(string message, Exception? innerException = null) : base(message, innerException) { } + } } diff --git a/src/AWS.Deploy.CLI/IToolInteractiveService.cs b/src/AWS.Deploy.CLI/IToolInteractiveService.cs index 65f1ccc95..3639af381 100644 --- a/src/AWS.Deploy.CLI/IToolInteractiveService.cs +++ b/src/AWS.Deploy.CLI/IToolInteractiveService.cs @@ -7,10 +7,10 @@ namespace AWS.Deploy.CLI { public interface IToolInteractiveService { - void Write(string message); - void WriteLine(string message); - void WriteDebugLine(string message); - void WriteErrorLine(string message); + void Write(string? message); + void WriteLine(string? message); + void WriteDebugLine(string? message); + void WriteErrorLine(string? message); string ReadLine(); bool Diagnostics { get; set; } diff --git a/src/AWS.Deploy.CLI/ServerMode/Controllers/DeploymentController.cs b/src/AWS.Deploy.CLI/ServerMode/Controllers/DeploymentController.cs index b5863f270..746cb6e89 100644 --- a/src/AWS.Deploy.CLI/ServerMode/Controllers/DeploymentController.cs +++ b/src/AWS.Deploy.CLI/ServerMode/Controllers/DeploymentController.cs @@ -181,7 +181,7 @@ public async Task SetDeploymentTarget(string sessionId, [FromBody if(!string.IsNullOrEmpty(input.NewDeploymentRecipeId) && !string.IsNullOrEmpty(input.NewDeploymentName)) { - state.SelectedRecommendation = state.NewRecommendations.FirstOrDefault(x => string.Equals(input.NewDeploymentRecipeId, x.Recipe.Id)); + state.SelectedRecommendation = state.NewRecommendations?.FirstOrDefault(x => string.Equals(input.NewDeploymentRecipeId, x.Recipe.Id)); if(state.SelectedRecommendation == null) { return NotFound($"Recommendation {input.NewDeploymentRecipeId} not found."); @@ -195,13 +195,13 @@ public async Task SetDeploymentTarget(string sessionId, [FromBody var serviceProvider = CreateSessionServiceProvider(state); var templateMetadataReader = serviceProvider.GetRequiredService(); - var existingDeployment = state.ExistingDeployments.FirstOrDefault(x => string.Equals(input.ExistingDeploymentName, x.Name)); + var existingDeployment = state.ExistingDeployments?.FirstOrDefault(x => string.Equals(input.ExistingDeploymentName, x.Name)); if (existingDeployment == null) { return NotFound($"Existing deployment {input.ExistingDeploymentName} not found."); } - state.SelectedRecommendation = state.NewRecommendations.FirstOrDefault(x => string.Equals(existingDeployment.RecipeId, x.Recipe.Id)); + state.SelectedRecommendation = state.NewRecommendations?.FirstOrDefault(x => string.Equals(existingDeployment.RecipeId, x.Recipe.Id)); if (state.SelectedRecommendation == null) { return NotFound($"Recommendation {input.NewDeploymentRecipeId} used in existing deployment {existingDeployment.RecipeId} not found."); diff --git a/src/AWS.Deploy.CLI/ServerMode/Hubs/DeploymentCommunicationHub.cs b/src/AWS.Deploy.CLI/ServerMode/Hubs/DeploymentCommunicationHub.cs index 4eac85dce..7e796c95f 100644 --- a/src/AWS.Deploy.CLI/ServerMode/Hubs/DeploymentCommunicationHub.cs +++ b/src/AWS.Deploy.CLI/ServerMode/Hubs/DeploymentCommunicationHub.cs @@ -12,9 +12,9 @@ namespace AWS.Deploy.CLI.ServerMode.Hubs public interface IDeploymentCommunicationHub { Task JoinSession(string sessionId); - Task OnLogDebugLine(string logs); - Task OnLogErrorMessageLine(string logs); - Task OnLogMessageLine(string logs); + Task OnLogDebugLine(string? logs); + Task OnLogErrorMessageLine(string? logs); + Task OnLogMessageLine(string? logs); } public class DeploymentCommunicationHub : Hub diff --git a/src/AWS.Deploy.CLI/ServerMode/Services/SessionOrchestratorInteractiveService.cs b/src/AWS.Deploy.CLI/ServerMode/Services/SessionOrchestratorInteractiveService.cs index 8e391d05e..b90aecb33 100644 --- a/src/AWS.Deploy.CLI/ServerMode/Services/SessionOrchestratorInteractiveService.cs +++ b/src/AWS.Deploy.CLI/ServerMode/Services/SessionOrchestratorInteractiveService.cs @@ -23,17 +23,17 @@ public SessionOrchestratorInteractiveService(string sessionId, IHubContext + /// Thrown if is given an invalid value. + /// + public class ValidationFailedException : Exception + { + public ValidationFailedException(string? message, Exception? innerException = null) : base(message, innerException) { } + } + + /// + /// Exception thrown if Project Path contains an invalid path + /// + [AWSDeploymentExpectedException] + public class InvalidProjectPathException : Exception + { + public InvalidProjectPathException(string message, Exception? innerException = null) : base(message, innerException) { } + } + /// /// Indicates a specific strongly typed Exception can be anticipated. /// Whoever throws this error should also present the user with helpful information diff --git a/src/AWS.Deploy.Common/ProjectDefinition.cs b/src/AWS.Deploy.Common/ProjectDefinition.cs index f853bcec8..67156bd5b 100644 --- a/src/AWS.Deploy.Common/ProjectDefinition.cs +++ b/src/AWS.Deploy.Common/ProjectDefinition.cs @@ -79,7 +79,8 @@ public ProjectDefinition( private bool CheckIfDockerFileExists(string projectPath) { - var dir = Directory.GetFiles(new FileInfo(projectPath).DirectoryName, "Dockerfile"); + var dir = Directory.GetFiles(new FileInfo(projectPath).DirectoryName ?? + throw new InvalidProjectPathException("The project path is invalid."), "Dockerfile"); return dir.Length == 1; } } diff --git a/src/AWS.Deploy.Common/ProjectDefinitionParser.cs b/src/AWS.Deploy.Common/ProjectDefinitionParser.cs index 534cdddb4..a090d8954 100644 --- a/src/AWS.Deploy.Common/ProjectDefinitionParser.cs +++ b/src/AWS.Deploy.Common/ProjectDefinitionParser.cs @@ -72,13 +72,13 @@ await GetProjectSolutionFile(projectPath), var targetFramework = xmlProjectFile.GetElementsByTagName("TargetFramework"); if (targetFramework.Count > 0) { - projectDefinition.TargetFramework = targetFramework[0].InnerText; + projectDefinition.TargetFramework = targetFramework[0]?.InnerText; } var assemblyName = xmlProjectFile.GetElementsByTagName("AssemblyName"); if (assemblyName.Count > 0) { - projectDefinition.AssemblyName = (string.IsNullOrWhiteSpace(assemblyName[0].InnerText) ? Path.GetFileNameWithoutExtension(projectPath) : assemblyName[0].InnerText); + projectDefinition.AssemblyName = (string.IsNullOrWhiteSpace(assemblyName[0]?.InnerText) ? Path.GetFileNameWithoutExtension(projectPath) : assemblyName[0]?.InnerText); } else { diff --git a/src/AWS.Deploy.Common/Recipes/OptionSettingItem.ValueOverride.cs b/src/AWS.Deploy.Common/Recipes/OptionSettingItem.ValueOverride.cs index 197a82077..16c4fbf53 100644 --- a/src/AWS.Deploy.Common/Recipes/OptionSettingItem.ValueOverride.cs +++ b/src/AWS.Deploy.Common/Recipes/OptionSettingItem.ValueOverride.cs @@ -1,8 +1,10 @@ // 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 AWS.Deploy.Common.Recipes.Validation; using Newtonsoft.Json; namespace AWS.Deploy.Common.Recipes @@ -85,8 +87,30 @@ public object GetValue(IDictionary replacementTokens, IDictionar return DefaultValue; } + /// + /// Assigns a value to the OptionSettingItem. + /// + /// + /// Thrown if one or more determine + /// is not valid. + /// public void SetValueOverride(object valueOverride) { + var isValid = true; + var validationFailedMessage = string.Empty; + foreach (var validator in this.BuildValidators()) + { + var result = validator.Validate(valueOverride); + if (!result.IsValid) + { + isValid = false; + validationFailedMessage += result.ValidationFailedMessage + Environment.NewLine; + } + } + + if (!isValid) + throw new ValidationFailedException(validationFailedMessage.Trim()); + if (valueOverride is bool || valueOverride is int || valueOverride is long) { _valueOverride = valueOverride; diff --git a/src/AWS.Deploy.Common/Recipes/OptionSettingItem.cs b/src/AWS.Deploy.Common/Recipes/OptionSettingItem.cs index b177c4e50..c872b6dc5 100644 --- a/src/AWS.Deploy.Common/Recipes/OptionSettingItem.cs +++ b/src/AWS.Deploy.Common/Recipes/OptionSettingItem.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using AWS.Deploy.Common.Recipes.Validation; using Newtonsoft.Json; namespace AWS.Deploy.Common.Recipes @@ -62,6 +63,11 @@ public partial class OptionSettingItem /// public bool Updatable { get; set; } + /// + /// List of all validators that should be run when configuring this OptionSettingItem. + /// + public List Validators { get; set; } = new (); + /// /// The allowed values for the setting. /// diff --git a/src/AWS.Deploy.Common/Recipes/RecipeDefinition.cs b/src/AWS.Deploy.Common/Recipes/RecipeDefinition.cs index d55dc0c02..dfdf4ba99 100644 --- a/src/AWS.Deploy.Common/Recipes/RecipeDefinition.cs +++ b/src/AWS.Deploy.Common/Recipes/RecipeDefinition.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Collections.Generic; +using AWS.Deploy.Common.Recipes.Validation; namespace AWS.Deploy.Common.Recipes { @@ -78,6 +79,11 @@ public class RecipeDefinition /// public List OptionSettings { get; set; } = new (); + /// + /// List of all validators that should be run against this recipe before deploying. + /// + public List Validators { get; set; } = new (); + /// /// The priority of the recipe. The highest priority is the top choice for deploying to. /// diff --git a/src/AWS.Deploy.Common/Recipes/Validation/IDeployToolValidationContext.cs b/src/AWS.Deploy.Common/Recipes/Validation/IDeployToolValidationContext.cs new file mode 100644 index 000000000..9a6e13bc7 --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/IDeployToolValidationContext.cs @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace AWS.Deploy.Common.Recipes.Validation +{ + /// + /// Captures some basic information about the context the deployment tool is being running in. + /// Originally, this is to support implementations having the ability + /// to customize validation based on things like . + /// + /// WARNING: Please be careful adding additional properties to this interface or trying to re-purpose this interface + /// for something other than validation. Consider if it instead makes more sense to use + /// Interface Segregation to define a different interface for your use case. It's fine for OrchestratorSession + /// to implement multiple interfaces. + /// + public interface IDeployToolValidationContext + { + ProjectDefinition ProjectDefinition { get; } + string AWSRegion { get; } + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/IOptionSettingItemValidator.cs b/src/AWS.Deploy.Common/Recipes/Validation/IOptionSettingItemValidator.cs new file mode 100644 index 000000000..e51ca964b --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/IOptionSettingItemValidator.cs @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace AWS.Deploy.Common.Recipes.Validation +{ + /// + /// This interface outlines the framework for OptionSettingItem validators. + /// Validators such as implement this interface and provide custom validation logic + /// on OptionSettingItems + /// + public interface IOptionSettingItemValidator + { + ValidationResult Validate(object input); + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/IRecipeValidator.cs b/src/AWS.Deploy.Common/Recipes/Validation/IRecipeValidator.cs new file mode 100644 index 000000000..5f12127f7 --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/IRecipeValidator.cs @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace AWS.Deploy.Common.Recipes.Validation +{ + /// + /// This interface outlines the framework for recipe validators. + /// Validators such as implement this interface + /// and provide custom validation logic on recipes. + /// + public interface IRecipeValidator + { + ValidationResult Validate(RecipeDefinition recipe, IDeployToolValidationContext deployValidationContext); + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidatorConfig.cs b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidatorConfig.cs new file mode 100644 index 000000000..43198553b --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidatorConfig.cs @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\r +// SPDX-License-Identifier: Apache-2.0 + +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace AWS.Deploy.Common.Recipes.Validation +{ + /// + /// This class is used to store the OptionSettingItem validator type and its corresponding configuration + /// after parsing the validator from the deployment recipes. + /// + public class OptionSettingItemValidatorConfig + { + [JsonConverter(typeof(StringEnumConverter))] + public OptionSettingItemValidatorList ValidatorType {get;set;} + public object? Configuration {get;set;} + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidatorList.cs b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidatorList.cs new file mode 100644 index 000000000..acb293c24 --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidatorList.cs @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\r +// SPDX-License-Identifier: Apache-2.0 + +namespace AWS.Deploy.Common.Recipes.Validation +{ + public enum OptionSettingItemValidatorList + { + /// + /// Must be paired with + /// + Range, + /// + /// Must be paired with + /// + Regex, + /// + /// Must be paired with + /// + Required + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/RangeValidator.cs b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/RangeValidator.cs new file mode 100644 index 000000000..2fdbaf49a --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/RangeValidator.cs @@ -0,0 +1,41 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\r +// SPDX-License-Identifier: Apache-2.0 + +namespace AWS.Deploy.Common.Recipes.Validation +{ + /// + /// The validator is typically used with OptionSettingItems which have a numeric type. + /// The minimum and maximum values are specified in the deployment recipe + /// and this validator checks if the set value of the OptionSettingItem falls within this range or not. + /// + public class RangeValidator : IOptionSettingItemValidator + { + private static readonly string defaultValidationFailedMessage = + "Value must be greater than or equal to {{Min}} and less than or equal to {{Max}}"; + + public int Min { get; set; } = int.MinValue; + public int Max { get;set; } = int.MaxValue; + + /// + /// Supports replacement tokens {{Min}} and {{Max}} + /// + public string ValidationFailedMessage { get; set; } = defaultValidationFailedMessage; + + public ValidationResult Validate(object input) + { + if (int.TryParse(input?.ToString(), out var result) && + result >= Min && + result <= Max) + { + return ValidationResult.Valid(); + } + + var message = + ValidationFailedMessage + .Replace("{{Min}}", Min.ToString()) + .Replace("{{Max}}", Max.ToString()); + + return ValidationResult.Failed(message); + } + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/RegexValidator.cs b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/RegexValidator.cs new file mode 100644 index 000000000..75ae2b931 --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/RegexValidator.cs @@ -0,0 +1,35 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\r +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.RegularExpressions; + +namespace AWS.Deploy.Common.Recipes.Validation +{ + /// + /// The validator is typically used with OptionSettingItems which have a string type. + /// The regex string is specified in the deployment recipe + /// and this validator checks if the set value of the OptionSettingItem matches the regex or not. + /// + public class RegexValidator : IOptionSettingItemValidator + { + private static readonly string defaultRegex = "(.*)"; + private static readonly string defaultValidationFailedMessage = "Value must match Regex {{Regex}}"; + + public string Regex { get; set; } = defaultRegex; + public string ValidationFailedMessage { get; set; } = defaultValidationFailedMessage; + public bool AllowEmptyString { get; set; } + + public ValidationResult Validate(object input) + { + var regex = new Regex(Regex); + + var message = ValidationFailedMessage.Replace("{{Regex}}", Regex); + + return new ValidationResult + { + IsValid = regex.IsMatch(input?.ToString() ?? "") || (AllowEmptyString && string.IsNullOrEmpty(input?.ToString())), + ValidationFailedMessage = message + }; + } + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/RequiredValidator.cs b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/RequiredValidator.cs new file mode 100644 index 000000000..fa5402f53 --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/RequiredValidator.cs @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\r +// SPDX-License-Identifier: Apache-2.0 + +namespace AWS.Deploy.Common.Recipes.Validation +{ + /// + /// The validator is used to enforce that a particular OptionSettingItem has a value before deployment. + /// + public class RequiredValidator : IOptionSettingItemValidator + { + private static readonly string defaultValidationFailedMessage = "Value can not be empty"; + public string ValidationFailedMessage { get; set; } = defaultValidationFailedMessage; + + public ValidationResult Validate(object input) => + new() + { + IsValid = !string.IsNullOrEmpty(input?.ToString()), + ValidationFailedMessage = ValidationFailedMessage + }; + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/RecipeValidatorConfig.cs b/src/AWS.Deploy.Common/Recipes/Validation/RecipeValidatorConfig.cs new file mode 100644 index 000000000..341ff90e9 --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/RecipeValidatorConfig.cs @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\r +// SPDX-License-Identifier: Apache-2.0 + +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace AWS.Deploy.Common.Recipes.Validation +{ + /// + /// This class is used to store the Recipe validator type and its corresponding configuration + /// after parsing the validator from the deployment recipes. + /// + public class RecipeValidatorConfig + { + [JsonConverter(typeof(StringEnumConverter))] + public RecipeValidatorList ValidatorType {get;set;} + public object? Configuration {get;set;} + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/RecipeValidatorList.cs b/src/AWS.Deploy.Common/Recipes/Validation/RecipeValidatorList.cs new file mode 100644 index 000000000..79aba9860 --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/RecipeValidatorList.cs @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\r +// SPDX-License-Identifier: Apache-2.0 + +namespace AWS.Deploy.Common.Recipes.Validation +{ + public enum RecipeValidatorList + { + /// + /// Must be paired with + /// + FargateTaskSizeCpuMemoryLimits + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/RecipeValidators/FargateTaskCpuMemorySizeValidator.cs b/src/AWS.Deploy.Common/Recipes/Validation/RecipeValidators/FargateTaskCpuMemorySizeValidator.cs new file mode 100644 index 000000000..46013f30e --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/RecipeValidators/FargateTaskCpuMemorySizeValidator.cs @@ -0,0 +1,98 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\r +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Linq; + +namespace AWS.Deploy.Common.Recipes.Validation +{ + /// + /// Enforces Fargate cpu and memory conditional requirements: + /// CPU value Memory value (MiB) + /// 256 (.25 vCPU) 512 (0.5 GB), 1024 (1 GB), 2048 (2 GB) + /// 512 (.5 vCPU) 1024 (1 GB), 2048 (2 GB), 3072 (3 GB), 4096 (4 GB) + /// 1024 (1 vCPU) 2048 (2 GB), 3072 (3 GB), 4096 (4 GB), 5120 (5 GB), 6144 (6 GB), 7168 (7 GB), 8192 (8 GB) + /// 2048 (2 vCPU) Between 4096 (4 GB) and 16384 (16 GB) in increments of 1024 (1 GB) + /// 4096 (4 vCPU) Between 8192 (8 GB) and 30720 (30 GB) in increments of 1024 (1 GB) + /// + /// See https://docs.aws.amazon.com/AmazonECS/latest/userguide/task_definition_parameters.html#task_size + /// for more details. + /// + public class FargateTaskCpuMemorySizeValidator : IRecipeValidator + { + private readonly Dictionary _cpuMemoryMap = new() + { + { "256", new[] { "512", "1024", "2048" } }, + { "512", new[] { "1024", "2048", "3072", "4096" } }, + { "1024", new[] { "2048", "3072", "4096", "5120", "6144", "7168", "8192" } }, + { "2048", BuildMemoryArray(4096, 16384).ToArray() }, + { "4096", BuildMemoryArray(8192, 30720).ToArray()} + }; + + private static IEnumerable BuildMemoryArray(int start, int end, int increment = 1024) + { + while (start <= end) + { + yield return start.ToString(); + start += increment; + } + } + + private static readonly string defaultCpuOptionSettingsId = "TaskCpu"; + private static readonly string defaultMemoryOptionSettingsId = "TaskMemory"; + private static readonly string defaultValidationFailedMessage = + "Cpu value {{cpu}} is not compatible with memory value {{memory}}. Allowed values are {{memoryList}}"; + + /// + /// Supports replacement tokens {{cpu}}, {{memory}}, and {{memoryList}} + /// + public string ValidationFailedMessage { get; set; } = defaultValidationFailedMessage; + + public string? InvalidCpuValueValidationFailedMessage {get;set;} + + public string CpuOptionSettingsId { get; set; } = defaultCpuOptionSettingsId; + public string MemoryOptionSettingsId { get; set; } = defaultMemoryOptionSettingsId; + + /// + public ValidationResult Validate(RecipeDefinition recipe, IDeployToolValidationContext deployValidationContext) + { + var cpuItem = recipe.OptionSettings.FirstOrDefault(x => x.Id == CpuOptionSettingsId); + var memoryItem = recipe.OptionSettings.FirstOrDefault(x => x.Id == MemoryOptionSettingsId); + + if (null == cpuItem || null == memoryItem) + { + return ValidationResult.Failed("Could not find a valid value for Task CPU or Task Memory " + + "as part of of the ECS Fargate deployment configuration. Please provide a valid value and try again."); + } + + var cpu = cpuItem.GetValue(new Dictionary()); + var memory = memoryItem.GetValue(new Dictionary()); + + if (!_cpuMemoryMap.ContainsKey(cpu)) + { + // this could happen, but shouldn't. + // either there is mismatch between _cpuMemoryMap and the AllowedValues + // or the UX flow calling in here doesn't enforce AllowedValues. + var message = InvalidCpuValueValidationFailedMessage?.Replace("{{cpu}}", cpu); + + return ValidationResult.Failed(message?? "Cpu validation failed"); + } + + var validMemoryValues = _cpuMemoryMap[cpu]; + + if (validMemoryValues.Contains(memory)) + { + return ValidationResult.Valid(); + } + + var failed = + ValidationFailedMessage + .Replace("{{cpu}}", cpu) + .Replace("{{memory}}", memory) + .Replace("{{memoryList}}", string.Join(", ", validMemoryValues)); + + return ValidationResult.Failed(failed); + + } + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/ValidationResult.cs b/src/AWS.Deploy.Common/Recipes/Validation/ValidationResult.cs new file mode 100644 index 000000000..5850aad41 --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/ValidationResult.cs @@ -0,0 +1,28 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace AWS.Deploy.Common.Recipes.Validation +{ + public class ValidationResult + { + public bool IsValid { get; set; } + public string? ValidationFailedMessage { get;set; } + + public static ValidationResult Failed(string message) + { + return new ValidationResult + { + IsValid = false, + ValidationFailedMessage = message + }; + } + + public static ValidationResult Valid() + { + return new ValidationResult + { + IsValid = true + }; + } + } +} diff --git a/src/AWS.Deploy.Common/Recipes/Validation/ValidatorFactory.cs b/src/AWS.Deploy.Common/Recipes/Validation/ValidatorFactory.cs new file mode 100644 index 000000000..282675fe2 --- /dev/null +++ b/src/AWS.Deploy.Common/Recipes/Validation/ValidatorFactory.cs @@ -0,0 +1,65 @@ +// 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 Newtonsoft.Json.Linq; + +namespace AWS.Deploy.Common.Recipes.Validation +{ + /// + /// Builds and instances. + /// + public static class ValidatorFactory + { + private static readonly Dictionary _optionSettingItemValidatorTypeMapping = new() + { + { OptionSettingItemValidatorList.Range, typeof(RangeValidator) }, + { OptionSettingItemValidatorList.Regex, typeof(RegexValidator) }, + { OptionSettingItemValidatorList.Required, typeof(RequiredValidator) } + }; + + private static readonly Dictionary _recipeValidatorTypeMapping = new() + { + { RecipeValidatorList.FargateTaskSizeCpuMemoryLimits, typeof(FargateTaskCpuMemorySizeValidator) } + }; + + public static IOptionSettingItemValidator[] BuildValidators(this OptionSettingItem optionSettingItem) + { + return optionSettingItem.Validators + .Select(v => Activate(v.ValidatorType, v.Configuration, _optionSettingItemValidatorTypeMapping)) + .OfType() + .ToArray(); + } + + public static IRecipeValidator[] BuildValidators(this RecipeDefinition recipeDefinition) + { + return recipeDefinition.Validators + .Select(v => Activate(v.ValidatorType, v.Configuration,_recipeValidatorTypeMapping)) + .OfType() + .ToArray(); + } + + private static object? Activate(TValidatorList validatorType, object? configuration, Dictionary typeMappings) where TValidatorList : struct + { + if (null == configuration) + { + var validatorInstance = Activator.CreateInstance(typeMappings[validatorType]); + if (validatorInstance == null) + throw new InvalidValidatorTypeException($"Could not create an instance of validator type {validatorType}"); + return validatorInstance; + } + + if (configuration is JObject jObject) + { + var validatorInstance = jObject.ToObject(typeMappings[validatorType]); + if (validatorInstance == null) + throw new InvalidValidatorTypeException($"Could not create an instance of validator type {validatorType}"); + return validatorInstance; + } + + return configuration; + } + } +} diff --git a/src/AWS.Deploy.Constants/CDK.cs b/src/AWS.Deploy.Constants/CDK.cs index fbba2f38c..879d08317 100644 --- a/src/AWS.Deploy.Constants/CDK.cs +++ b/src/AWS.Deploy.Constants/CDK.cs @@ -6,7 +6,7 @@ namespace AWS.Deploy.Constants { - public static class CDK + internal static class CDK { /// /// Deployment tool workspace directory to create CDK app during the deployment. diff --git a/src/AWS.Deploy.Constants/CLI.cs b/src/AWS.Deploy.Constants/CLI.cs index ac3c3b5e3..71d061cfb 100644 --- a/src/AWS.Deploy.Constants/CLI.cs +++ b/src/AWS.Deploy.Constants/CLI.cs @@ -4,7 +4,7 @@ namespace AWS.Deploy.Constants { - public static class CLI + internal static class CLI { public const string CREATE_NEW_LABEL = "*** Create new ***"; public const string DEFAULT_LABEL = "*** Default ***"; diff --git a/src/AWS.Deploy.Constants/CloudFormationIdentifier.cs b/src/AWS.Deploy.Constants/CloudFormationIdentifier.cs index b42480992..c3fc8b0b3 100644 --- a/src/AWS.Deploy.Constants/CloudFormationIdentifier.cs +++ b/src/AWS.Deploy.Constants/CloudFormationIdentifier.cs @@ -4,7 +4,7 @@ namespace AWS.Deploy.Constants { - public static class CloudFormationIdentifier + internal static class CloudFormationIdentifier { /// /// The CDK context parameter name used to pass in the location of the AWS .NET deployment tool's settings file. diff --git a/src/AWS.Deploy.DockerEngine/DockerEngine.cs b/src/AWS.Deploy.DockerEngine/DockerEngine.cs index 4c360adfb..c591d3531 100644 --- a/src/AWS.Deploy.DockerEngine/DockerEngine.cs +++ b/src/AWS.Deploy.DockerEngine/DockerEngine.cs @@ -126,9 +126,12 @@ private ImageMapping GetImageMapping() { var content = ProjectUtilities.ReadDockerFileConfig(); var definitions = JsonConvert.DeserializeObject>(content); - var mappings = definitions.Where(x => x.SdkType.Equals(_project.SdkType)).FirstOrDefault(); + var mappings = definitions.FirstOrDefault(x => x.SdkType.Equals(_project.SdkType)); + if (mappings == null) + throw new UnsupportedProjectException($"The project with SDK Type {_project.SdkType} is not supported."); - return mappings.ImageMapping.FirstOrDefault(x => x.TargetFramework.Equals(_project.TargetFramework)); + return mappings.ImageMapping.FirstOrDefault(x => x.TargetFramework.Equals(_project.TargetFramework)) + ?? throw new UnsupportedProjectException($"The project with Target Framework {_project.TargetFramework} is not supported."); } /// diff --git a/src/AWS.Deploy.DockerEngine/DockerFile.cs b/src/AWS.Deploy.DockerEngine/DockerFile.cs index 3e77aa651..c595bc868 100644 --- a/src/AWS.Deploy.DockerEngine/DockerFile.cs +++ b/src/AWS.Deploy.DockerEngine/DockerFile.cs @@ -63,7 +63,7 @@ public void WriteDockerFile(string projectDirectory, List? projectList) projects += $"COPY [\"{projectList[i]}\", \"{projectList[i].Substring(0, projectList[i].LastIndexOf("/") + 1)}\"]" + (i < projectList.Count - 1 ? Environment.NewLine : ""); } - projectPath = projectList.Where(x => x.EndsWith(_projectName)).FirstOrDefault(); + projectPath = projectList.First(x => x.EndsWith(_projectName)); if (projectPath.LastIndexOf("/") > -1) { projectFolder = projectPath.Substring(0, projectPath.LastIndexOf("/")); diff --git a/src/AWS.Deploy.DockerEngine/Exceptions.cs b/src/AWS.Deploy.DockerEngine/Exceptions.cs index 9bd6fdd19..d844c066a 100644 --- a/src/AWS.Deploy.DockerEngine/Exceptions.cs +++ b/src/AWS.Deploy.DockerEngine/Exceptions.cs @@ -24,4 +24,9 @@ public class DockerEngineExceptionBase : Exception { public DockerEngineExceptionBase(string message) : base(message) { } } + + public class UnsupportedProjectException : DockerEngineExceptionBase + { + public UnsupportedProjectException(string message) : base(message) { } + } } diff --git a/src/AWS.Deploy.Orchestration/AWS.Deploy.Orchestration.csproj b/src/AWS.Deploy.Orchestration/AWS.Deploy.Orchestration.csproj index d9abf276b..ca3183b66 100644 --- a/src/AWS.Deploy.Orchestration/AWS.Deploy.Orchestration.csproj +++ b/src/AWS.Deploy.Orchestration/AWS.Deploy.Orchestration.csproj @@ -32,4 +32,6 @@ + + diff --git a/src/AWS.Deploy.Orchestration/CdkAppSettingsSerializer.cs b/src/AWS.Deploy.Orchestration/CdkAppSettingsSerializer.cs index 1e6841c4d..9a678ae82 100644 --- a/src/AWS.Deploy.Orchestration/CdkAppSettingsSerializer.cs +++ b/src/AWS.Deploy.Orchestration/CdkAppSettingsSerializer.cs @@ -13,10 +13,14 @@ public class CdkAppSettingsSerializer { public string Build(CloudApplication cloudApplication, Recommendation recommendation) { + var projectPath = new FileInfo(recommendation.ProjectPath).Directory?.FullName; + if (string.IsNullOrEmpty(projectPath)) + throw new InvalidProjectPathException("The project path provided is invalid."); + // General Settings var appSettingsContainer = new RecipeConfiguration>( cloudApplication.StackName, - new FileInfo(recommendation.ProjectPath).Directory.FullName, + projectPath, recommendation.Recipe.Id, recommendation.Recipe.Version, new () diff --git a/src/AWS.Deploy.Orchestration/DeploymentBundleHandler.cs b/src/AWS.Deploy.Orchestration/DeploymentBundleHandler.cs index 790fa2021..61a09d6cc 100644 --- a/src/AWS.Deploy.Orchestration/DeploymentBundleHandler.cs +++ b/src/AWS.Deploy.Orchestration/DeploymentBundleHandler.cs @@ -138,7 +138,9 @@ public async Task CreateDotnetPublishZip(Recommendation recommendation) private string GetDockerExecutionDirectory(Recommendation recommendation) { var dockerExecutionDirectory = recommendation.DeploymentBundle.DockerExecutionDirectory; - var dockerFileDirectory = new FileInfo(recommendation.ProjectPath).Directory.FullName; + var dockerFileDirectory = new FileInfo(recommendation.ProjectPath).Directory?.FullName; + if (dockerFileDirectory == null) + throw new InvalidProjectPathException("The project path is invalid."); var projectSolutionPath = GetProjectSolutionFile(recommendation.ProjectPath); if (string.IsNullOrEmpty(dockerExecutionDirectory)) @@ -149,7 +151,8 @@ private string GetDockerExecutionDirectory(Recommendation recommendation) } else { - dockerExecutionDirectory = new FileInfo(projectSolutionPath).Directory.FullName; + var projectSolutionDirectory = new FileInfo(projectSolutionPath).Directory?.FullName; + dockerExecutionDirectory = projectSolutionDirectory ?? throw new InvalidSolutionPathException("The solution path is invalid."); } } @@ -158,7 +161,9 @@ private string GetDockerExecutionDirectory(Recommendation recommendation) private string GetDockerFilePath(Recommendation recommendation) { - var dockerFileDirectory = new FileInfo(recommendation.ProjectPath).Directory.FullName; + var dockerFileDirectory = new FileInfo(recommendation.ProjectPath).Directory?.FullName; + if (dockerFileDirectory == null) + throw new InvalidProjectPathException("The project path is invalid."); return Path.Combine(dockerFileDirectory, "Dockerfile"); } diff --git a/src/AWS.Deploy.Orchestration/Exceptions.cs b/src/AWS.Deploy.Orchestration/Exceptions.cs index fde01b2f2..713965c40 100644 --- a/src/AWS.Deploy.Orchestration/Exceptions.cs +++ b/src/AWS.Deploy.Orchestration/Exceptions.cs @@ -128,4 +128,22 @@ public class InvalidRecipePathException : Exception { public InvalidRecipePathException(string message, Exception? innerException = null) : base(message, innerException) { } } + + /// + /// Exception thrown if Solution Path contains an invalid path + /// + [AWSDeploymentExpectedException] + public class InvalidSolutionPathException : Exception + { + public InvalidSolutionPathException(string message, Exception? innerException = null) : base(message, innerException) { } + } + + /// + /// Exception thrown if AWS Deploy Recipes CDK Common Product Version is invalid. + /// + [AWSDeploymentExpectedException] + public class InvalidAWSDeployRecipesCDKCommonVersionException : Exception + { + public InvalidAWSDeployRecipesCDKCommonVersionException(string message, Exception? innerException = null) : base(message, innerException) { } + } } diff --git a/src/AWS.Deploy.Orchestration/IOrchestratorInteractiveService.cs b/src/AWS.Deploy.Orchestration/IOrchestratorInteractiveService.cs index b3e52417d..c66a38327 100644 --- a/src/AWS.Deploy.Orchestration/IOrchestratorInteractiveService.cs +++ b/src/AWS.Deploy.Orchestration/IOrchestratorInteractiveService.cs @@ -5,10 +5,10 @@ namespace AWS.Deploy.Orchestration { public interface IOrchestratorInteractiveService { - void LogErrorMessageLine(string message); + void LogErrorMessageLine(string? message); - void LogMessageLine(string message); + void LogMessageLine(string? message); - void LogDebugLine(string message); + void LogDebugLine(string? message); } } diff --git a/src/AWS.Deploy.Orchestration/OrchestratorSession.cs b/src/AWS.Deploy.Orchestration/OrchestratorSession.cs index da746061e..0cd8ce8dc 100644 --- a/src/AWS.Deploy.Orchestration/OrchestratorSession.cs +++ b/src/AWS.Deploy.Orchestration/OrchestratorSession.cs @@ -4,10 +4,11 @@ using System.Threading.Tasks; using Amazon.Runtime; using AWS.Deploy.Common; +using AWS.Deploy.Common.Recipes.Validation; namespace AWS.Deploy.Orchestration { - public class OrchestratorSession + public class OrchestratorSession : IDeployToolValidationContext { public ProjectDefinition ProjectDefinition { get; set; } public string? AWSProfileName { get; set; } diff --git a/src/AWS.Deploy.Orchestration/RecommendationEngine/FileExistsTest.cs b/src/AWS.Deploy.Orchestration/RecommendationEngine/FileExistsTest.cs index 2625129d9..c653626a3 100644 --- a/src/AWS.Deploy.Orchestration/RecommendationEngine/FileExistsTest.cs +++ b/src/AWS.Deploy.Orchestration/RecommendationEngine/FileExistsTest.cs @@ -16,6 +16,11 @@ public class FileExistsTest : BaseRecommendationTest public override Task Execute(RecommendationTestInput input) { var directory = Path.GetDirectoryName(input.ProjectDefinition.ProjectPath); + + if (directory == null || + input.Test.Condition.FileName == null) + return Task.FromResult(false); + var result = (Directory.GetFiles(directory, input.Test.Condition.FileName).Length == 1); return Task.FromResult(result); } diff --git a/src/AWS.Deploy.Orchestration/TemplateEngine.cs b/src/AWS.Deploy.Orchestration/TemplateEngine.cs index 7cde37cd6..e4f87dc6f 100644 --- a/src/AWS.Deploy.Orchestration/TemplateEngine.cs +++ b/src/AWS.Deploy.Orchestration/TemplateEngine.cs @@ -61,7 +61,8 @@ public async Task GenerateCDKProjectFromTemplate(Recommendation recommendation, // CDK Template projects can parameterize the version number of the AWS.Deploy.Recipes.CDK.Common package. This avoid // projects having to be modified every time the package version is bumped. - { "AWSDeployRecipesCDKCommonVersion", FileVersionInfo.GetVersionInfo(typeof(Constants.CloudFormationIdentifier).Assembly.Location).ProductVersion } + { "AWSDeployRecipesCDKCommonVersion", FileVersionInfo.GetVersionInfo(typeof(Constants.CloudFormationIdentifier).Assembly.Location).ProductVersion + ?? throw new InvalidAWSDeployRecipesCDKCommonVersionException("The version number of the AWS.Deploy.Recipes.CDK.Common package is invalid.") } }; foreach(var option in recommendation.Recipe.OptionSettings) diff --git a/src/AWS.Deploy.Recipes/DeploymentBundleDefinitionLocator.cs b/src/AWS.Deploy.Recipes/DeploymentBundleDefinitionLocator.cs index 5e5327e2c..345ca2e14 100644 --- a/src/AWS.Deploy.Recipes/DeploymentBundleDefinitionLocator.cs +++ b/src/AWS.Deploy.Recipes/DeploymentBundleDefinitionLocator.cs @@ -10,7 +10,7 @@ public class DeploymentBundleDefinitionLocator public static string FindDeploymentBundleDefinitionPath() { var assemblyPath = typeof(DeploymentBundleDefinitionLocator).Assembly.Location; - var deploymentBundleDefinitionPath = Path.Combine(Directory.GetParent(assemblyPath).FullName, "DeploymentBundleDefinitions"); + var deploymentBundleDefinitionPath = Path.Combine(Directory.GetParent(assemblyPath)!.FullName, "DeploymentBundleDefinitions"); return deploymentBundleDefinitionPath; } } diff --git a/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppECSFargate.recipe b/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppECSFargate.recipe index 51057e882..640670288 100644 --- a/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppECSFargate.recipe +++ b/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppECSFargate.recipe @@ -65,7 +65,10 @@ ], - + "Validators": [ + { + "ValidatorType": "FargateTaskSizeCpuMemoryLimits" + }], "OptionSettings": [ { @@ -93,6 +96,16 @@ "Type": "String", "AdvancedSetting": false, "Updatable": false, + "Validators": [ + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "arn:[^:]+:ecs:[^:]*:[0-9]{12}:cluster/.+", + "AllowEmptyString": true, + "ValidationFailedMessage": "Invalid cluster Arn. The ARN should contain the arn:[PARTITION]:ecs namespace, followed by the Region of the cluster, the AWS account ID of the cluster owner, the cluster namespace, and then the cluster name. For example, arn:aws:ecs:region:012345678910:cluster/test. For more information visit https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_Cluster.html" + } + } + ], "DependsOn": [ { "Id": "ECSCluster.CreateNew", @@ -108,6 +121,16 @@ "DefaultValue": "{ProjectName}", "AdvancedSetting": false, "Updatable": false, + "Validators": [ + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "^([A-Za-z0-9-]{1,255})$", + "AllowEmptyString": true, + "ValidationFailedMessage": "Invalid cluster name. The cluster name can only contain letters (case-sensitive), numbers, hyphens and can't be longer than 255 character in length." + } + } + ], "DependsOn": [ { "Id": "ECSCluster.CreateNew", @@ -126,7 +149,16 @@ "TypeHint": "ECSService", "DefaultValue": "{ProjectName}-service", "AdvancedSetting": false, - "Updatable": false + "Updatable": false, + "Validators": [ + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "^([A-Za-z0-9_-]{1,255})$", + "ValidationFailedMessage": "Invalid service name. The service name can only contain letters (case-sensitive), numbers, hyphens, underscores and can't be longer than 255 character in length." + } + } + ] }, { "Id": "DesiredCount", @@ -135,7 +167,16 @@ "Type": "Int", "DefaultValue": 3, "AdvancedSetting": false, - "Updatable": true + "Updatable": true, + "Validators": [ + { + "ValidatorType": "Range", + "Configuration" : { + "Min": 1, + "Max": 5000 + } + } + ] }, { "Id": "ApplicationIAMRole", @@ -165,6 +206,16 @@ "Type": "String", "AdvancedSetting": false, "Updatable": false, + "Validators": [ + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "arn:.+:iam::[0-9]{12}:.+", + "AllowEmptyString": true, + "ValidationFailedMessage": "Invalid IAM Role ARN. The ARN should contain the arn:[PARTITION]:iam namespace, followed by the account ID, and then the resource path. For example - arn:aws:iam::123456789012:role/S3Access is a valid IAM Role ARN. For more information visit https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html#identifiers-arns" + } + } + ], "DependsOn": [ { "Id": "ApplicationIAMRole.CreateNew", @@ -215,6 +266,16 @@ "DefaultValue": null, "AdvancedSetting": false, "Updatable": false, + "Validators": [ + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "^vpc-([0-9a-f]{8}|[0-9a-f]{17})$", + "AllowEmptyString": true, + "ValidationFailedMessage": "Invalid VPC ID. The VPC ID must start with the \"vpc-\" prefix, followed by either 8 or 17 characters consisting of digits and letters(lower-case) from a to f. For example vpc-abc88de9 is a valid VPC ID." + } + } + ], "DependsOn": [ { "Id": "Vpc.IsDefault", diff --git a/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppElasticBeanstalk.recipe b/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppElasticBeanstalk.recipe index 362479a77..5ce440b70 100644 --- a/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppElasticBeanstalk.recipe +++ b/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppElasticBeanstalk.recipe @@ -77,7 +77,16 @@ "Type": "String", "DefaultValue": "{ProjectName}", "AdvancedSetting": false, - "Updatable": false + "Updatable": false, + "Validators": [ + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "^[^/]{1,100}$", + "ValidationFailedMessage": "Invalid Application Name. The Application name can contain up to 100 Unicode characters, not including forward slash (/)." + } + } + ] } ] }, @@ -90,7 +99,16 @@ "TypeHint": "BeanstalkEnvironment", "DefaultValue": "{ProjectName}-dev", "AdvancedSetting": false, - "Updatable": false + "Updatable": false, + "Validators": [ + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "^[a-zA-Z0-9][a-zA-Z0-9-]{2,38}[a-zA-Z0-9]$", + "ValidationFailedMessage": "Invalid Environment Name. The Environment Name Must be from 4 to 40 characters in length. The name can contain only letters, numbers, and hyphens. It can't start or end with a hyphen." + } + } + ] }, { "Id": "InstanceType", @@ -177,6 +195,16 @@ "Id": "ApplicationIAMRole.CreateNew", "Value": false } + ], + "Validators": [ + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "arn:.+:iam::[0-9]{12}:.+", + "AllowEmptyString": true, + "ValidationFailedMessage": "Invalid IAM Role ARN. The ARN should contain the arn:[PARTITION]:iam namespace, followed by the account ID, and then the resource path. For example - arn:aws:iam::123456789012:role/S3Access is a valid IAM Role ARN. For more information visit https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html#identifiers-arns" + } + } ] } ] @@ -189,7 +217,17 @@ "TypeHint": "EC2KeyPair", "DefaultValue": "", "AdvancedSetting": true, - "Updatable": false + "Updatable": false, + "Validators": [ + { + "ValidatorType": "Regex", + "Configuration" : { + "Regex": "^(?! ).+(? - netstandard2.0 + netstandard2.0 AWS .NET deployment tool Server Mode Client Package to access the APIs exposed by the deployment tool server mode. This package is not intended for direct usage. AWS.Deploy.ServerMode.Client diff --git a/src/AWS.Deploy.ServerMode.ClientGenerator/Program.cs b/src/AWS.Deploy.ServerMode.ClientGenerator/Program.cs index b2177bdcf..b27c30ee6 100644 --- a/src/AWS.Deploy.ServerMode.ClientGenerator/Program.cs +++ b/src/AWS.Deploy.ServerMode.ClientGenerator/Program.cs @@ -55,11 +55,17 @@ static string DetermineFullFilePath(string codeFile) { var dir = new DirectoryInfo(Directory.GetCurrentDirectory()); - while (!string.Equals(dir.Name, "src")) + while (!string.Equals(dir?.Name, "src")) { + if (dir == null) + break; + dir = dir.Parent; } + if (dir == null) + throw new Exception("Could not determine file path of current directory."); + return Path.Combine(dir.FullName, "AWS.Deploy.ServerMode.Client", codeFile); } } diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/AWS.Deploy.CLI.Common.UnitTests.csproj b/test/AWS.Deploy.CLI.Common.UnitTests/AWS.Deploy.CLI.Common.UnitTests.csproj index a8aeac830..572f4266f 100644 --- a/test/AWS.Deploy.CLI.Common.UnitTests/AWS.Deploy.CLI.Common.UnitTests.csproj +++ b/test/AWS.Deploy.CLI.Common.UnitTests/AWS.Deploy.CLI.Common.UnitTests.csproj @@ -12,4 +12,12 @@ + + + + + + + + diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ECSFargateOptionSettingItemValidationTests.cs b/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ECSFargateOptionSettingItemValidationTests.cs new file mode 100644 index 000000000..f6c4f2995 --- /dev/null +++ b/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ECSFargateOptionSettingItemValidationTests.cs @@ -0,0 +1,146 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using AWS.Deploy.Common; +using AWS.Deploy.Common.Recipes; +using AWS.Deploy.Common.Recipes.Validation; +using Should; +using Xunit; +using Xunit.Abstractions; + +namespace AWS.Deploy.CLI.Common.UnitTests.Recipes.Validation +{ + public class ECSFargateOptionSettingItemValidationTests + { + [Theory] + [InlineData("arn:aws:ecs:us-east-1:012345678910:cluster/test", true)] + [InlineData("arn:aws-cn:ecs:us-east-1:012345678910:cluster/test", true)] + [InlineData("arb:aws:ecs:us-east-1:012345678910:cluster/test", false)] //typo arb instean of arn + [InlineData("arn:aws:ecs:us-east-1:01234567891:cluster/test", false)] //invalid account ID + [InlineData("arn:aws:ecs:us-east-1:012345678910:cluster", false)] //no cluster name + [InlineData("arn:aws:ecs:us-east-1:012345678910:fluster/test", false)] //fluster instead of cluster + public void ClusterArnValidationTests(string value, bool isValid) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description"); + optionSettingItem.Validators.Add(GetRegexValidatorConfig("arn:[^:]+:ecs:[^:]*:[0-9]{12}:cluster/.+")); + Validate(optionSettingItem, value, isValid); + } + + [Theory] + [InlineData("abcdef1234", true)] + [InlineData("abc123def45", true)] + [InlineData("abc12-34-56-XZ", true)] + [InlineData("abc_@1323", false)] //invalid characters + [InlineData("123*&$abc", false)] //invalid characters + public void NewClusterNameValidationTests(string value, bool isValid) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description"); + //up to 255 letters(uppercase and lowercase), numbers, underscores, and hyphens are allowed. + optionSettingItem.Validators.Add(GetRegexValidatorConfig("^([A-Za-z0-9-]{1,255})$")); + Validate(optionSettingItem, value, isValid); + } + + [Theory] + [InlineData("abcdef1234", true)] + [InlineData("abc123def45", true)] + [InlineData("abc12-34-56_XZ", true)] + [InlineData("abc_@1323", false)] //invalid character "@" + [InlineData("123*&$_abc_", false)] //invalid characters + public void ECSServiceNameValidationTests(string value, bool isValid) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description"); + // Up to 255 letters (uppercase and lowercase), numbers, hyphens, and underscores are allowed. + optionSettingItem.Validators.Add(GetRegexValidatorConfig("^([A-Za-z0-9_-]{1,255})$")); + Validate(optionSettingItem, value, isValid); + } + + [Theory] + [InlineData(5, true)] + [InlineData(10, true)] + [InlineData(-1, false)] + [InlineData(6000, false)] + [InlineData(1000, true)] + public void DesiredCountValidationTests(int value, bool isValid) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description"); + optionSettingItem.Validators.Add(GetRangeValidatorConfig(1, 5000)); + Validate(optionSettingItem, value, isValid); + } + + [Theory] + [InlineData("arn:aws:iam::123456789012:user/JohnDoe", true)] + [InlineData("arn:aws:iam::123456789012:user/division_abc/subdivision_xyz/JaneDoe", true)] + [InlineData("arn:aws:iam::123456789012:group/Developers", true)] + [InlineData("arn:aws:iam::123456789012:role/S3Access", true)] + [InlineData("arn:aws:IAM::123456789012:role/S3Access", false)] //invalid uppercase IAM + [InlineData("arn:aws:iam::1234567890124354:role/S3Access", false)] //invalid account ID + public void RoleArnValidationTests(string value, bool isValid) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description"); + optionSettingItem.Validators.Add(GetRegexValidatorConfig("arn:.+:iam::[0-9]{12}:.+")); + Validate(optionSettingItem, value, isValid); + } + + [Theory] + [InlineData("vpc-0123abcd", true)] + [InlineData("vpc-ab12bf49", true)] + [InlineData("vpc-ffffffffaaaabbbb1", true)] + [InlineData("vpc-12345678", true)] + [InlineData("ipc-456678", false)] //invalid prefix + [InlineData("vpc-zzzzzzzz", false)] //invalid character z + [InlineData("vpc-ffffffffaaaabbbb12", false)] //suffix length greater than 17 + public void VpcIdValidationTests(string value, bool isValid) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description"); + //must start with the \"vpc-\" prefix, + //followed by either 8 or 17 characters consisting of digits and letters(lower-case) from a to f. + optionSettingItem.Validators.Add(GetRegexValidatorConfig("^vpc-([0-9a-f]{8}|[0-9a-f]{17})$")); + Validate(optionSettingItem, value, isValid); + } + + private OptionSettingItemValidatorConfig GetRegexValidatorConfig(string regex) + { + var regexValidatorConfig = new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.Regex, + Configuration = new RegexValidator + { + Regex = regex + } + }; + return regexValidatorConfig; + } + + private OptionSettingItemValidatorConfig GetRangeValidatorConfig(int min, int max) + { + var rangeValidatorConfig = new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.Range, + Configuration = new RangeValidator + { + Min = min, + Max = max + } + }; + return rangeValidatorConfig; + } + + private void Validate(OptionSettingItem optionSettingItem, T value, bool isValid) + { + ValidationFailedException exception = null; + try + { + optionSettingItem.SetValueOverride(value); + } + catch (ValidationFailedException e) + { + exception = e; + } + + if (isValid) + exception.ShouldBeNull(); + else + exception.ShouldNotBeNull(); + } + } +} diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ElasticBeanStalkOptionSettingItemValidationTests.cs b/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ElasticBeanStalkOptionSettingItemValidationTests.cs new file mode 100644 index 000000000..cfbec0982 --- /dev/null +++ b/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ElasticBeanStalkOptionSettingItemValidationTests.cs @@ -0,0 +1,128 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using AWS.Deploy.Common; +using AWS.Deploy.Common.Recipes; +using AWS.Deploy.Common.Recipes.Validation; +using Should; +using Xunit; + +namespace AWS.Deploy.CLI.Common.UnitTests.Recipes.Validation +{ + public class ElasticBeanStalkOptionSettingItemValidationTests + { + [Theory] + [InlineData("12345sas", true)] + [InlineData("435&*abc@3123", true)] + [InlineData("abc/123/#", false)] // invalid character forward slash(/) + public void ApplicationNameValidationTest(string value, bool isValid) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description"); + //can contain up to 100 Unicode characters, not including forward slash (/). + optionSettingItem.Validators.Add(GetRegexValidatorConfig("^[^/]{1,100}$")); + Validate(optionSettingItem, value, isValid); + } + + [Theory] + [InlineData("abc-123", true)] + [InlineData("abc-ABC-123-xyz", true)] + [InlineData("abc", false)] // invalid length less than 4 characters. + [InlineData("-12-abc", false)] // invalid character leading hyphen (-) + public void EnvironmentNameValidationTest(string value, bool isValid) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description"); + // Must be from 4 to 40 characters in length. The name can contain only letters, numbers, and hyphens. + // It can't start or end with a hyphen. + optionSettingItem.Validators.Add(GetRegexValidatorConfig("^[a-zA-Z0-9][a-zA-Z0-9-]{2,38}[a-zA-Z0-9]$")); + Validate(optionSettingItem, value, isValid); + } + + [Theory] + [InlineData("arn:aws:iam::123456789012:user/JohnDoe", true)] + [InlineData("arn:aws:iam::123456789012:user/division_abc/subdivision_xyz/JaneDoe", true)] + [InlineData("arn:aws:iam::123456789012:group/Developers", true)] + [InlineData("arn:aws:iam::123456789012:role/S3Access", true)] + [InlineData("arn:aws:IAM::123456789012:role/S3Access", false)] //invalid uppercase IAM + [InlineData("arn:aws:iam::1234567890124354:role/S3Access", false)] //invalid account ID + public void IAMRoleArnValidationTest(string value, bool isValid) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description"); + optionSettingItem.Validators.Add(GetRegexValidatorConfig("arn:.+:iam::[0-9]{12}:.+")); + Validate(optionSettingItem, value, isValid); + } + + [Theory] + [InlineData("abcd1234", true)] + [InlineData("abc 1234 xyz", true)] + [InlineData(" abc 123-xyz", false)] //leading space + [InlineData(" 123 abc-456 ", false)] //leading and trailing space + public void EC2KeyPairValidationTest(string value, bool isValid) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description"); + // It allows all ASCII characters but without leading and trailing spaces + optionSettingItem.Validators.Add(GetRegexValidatorConfig("^(?! ).+(?(OptionSettingItem optionSettingItem, T value, bool isValid) + { + ValidationFailedException exception = null; + + try + { + optionSettingItem.SetValueOverride(value); + } + catch (ValidationFailedException e) + { + exception = e; + } + + if (isValid) + exception.ShouldBeNull(); + else + exception.ShouldNotBeNull(); + } + + } +} diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/OptionSettingsItemValidationTests.cs b/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/OptionSettingsItemValidationTests.cs new file mode 100644 index 000000000..6ebebdc82 --- /dev/null +++ b/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/OptionSettingsItemValidationTests.cs @@ -0,0 +1,193 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.\r +// SPDX-License-Identifier: Apache-2.0 + +using AWS.Deploy.Common; +using AWS.Deploy.Common.Recipes; +using AWS.Deploy.Common.Recipes.Validation; +using Should; +using Xunit; +using Xunit.Abstractions; + +// Justification: False Positives with assertions, also test class +// ReSharper disable PossibleNullReferenceException + +namespace AWS.Deploy.CLI.Common.UnitTests.Recipes.Validation +{ + /// + /// Tests for the interaction between + /// and + /// + public class OptionSettingsItemValidationTests + { + private readonly ITestOutputHelper _output; + + public OptionSettingsItemValidationTests(ITestOutputHelper output) + { + _output = output; + } + + [Theory] + [InlineData("")] + [InlineData("-10")] + [InlineData("100")] + public void InvalidInputInMultipleValidatorsThrowsException(string invalidValue) + { + var optionSettingItem = new OptionSettingItem("id", "name", "description") + { + Validators = new() + { + new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.Required + }, + new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.Range, + Configuration = new RangeValidator + { + Min = 7, + Max = 10 + } + } + } + }; + + ValidationFailedException exception = null; + + // ACT + try + { + optionSettingItem.SetValueOverride(invalidValue); + } + catch (ValidationFailedException e) + { + exception = e; + } + + exception.ShouldNotBeNull(); + + _output.WriteLine(exception.Message); + } + + [Fact] + public void InvalidInputInSingleValidatorThrowsException() + { + var invalidValue = "lowercase_only"; + var optionSettingItem = new OptionSettingItem("id", "name", "description") + { + Validators = new() + { + new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.Regex, + Configuration = new RegexValidator + { + Regex = "^[A-Z]*$" + } + } + } + }; + + ValidationFailedException exception = null; + + // ACT + try + { + optionSettingItem.SetValueOverride(invalidValue); + } + catch (ValidationFailedException e) + { + exception = e; + } + + exception.ShouldNotBeNull(); + exception.Message.ShouldContain("[A-Z]*"); + + } + + [Fact] + public void ValidInputDoesNotThrowException() + { + var validValue = 8; + + var optionSettingItem = new OptionSettingItem("id", "name", "description") + { + Validators = new() + { + new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.Range, + Configuration = new RangeValidator + { + Min = 7, + Max = 10 + } + }, + new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.Required + } + } + }; + + ValidationFailedException exception = null; + + // ACT + try + { + optionSettingItem.SetValueOverride(validValue); + } + catch (ValidationFailedException e) + { + exception = e; + } + + exception.ShouldBeNull(); + } + + + /// + /// This tests a decent amount of plumbing for a unit test, but + /// helps tests several important concepts. + /// + [Fact] + public void CustomValidatorMessagePropagatesToValidationException() + { + // ARRANGE + var customValidationMessage = "Custom Validation Message: Testing!"; + var invalidValue = 100; + + var optionSettingItem = new OptionSettingItem("id", "name", "description") + { + Validators = new() + { + new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.Range, + Configuration = new RangeValidator + { + Min = 7, + Max = 10, + ValidationFailedMessage = customValidationMessage + } + } + } + }; + + ValidationFailedException exception = null; + + // ACT + try + { + optionSettingItem.SetValueOverride(invalidValue); + } + catch (ValidationFailedException e) + { + exception = e; + } + + exception.ShouldNotBeNull(); + exception.Message.ShouldEqual(customValidationMessage); + } + } +} diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ValidatorFactoryTests.cs b/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ValidatorFactoryTests.cs new file mode 100644 index 000000000..32f90512c --- /dev/null +++ b/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ValidatorFactoryTests.cs @@ -0,0 +1,179 @@ +// 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 Amazon.Runtime.Internal; +using AWS.Deploy.Common.Recipes; +using AWS.Deploy.Common.Recipes.Validation; +using Newtonsoft.Json; +using Should; +using Xunit; + +// Justification: False Positives with assertions, also test class +// ReSharper disable PossibleNullReferenceException + +namespace AWS.Deploy.CLI.Common.UnitTests.Recipes.Validation +{ + /// + /// Tests for + /// + public class ValidatorFactoryTests + { + [Fact] + public void HasABindingForAllOptionSettingItemValidators() + { + // ARRANGE + var allValidators = Enum.GetValues(typeof(OptionSettingItemValidatorList)); + + var optionSettingItem = new OptionSettingItem("id", "name", "description") + { + Validators = + allValidators + .Cast() + .Select(validatorType => + new OptionSettingItemValidatorConfig + { + ValidatorType = validatorType + } + ) + .ToList() + }; + + // ACT + var validators = optionSettingItem.BuildValidators(); + + // ASSERT + validators.Length.ShouldEqual(allValidators.Length); + } + + [Fact] + public void HasABindingForAllRecipeValidators() + { + // ARRANGE + var allValidators = Enum.GetValues(typeof(RecipeValidatorList)); + + var recipeDefinition = new RecipeDefinition("id", "version", "name", + DeploymentTypes.CdkProject, DeploymentBundleTypes.Container, "template", "templateId", "description", "targetService") + { + Validators = + allValidators + .Cast() + .Select(validatorType => + new RecipeValidatorConfig + { + ValidatorType = validatorType + } + ) + .ToList() + }; + + // ACT + var validators = recipeDefinition.BuildValidators(); + + // ASSERT + validators.Length.ShouldEqual(allValidators.Length); + } + + /// + /// Make sure we build correctly when coming from json + /// + [Fact] + public void CanBuildRehydratedOptionSettingsItem() + { + // ARRANGE + var expectedValidator = new RequiredValidator + { + ValidationFailedMessage = "Custom Test Message" + }; + + var optionSettingItem = new OptionSettingItem("id", "name", "description") + { + Name = "Test Item", + Validators = new List + { + new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.Required, + Configuration = expectedValidator + } + } + }; + + var json = JsonConvert.SerializeObject(optionSettingItem, Formatting.Indented); + + var deserialized = JsonConvert.DeserializeObject(json); + + // ACT + var validators = deserialized.BuildValidators(); + + // ASSERT + validators.Length.ShouldEqual(1); + validators.First().ShouldBeType(expectedValidator.GetType()); + validators.OfType().First().ValidationFailedMessage.ShouldEqual(expectedValidator.ValidationFailedMessage); + } + + /// + /// This tests captures the behavior of the system. Requirements for this area are a little unclear + /// and can be adjusted as needed. This test is not meant to show 'ideal' behavior; only 'current' + /// behavior. + /// + /// This test behavior is dependent on using intermediary json. If you just + /// used a fully populated , this test would behave differently. + /// Coming from json, wins, + /// coming from object model wins. + /// + [Fact] + public void WhenValidatorTypeAndConfigurationHaveAMismatchThenValidatorTypeWins() + { + // ARRANGE + var optionSettingItem = new OptionSettingItem("id", "name", "description") + { + Name = "Test Item", + Validators = new List + { + new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.Regex, + // Required can only map to RequiredValidator, this setup doesn't make sense: + Configuration = new RangeValidator + { + Min = 1 + } + } + } + }; + + var json = JsonConvert.SerializeObject(optionSettingItem, Formatting.Indented); + + var deserialized = JsonConvert.DeserializeObject(json); + + Exception exception = null; + IOptionSettingItemValidator[] validators = null; + + // ACT + try + { + validators = deserialized.BuildValidators(); + } + catch (Exception e) + { + exception = e; + } + + // ASSERT + exception.ShouldBeNull(); + + // we have our built validator + validators.ShouldNotBeNull(); + validators.Length.ShouldEqual(1); + + // things get a little odd, the type is correct, + // but the output messages is going to be from the RangeValidator. + validators.First().ShouldBeType(); + validators.OfType().First().ValidationFailedMessage.ShouldEqual( + new RangeValidator().ValidationFailedMessage); + } + } +} diff --git a/test/AWS.Deploy.CLI.UnitTests/ApplyPreviousSettingsTests.cs b/test/AWS.Deploy.CLI.UnitTests/ApplyPreviousSettingsTests.cs index c7b3f2184..6370e3629 100644 --- a/test/AWS.Deploy.CLI.UnitTests/ApplyPreviousSettingsTests.cs +++ b/test/AWS.Deploy.CLI.UnitTests/ApplyPreviousSettingsTests.cs @@ -46,7 +46,7 @@ await parser.Parse(fullPath), [Theory] [InlineData(true, null)] - [InlineData(false, "role_arn")] + [InlineData(false, "arn:aws:iam::123456789012:group/Developers")] public async Task ApplyApplicationIAMRolePreviousSettings(bool createNew, string roleArn) { var engine = await BuildRecommendationEngine("WebAppNoDockerFile"); @@ -79,7 +79,7 @@ public async Task ApplyApplicationIAMRolePreviousSettings(bool createNew, string [Theory] [InlineData(true, false, "")] [InlineData(false, true, "")] - [InlineData(false, false, "vpc_id")] + [InlineData(false, false, "vpc-88888888")] public async Task ApplyVpcPreviousSettings(bool isDefault, bool createNew, string vpcId) { var engine = await BuildRecommendationEngine("WebAppWithDockerFile"); diff --git a/test/AWS.Deploy.CLI.UnitTests/ConsoleUtilitiesTests.cs b/test/AWS.Deploy.CLI.UnitTests/ConsoleUtilitiesTests.cs index ff2c1705e..cfffcab38 100644 --- a/test/AWS.Deploy.CLI.UnitTests/ConsoleUtilitiesTests.cs +++ b/test/AWS.Deploy.CLI.UnitTests/ConsoleUtilitiesTests.cs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 using System.Collections.Generic; +using System.Net.Http; using Should; using AWS.Deploy.Common; using Xunit; diff --git a/test/AWS.Deploy.CLI.UnitTests/RecommendationTests.cs b/test/AWS.Deploy.CLI.UnitTests/RecommendationTests.cs index f569d40d7..f0a34d191 100644 --- a/test/AWS.Deploy.CLI.UnitTests/RecommendationTests.cs +++ b/test/AWS.Deploy.CLI.UnitTests/RecommendationTests.cs @@ -232,11 +232,12 @@ public async Task ObjectMappingSetWithValue() var beanstalkRecommendation = recommendations.First(r => r.Recipe.Id == Constants.ASPNET_CORE_BEANSTALK_RECIPE_ID); var applicationIAMRoleOptionSetting = beanstalkRecommendation.Recipe.OptionSettings.First(optionSetting => optionSetting.Id.Equals("ApplicationIAMRole")); - applicationIAMRoleOptionSetting.SetValueOverride(new IAMRoleTypeHintResponse {CreateNew = false, RoleArn = "role_arn"}); + applicationIAMRoleOptionSetting.SetValueOverride(new IAMRoleTypeHintResponse {CreateNew = false, + RoleArn = "arn:aws:iam::123456789012:group/Developers" }); var iamRoleTypeHintResponse = beanstalkRecommendation.GetOptionSettingValue(applicationIAMRoleOptionSetting); - Assert.Equal("role_arn", iamRoleTypeHintResponse.RoleArn); + Assert.Equal("arn:aws:iam::123456789012:group/Developers", iamRoleTypeHintResponse.RoleArn); Assert.False(iamRoleTypeHintResponse.CreateNew); } diff --git a/test/AWS.Deploy.Orchestration.UnitTests/AWS.Deploy.Orchestration.UnitTests.csproj b/test/AWS.Deploy.Orchestration.UnitTests/AWS.Deploy.Orchestration.UnitTests.csproj index 4f8c65f64..252d707e2 100644 --- a/test/AWS.Deploy.Orchestration.UnitTests/AWS.Deploy.Orchestration.UnitTests.csproj +++ b/test/AWS.Deploy.Orchestration.UnitTests/AWS.Deploy.Orchestration.UnitTests.csproj @@ -20,4 +20,6 @@ + + diff --git a/version.json b/version.json index c17a288f3..b4efb1c30 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": "0.9", + "version": "0.10", "publicReleaseRefSpec": [ ".*" ],