diff --git a/allowed-breaking-changes.txt b/allowed-breaking-changes.txt index 48d35a18122ba..a7f762f73238c 100644 --- a/allowed-breaking-changes.txt +++ b/allowed-breaking-changes.txt @@ -39,4 +39,6 @@ change-return-type:@aws-cdk/aws-lambda-destinations.EventBridgeDestination.bind change-return-type:@aws-cdk/aws-lambda-destinations.LambdaDestination.bind change-return-type:@aws-cdk/aws-lambda-destinations.SnsDestination.bind change-return-type:@aws-cdk/aws-lambda-destinations.SqsDestination.bind - +incompatible-argument:@aws-cdk/aws-ecs.BaseService.configureAwsVpcNetworking +incompatible-argument:@aws-cdk/aws-ecs.Ec2Service.configureAwsVpcNetworking +incompatible-argument:@aws-cdk/aws-ecs.FargateService.configureAwsVpcNetworking \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts index abd4807e7e960..2c33ba3df2521 100644 --- a/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts @@ -565,20 +565,21 @@ export abstract class BaseService extends Resource * This method is called to create a networkConfiguration. */ // tslint:disable-next-line:max-line-length - protected configureAwsVpcNetworking(vpc: ec2.IVpc, assignPublicIp?: boolean, vpcSubnets?: ec2.SubnetSelection, securityGroup?: ec2.ISecurityGroup) { + protected configureAwsVpcNetworking(vpc: ec2.IVpc, assignPublicIp?: boolean, vpcSubnets?: ec2.SubnetSelection, securityGroups?: ec2.ISecurityGroup[]) { if (vpcSubnets === undefined) { vpcSubnets = { subnetType: assignPublicIp ? ec2.SubnetType.PUBLIC : ec2.SubnetType.PRIVATE }; } - if (securityGroup === undefined) { - securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', { vpc }); + if (securityGroups === undefined || securityGroups.length === 0) { + securityGroups = [ new ec2.SecurityGroup(this, 'SecurityGroup', { vpc }) ]; } - this.connections.addSecurityGroup(securityGroup); + + securityGroups.forEach((sg) => { this.connections.addSecurityGroup(sg); }, this); this.networkConfiguration = { awsvpcConfiguration: { assignPublicIp: assignPublicIp ? 'ENABLED' : 'DISABLED', subnets: vpc.selectSubnets(vpcSubnets).subnetIds, - securityGroups: Lazy.listValue({ produce: () => [securityGroup!.securityGroupId] }), + securityGroups: securityGroups.map((sg) => sg.securityGroupId) } }; } diff --git a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts index 13877c0785126..98a2e70a5a8b5 100644 --- a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts @@ -41,9 +41,19 @@ export interface Ec2ServiceProps extends BaseServiceOptions { * This property is only used for tasks that use the awsvpc network mode. * * @default - A new security group is created. + * @deprecated use securityGroups instead */ readonly securityGroup?: ec2.ISecurityGroup; + /** + * The security groups to associate with the service. If you do not specify a security group, the default security group for the VPC is used. + * + * This property is only used for tasks that use the awsvpc network mode. + * + * @default - A new security group is created + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + /** * The placement constraints to use for tasks in the service. For more information, see * [Amazon ECS Task Placement Constraints](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-placement-constraints.html). @@ -108,6 +118,11 @@ export class Ec2Service extends BaseService implements IEc2Service { private readonly strategies: CfnService.PlacementStrategyProperty[]; private readonly daemon: boolean; + /** + * The security groups associated to the task. + */ + private readonly securityGroups?: ec2.ISecurityGroup[]; + /** * Constructs a new instance of the Ec2Service class. */ @@ -132,6 +147,10 @@ export class Ec2Service extends BaseService implements IEc2Service { throw new Error('You can only specify either propagateTags or propagateTaskTagsFrom. Alternatively, you can leave both blank'); } + if (props.securityGroup !== undefined && props.securityGroups !== undefined) { + throw new Error("Only one of SecurityGroup or SecurityGroups can be populated."); + } + const propagateTagsFromSource = props.propagateTaskTagsFrom !== undefined ? props.propagateTaskTagsFrom : (props.propagateTags !== undefined ? props.propagateTags : PropagatedTagSource.NONE); @@ -157,8 +176,14 @@ export class Ec2Service extends BaseService implements IEc2Service { this.strategies = []; this.daemon = props.daemon || false; + if (props.securityGroup !== undefined) { + this.securityGroups = [ props.securityGroup ]; + } else if (props.securityGroups !== undefined) { + this.securityGroups = props.securityGroups; + } + if (props.taskDefinition.networkMode === NetworkMode.AWS_VPC) { - this.configureAwsVpcNetworking(props.cluster.vpc, props.assignPublicIp, props.vpcSubnets, props.securityGroup); + this.configureAwsVpcNetworking(props.cluster.vpc, props.assignPublicIp, props.vpcSubnets, this.securityGroups); } else { // Either None, Bridge or Host networking. Copy SecurityGroups from ASG. // We have to be smart here -- by default future Security Group rules would be created @@ -220,7 +245,7 @@ export class Ec2Service extends BaseService implements IEc2Service { * Validate combinations of networking arguments */ function validateNoNetworkingProps(props: Ec2ServiceProps) { - if (props.vpcSubnets !== undefined || props.securityGroup !== undefined || props.assignPublicIp) { + if (props.vpcSubnets !== undefined || props.securityGroups !== undefined || props.assignPublicIp) { throw new Error('vpcSubnets, securityGroup and assignPublicIp can only be used in AwsVpc networking mode'); } } diff --git a/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts b/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts index d2c064df93d6f..81699d4e74e86 100644 --- a/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts @@ -34,9 +34,17 @@ export interface FargateServiceProps extends BaseServiceOptions { * The security groups to associate with the service. If you do not specify a security group, the default security group for the VPC is used. * * @default - A new security group is created. + * @deprecated use securityGroups instead */ readonly securityGroup?: ec2.ISecurityGroup; + /** + * The security groups to associate with the service. If you do not specify a security group, the default security group for the VPC is used. + * + * @default - A new security group is created + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + /** * The platform version on which to run your service. * @@ -82,6 +90,11 @@ export class FargateService extends BaseService implements IFargateService { return new Import(scope, id); } + /** + * The security groups associated to the task. + */ + private readonly securityGroups?: ec2.ISecurityGroup[]; + /** * Constructs a new instance of the FargateService class. */ @@ -94,6 +107,10 @@ export class FargateService extends BaseService implements IFargateService { throw new Error('You can only specify either propagateTags or propagateTaskTagsFrom. Alternatively, you can leave both blank'); } + if (props.securityGroup !== undefined && props.securityGroups !== undefined) { + throw new Error("Only one of SecurityGroup or SecurityGroups can be populated."); + } + const propagateTagsFromSource = props.propagateTaskTagsFrom !== undefined ? props.propagateTaskTagsFrom : (props.propagateTags !== undefined ? props.propagateTags : PropagatedTagSource.NONE); @@ -109,7 +126,13 @@ export class FargateService extends BaseService implements IFargateService { platformVersion: props.platformVersion, }, props.taskDefinition); - this.configureAwsVpcNetworking(props.cluster.vpc, props.assignPublicIp, props.vpcSubnets, props.securityGroup); + if (props.securityGroup !== undefined) { + this.securityGroups = [ props.securityGroup ]; + } else if (props.securityGroups !== undefined) { + this.securityGroups = props.securityGroups; + } + + this.configureAwsVpcNetworking(props.cluster.vpc, props.assignPublicIp, props.vpcSubnets, this.securityGroups); if (!props.taskDefinition.defaultContainer) { throw new Error('A TaskDefinition must have at least one essential container'); diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts index 2cc7349ac6545..47064b6455141 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts @@ -237,6 +237,223 @@ export = { test.done(); }, + "with multiple securty groups, it correctly updates cloudformation template"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef', { + networkMode: ecs.NetworkMode.AWS_VPC + }); + const securityGroup1 = new ec2.SecurityGroup(stack, 'SecurityGroup1', { + allowAllOutbound: true, + description: 'Example', + securityGroupName: 'Bingo', + vpc, + }); + const securityGroup2 = new ec2.SecurityGroup(stack, 'SecurityGroup2', { + allowAllOutbound: false, + description: 'Example', + securityGroupName: 'Rolly', + vpc, + }); + + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE + }); + + taskDefinition.addContainer("web", { + image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512, + }); + + // WHEN + const service = new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + desiredCount: 2, + assignPublicIp: true, + cloudMapOptions: { + name: "myapp", + dnsRecordType: cloudmap.DnsRecordType.A, + dnsTtl: cdk.Duration.seconds(50), + failureThreshold: 20 + }, + daemon: false, + healthCheckGracePeriod: cdk.Duration.seconds(60), + maxHealthyPercent: 150, + minHealthyPercent: 55, + securityGroups: [ securityGroup1, securityGroup2 ], + serviceName: "bonjour", + vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC } + }); + + service.addPlacementConstraints(PlacementConstraint.memberOf("attribute:ecs.instance-type =~ t2.*")); + service.addPlacementStrategies(PlacementStrategy.spreadAcross(ecs.BuiltInAttributes.AVAILABILITY_ZONE)); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + TaskDefinition: { + Ref: "Ec2TaskDef0226F28C" + }, + Cluster: { + Ref: "EcsCluster97242B84" + }, + DeploymentConfiguration: { + MaximumPercent: 150, + MinimumHealthyPercent: 55 + }, + DesiredCount: 2, + LaunchType: LaunchType.EC2, + NetworkConfiguration: { + AwsvpcConfiguration: { + AssignPublicIp: "ENABLED", + SecurityGroups: [ + { + "Fn::GetAtt": [ + "SecurityGroup1F554B36F", + "GroupId" + ] + }, + { + "Fn::GetAtt": [ + "SecurityGroup23BE86BB7", + "GroupId" + ] + } + ], + Subnets: [ + { + Ref: "MyVpcPublicSubnet1SubnetF6608456" + }, + { + Ref: "MyVpcPublicSubnet2Subnet492B6BFB" + } + ] + } + }, + PlacementConstraints: [ + { + Expression: "attribute:ecs.instance-type =~ t2.*", + Type: "memberOf" + } + ], + PlacementStrategies: [ + { + Field: "attribute:ecs.availability-zone", + Type: "spread" + } + ], + SchedulingStrategy: "REPLICA", + ServiceName: "bonjour", + ServiceRegistries: [ + { + RegistryArn: { + "Fn::GetAtt": [ + "Ec2ServiceCloudmapService45B52C0F", + "Arn" + ] + } + } + ] + })); + + expect(stack).to(haveResource("AWS::EC2::SecurityGroup", { + GroupDescription: "Example", + GroupName: "Bingo", + SecurityGroupEgress: [ + { + CidrIp: "0.0.0.0/0", + Description: "Allow all outbound traffic by default", + IpProtocol: "-1" + } + ], + VpcId: { + Ref: "MyVpcF9F0CA6F" + } + })); + + expect(stack).to(haveResource("AWS::EC2::SecurityGroup", { + GroupDescription: "Example", + GroupName: "Rolly", + SecurityGroupEgress: [ + { + CidrIp: "255.255.255.255/32", + Description: "Disallow all traffic", + FromPort: 252, + IpProtocol: "icmp", + ToPort: 86 + } + ], + VpcId: { + Ref: "MyVpcF9F0CA6F" + } + })); + + test.done(); + }, + + "throws when both securityGroup and securityGroups are supplied"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef', { + networkMode: ecs.NetworkMode.AWS_VPC + }); + const securityGroup1 = new ec2.SecurityGroup(stack, 'SecurityGroup1', { + allowAllOutbound: true, + description: 'Example', + securityGroupName: 'Bingo', + vpc, + }); + const securityGroup2 = new ec2.SecurityGroup(stack, 'SecurityGroup2', { + allowAllOutbound: false, + description: 'Example', + securityGroupName: 'Rolly', + vpc, + }); + + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE + }); + + taskDefinition.addContainer("web", { + image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512, + }); + + // THEN + test.throws(() => { + new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + desiredCount: 2, + assignPublicIp: true, + cloudMapOptions: { + name: "myapp", + dnsRecordType: cloudmap.DnsRecordType.A, + dnsTtl: cdk.Duration.seconds(50), + failureThreshold: 20 + }, + daemon: false, + healthCheckGracePeriod: cdk.Duration.seconds(60), + maxHealthyPercent: 150, + minHealthyPercent: 55, + securityGroup: securityGroup1, + securityGroups: [ securityGroup2 ], + serviceName: "bonjour", + vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC } + }); + }, /Only one of SecurityGroup or SecurityGroups can be populated./); + + test.done(); + }, + "throws when task definition is not EC2 compatible"(test: Test) { const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'MyVpc', {}); diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts b/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts index 6c08eab562333..f7487a895ee7e 100644 --- a/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts @@ -346,6 +346,151 @@ export = { test.done(); }, + + "throws when securityGroup and securityGroups are supplied"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + const securityGroup1 = new ec2.SecurityGroup(stack, 'SecurityGroup1', { + allowAllOutbound: true, + description: 'Example', + securityGroupName: 'Bingo', + vpc, + }); + const securityGroup2 = new ec2.SecurityGroup(stack, 'SecurityGroup2', { + allowAllOutbound: false, + description: 'Example', + securityGroupName: 'Rolly', + vpc, + }); + + taskDefinition.addContainer("web", { + image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"), + }); + + // THEN + test.throws(() => { + new ecs.FargateService(stack, "FargateService", { + cluster, + taskDefinition, + securityGroup: securityGroup1, + securityGroups: [ securityGroup2 ] + }); + }, /Only one of SecurityGroup or SecurityGroups can be populated./); + + test.done(); + }, + + "with multiple securty groups, it correctly updates cloudformation template"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + const securityGroup1 = new ec2.SecurityGroup(stack, 'SecurityGroup1', { + allowAllOutbound: true, + description: 'Example', + securityGroupName: 'Bingo', + vpc, + }); + const securityGroup2 = new ec2.SecurityGroup(stack, 'SecurityGroup2', { + allowAllOutbound: false, + description: 'Example', + securityGroupName: 'Rolly', + vpc, + }); + + taskDefinition.addContainer("web", { + image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"), + }); + + new ecs.FargateService(stack, "FargateService", { + cluster, + taskDefinition, + securityGroups: [ securityGroup1, securityGroup2 ] + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + TaskDefinition: { + Ref: "FargateTaskDefC6FB60B4" + }, + Cluster: { + Ref: "EcsCluster97242B84" + }, + DeploymentConfiguration: { + MaximumPercent: 200, + MinimumHealthyPercent: 50 + }, + DesiredCount: 1, + LaunchType: LaunchType.FARGATE, + EnableECSManagedTags: false, + NetworkConfiguration: { + AwsvpcConfiguration: { + AssignPublicIp: "DISABLED", + SecurityGroups: [ + { + "Fn::GetAtt": [ + "SecurityGroup1F554B36F", + "GroupId" + ] + }, + { + "Fn::GetAtt": [ + "SecurityGroup23BE86BB7", + "GroupId" + ] + } + ], + Subnets: [ + { + Ref: "MyVpcPrivateSubnet1Subnet5057CF7E" + }, + { + Ref: "MyVpcPrivateSubnet2Subnet0040C983" + }, + ] + } + } + })); + + expect(stack).to(haveResource("AWS::EC2::SecurityGroup", { + GroupDescription: "Example", + GroupName: "Bingo", + SecurityGroupEgress: [ + { + CidrIp: "0.0.0.0/0", + Description: "Allow all outbound traffic by default", + IpProtocol: "-1" + } + ], + VpcId: { + Ref: "MyVpcF9F0CA6F" + } + })); + + expect(stack).to(haveResource("AWS::EC2::SecurityGroup", { + GroupDescription: "Example", + GroupName: "Rolly", + SecurityGroupEgress: [ + { + CidrIp: "255.255.255.255/32", + Description: "Disallow all traffic", + FromPort: 252, + IpProtocol: "icmp", + ToPort: 86 + } + ], + VpcId: { + Ref: "MyVpcF9F0CA6F" + } + })); + + test.done(); + }, + }, "When setting up a health check": {