diff --git a/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/SecurityGroupsInVpcValidator.cs b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/SecurityGroupsInVpcValidator.cs index 290a495e6..b26f8b374 100644 --- a/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/SecurityGroupsInVpcValidator.cs +++ b/src/AWS.Deploy.Common/Recipes/Validation/OptionSettingItemValidators/SecurityGroupsInVpcValidator.cs @@ -16,7 +16,17 @@ namespace AWS.Deploy.Common.Recipes.Validation public class SecurityGroupsInVpcValidator : IOptionSettingItemValidator { private static readonly string defaultValidationFailedMessage = "The selected security groups are not part of the selected VPC."; + + /// + /// Path to the OptionSetting that stores a selected Vpc Id + /// public string VpcId { get; set; } = ""; + + /// + /// Path to the OptionSetting that determines if the default VPC should be used + /// + public string IsDefaultVpcOptionSettingId { get; set; } = ""; + public string ValidationFailedMessage { get; set; } = defaultValidationFailedMessage; private readonly IAWSResourceQueryer _awsResourceQueryer; @@ -32,18 +42,77 @@ public async Task Validate(object input, Recommendation recomm { if (string.IsNullOrEmpty(VpcId)) return ValidationResult.Failed($"The '{nameof(SecurityGroupsInVpcValidator)}' validator is missing the '{nameof(VpcId)}' configuration."); - var vpcIdSetting = _optionSettingHandler.GetOptionSetting(recommendation, VpcId); - var vpcId = _optionSettingHandler.GetOptionSettingValue(recommendation, vpcIdSetting); + + var vpcId = ""; + + // The ECS Fargate recipes expose a separate radio button to select the default VPC which is mutually exclusive + // with specifying an explicit VPC Id. Because we give preference to "UseDefault" in the CDK project, + // we should do so here as well and validate the security groups against the default VPC if it's selected. + if (!string.IsNullOrEmpty(IsDefaultVpcOptionSettingId)) + { + var isDefaultVpcOptionSetting = _optionSettingHandler.GetOptionSetting(recommendation, IsDefaultVpcOptionSettingId); + var shouldUseDefaultVpc = _optionSettingHandler.GetOptionSettingValue(recommendation, isDefaultVpcOptionSetting); + + if (shouldUseDefaultVpc) + { + vpcId = (await _awsResourceQueryer.GetDefaultVpc()).VpcId; + } + } + + // If the "Use default?" option doesn't exist in the recipe, or it does and was false, or + // we failed to look up the default VPC, then use the explicity VPC Id + if (string.IsNullOrEmpty(vpcId)) + { + var vpcIdSetting = _optionSettingHandler.GetOptionSetting(recommendation, VpcId); + vpcId = _optionSettingHandler.GetOptionSettingValue(recommendation, vpcIdSetting); + } + if (string.IsNullOrEmpty(vpcId)) return ValidationResult.Failed("The VpcId setting is not set or is empty. Make sure to set the VPC Id first."); var securityGroupIds = (await _awsResourceQueryer.DescribeSecurityGroups(vpcId)).Select(x => x.GroupId); + + // The ASP.NET Fargate recipe uses a list of security groups if (input?.TryDeserialize>(out var inputList) ?? false) { + var invalidSecurityGroups = new List(); foreach (var securityGroup in inputList!) { if (!securityGroupIds.Contains(securityGroup)) - return ValidationResult.Failed("The selected security group(s) are invalid since they do not belong to the currently selected VPC."); + invalidSecurityGroups.Add(securityGroup); + } + + if (invalidSecurityGroups.Any()) + { + return ValidationResult.Failed($"The selected security group(s) ({string.Join(", ", invalidSecurityGroups)}) " + + $"are invalid since they do not belong to the currently selected VPC {vpcId}."); + } + + return ValidationResult.Valid(); + } + + // The Console ECS Fargate Service recipe uses a comma-separated string, which will fall through the TryDeserialize above + if (input is string) + { + // Security groups aren't required + if (string.IsNullOrEmpty(input.ToString())) + { + return ValidationResult.Valid(); + } + + var securityGroupList = input.ToString()?.Split(',') ?? new string[0]; + var invalidSecurityGroups = new List(); + + foreach (var securityGroup in securityGroupList) + { + if (!securityGroupIds.Contains(securityGroup)) + invalidSecurityGroups.Add(securityGroup); + } + + if (invalidSecurityGroups.Any()) + { + return ValidationResult.Failed($"The selected security group(s) ({string.Join(", ", invalidSecurityGroups)}) " + + $"are invalid since they do not belong to the currently selected VPC {vpcId}."); } return ValidationResult.Valid(); diff --git a/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppECSFargate.recipe b/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppECSFargate.recipe index 3516c7a77..5d719ef5e 100644 --- a/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppECSFargate.recipe +++ b/src/AWS.Deploy.Recipes/RecipeDefinitions/ASP.NETAppECSFargate.recipe @@ -382,6 +382,13 @@ "AllowEmptyString": true, "ValidationFailedMessage": "Invalid Security Group ID. The Security Group ID must start with the \"sg-\" prefix, followed by either 8 or 17 characters consisting of digits and letters(lower-case) from a to f. For example sg-abc88de9 is a valid Security Group ID." } + }, + { + "ValidatorType": "SecurityGroupsInVpc", + "Configuration": { + "VpcId": "Vpc.VpcId", + "IsDefaultVpcOptionSettingId": "Vpc.IsDefault" + } } ] }, diff --git a/src/AWS.Deploy.Recipes/RecipeDefinitions/ConsoleAppECSFargateService.recipe b/src/AWS.Deploy.Recipes/RecipeDefinitions/ConsoleAppECSFargateService.recipe index a53c91ca2..3a7a51c79 100644 --- a/src/AWS.Deploy.Recipes/RecipeDefinitions/ConsoleAppECSFargateService.recipe +++ b/src/AWS.Deploy.Recipes/RecipeDefinitions/ConsoleAppECSFargateService.recipe @@ -422,7 +422,16 @@ "Type": "String", "DefaultValue": "", "AdvancedSetting": true, - "Updatable": true + "Updatable": true, + "Validators": [ + { + "ValidatorType": "SecurityGroupsInVpc", + "Configuration": { + "VpcId": "Vpc.VpcId", + "IsDefaultVpcOptionSettingId": "Vpc.IsDefault" + } + } + ] }, { "Id": "TaskCpu", diff --git a/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ECSFargateOptionSettingItemValidationTests.cs b/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ECSFargateOptionSettingItemValidationTests.cs index cdf381939..fba71ba55 100644 --- a/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ECSFargateOptionSettingItemValidationTests.cs +++ b/test/AWS.Deploy.CLI.Common.UnitTests/Recipes/Validation/ECSFargateOptionSettingItemValidationTests.cs @@ -17,6 +17,7 @@ using ResourceNotFoundException = Amazon.CloudControlApi.Model.ResourceNotFoundException; using Task = System.Threading.Tasks.Task; using System.Collections.Generic; +using Amazon.EC2.Model; namespace AWS.Deploy.CLI.Common.UnitTests.Recipes.Validation { @@ -292,6 +293,48 @@ public async Task DockerExecutionDirectory_AbsoluteDoesNotExist() await Validate(optionSettingItem, Path.Join("C:", "other_project"), false); } + /// + /// Tests the relationship between an explicit VPC ID, whether "Default VPC" is checked, + /// and any security groups that are specified. + /// + /// selected VPC Id + /// whether the "Default VPC" radio is selected + /// selected security groups + /// Whether or not the test case is expected to be valid + [Theory] + // The Console Service recipe uses a comma-seperated string of security groups + [InlineData("vpc1", true, "", true)] // Valid because the security groups are optional + [InlineData("vpc1", true, "sg-1a,sg-1b", true)] // Valid because the security group does belong to the default VPC + [InlineData("vpc1", true, "sg-1a,sg-2a", false)] // Invalid because the security group does not belong to the default VPC + [InlineData("vpc2", false, "sg-2a", true)] // Valid because the security group does belong to the non-default VPC + [InlineData("vpc2", false, "sg-1a", false)] // Invalid because the security group does not belong to the non-default VPC + [InlineData("vpc2", true, "sg-1a", true)] // Valid because "true" for IsDefaultVPC overrides the "vpc2", so the security group matches + [InlineData("vpc2", true, "sg-2a", false)] // Invalid because "true" for IsDefaultVPC overrides the "vpc2", so the security group does not match + // + // The ASP.NET on Fargate recipe uses a JSON list of security groups (these are same cases from above) + // + [InlineData("vpc1", true, "[]", true)] + [InlineData("vpc1", true, "[\"sg-1a\",\"sg-1b\"]", true)] + [InlineData("vpc1", true, "[\"sg-1a\",\"sg-2a\"]", false)] + [InlineData("vpc2", false, "[\"sg-2a\"]", true)] + [InlineData("vpc2", false, "[\"sg-1a\"]", false)] + [InlineData("vpc2", true, "[\"sg-1a\"]", true)] + [InlineData("vpc2", true, "[\"sg-2a\"]", false)] + + public async Task VpcId_DefaultVpc_SecurityGroups_Relationship(string vpcId, bool isDefaultVpcSelected, object selectedSecurityGroups, bool isValid) + { + PrepareMockVPCsAndSecurityGroups(_awsResourceQueryer); + + var (vpcIdOption, vpcDefaultOption, securityGroupsOption) = PrepareECSVpcOptions(); + + securityGroupsOption.Validators.Add(GetSecurityGroupsInVpcValidatorConfig(_awsResourceQueryer, _optionSettingHandler)); + + await _optionSettingHandler.SetOptionSettingValue(_recommendation, vpcIdOption, vpcId); + await _optionSettingHandler.SetOptionSettingValue(_recommendation, vpcDefaultOption, isDefaultVpcSelected); + + await Validate(securityGroupsOption, selectedSecurityGroups, isValid); + } + private OptionSettingItemValidatorConfig GetRegexValidatorConfig(string regex) { var regexValidatorConfig = new OptionSettingItemValidatorConfig @@ -387,5 +430,69 @@ private async Task Validate(OptionSettingItem optionSettingItem, T value, boo else exception.ShouldNotBeNull(); } + + /// + /// Prepares a for testing + /// + private OptionSettingItemValidatorConfig GetSecurityGroupsInVpcValidatorConfig(Mock awsResourceQueryer, IOptionSettingHandler optionSettingHandler) + { + var validator = new SecurityGroupsInVpcValidator(awsResourceQueryer.Object, optionSettingHandler); + validator.VpcId = "Vpc.VpcId"; + validator.IsDefaultVpcOptionSettingId = "Vpc.IsDefault"; + + return new OptionSettingItemValidatorConfig + { + ValidatorType = OptionSettingItemValidatorList.SecurityGroupsInVpc, + Configuration = validator + }; + } + + /// + /// Mocks the provided to return the following + /// 1. Default vpc1 with security groups sg-1a and sg-1b + /// 2. Non-default vpc2 with security groups sg-2a and sg-2b + /// + /// Mocked AWS Resource Queryer + private void PrepareMockVPCsAndSecurityGroups(Mock awsResourceQueryer) + { + awsResourceQueryer.Setup(x => x.GetListOfVpcs()).ReturnsAsync( + new List { + new Vpc { VpcId = "vpc1", IsDefault = true }, + new Vpc { VpcId = "vpc2"} + }); + + awsResourceQueryer.Setup(x => x.DescribeSecurityGroups("vpc1")).ReturnsAsync( + new List { + new SecurityGroup { GroupId = "sg-1a", VpcId = "vpc1" }, + new SecurityGroup { GroupId = "sg-1b", VpcId = "vpc1" } + }); + + awsResourceQueryer.Setup(x => x.DescribeSecurityGroups("vpc2")).ReturnsAsync( + new List { + new SecurityGroup { GroupId = "sg-2a", VpcId = "vpc2" }, + new SecurityGroup { GroupId = "sg-2a", VpcId = "vpc2" } + }); + + awsResourceQueryer.Setup(x => x.GetDefaultVpc()).ReturnsAsync(new Vpc { VpcId = "vpc1", IsDefault = true }); + } + + /// + /// Prepares VPC-related options that match the ECS Fargate recipes for testing + /// + /// The "Vpc.VpcId" option, the "Vpc.IsDefault" option, and the "ECSServiceSecurityGroups" option + private (OptionSettingItem, OptionSettingItem, OptionSettingItem) PrepareECSVpcOptions() + { + var vpcIdOption = new OptionSettingItem("VpcId", "Vpc.VpcId", "name", "description"); + var vpcDefaultOption = new OptionSettingItem("IsDefault", "Vpc.IsDefault", "name", "description"); + var ecsServiceSecurityGroupsOption = new OptionSettingItem("ECSServiceSecurityGroups", "ECSServiceSecurityGroups", "name", ""); + + var vpc = new OptionSettingItem("Vpc", "Vpc", "", ""); + vpc.ChildOptionSettings.Add(vpcIdOption); + vpc.ChildOptionSettings.Add(vpcDefaultOption); + + _recipe.OptionSettings.Add(vpc); + + return (vpcIdOption, vpcDefaultOption, ecsServiceSecurityGroupsOption); + } } }