diff --git a/packages/@aws-cdk/aws-ecs/README.md b/packages/@aws-cdk/aws-ecs/README.md index 09a5ad13c14cd..a038cf65fbaf9 100644 --- a/packages/@aws-cdk/aws-ecs/README.md +++ b/packages/@aws-cdk/aws-ecs/README.md @@ -683,6 +683,49 @@ taskDefinition.addContainer('TheContainer', { }); ``` +## CloudMap Service Discovery + +To register your ECS service with a CloudMap Service Registry, you may add the +`cloudMapOptions` property to your service: + +```ts +const service = new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + // Create A records - useful for AWSVPC network mode. + dnsRecordType: cloudmap.DnsRecordType.A, + }, +}); +``` + +With `bridge` or `host` network modes, only `SRV` DNS record types are supported. +By default, `SRV` DNS record types will target the default container and default +port. However, you may target a different container and port on the same ECS task: + +```ts +// Add a container to the task definition +const specificContainer = taskDefinition.addContainer(...); + +// Add a port mapping +specificContainer.addPortMappings({ + containerPort: 7600, + protocol: ecs.Protocol.TCP, +}); + +new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + // Create SRV records - useful for bridge networking + dnsRecordType: cloudmap.DnsRecordType.SRV, + // Targets port TCP port 7600 `specificContainer` + container: specificContainer, + containerPort: 7600, + }, +}); +``` + ## Capacity Providers Currently, only `FARGATE` and `FARGATE_SPOT` capacity providers are supported. 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 8e2483f98838f..1e1fdde585f1e 100644 --- a/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts @@ -8,8 +8,8 @@ import * as cloudmap from '@aws-cdk/aws-servicediscovery'; import { Annotations, Duration, IResolvable, IResource, Lazy, Resource, Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { LoadBalancerTargetOptions, NetworkMode, TaskDefinition } from '../base/task-definition'; +import { ContainerDefinition, Protocol } from '../container-definition'; import { ICluster, CapacityProviderStrategy } from '../cluster'; -import { Protocol } from '../container-definition'; import { CfnService } from '../ecs.generated'; import { ScalableTaskCount } from './scalable-task-count'; @@ -572,10 +572,12 @@ export abstract class BaseService extends Resource } } - // If the task definition that your service task specifies uses the AWSVPC network mode and a type SRV DNS record is - // used, you must specify a containerName and containerPort combination - const containerName = dnsRecordType === cloudmap.DnsRecordType.SRV ? this.taskDefinition.defaultContainer!.containerName : undefined; - const containerPort = dnsRecordType === cloudmap.DnsRecordType.SRV ? this.taskDefinition.defaultContainer!.containerPort : undefined; + const { containerName, containerPort } = determineContainerNameAndPort({ + taskDefinition: this.taskDefinition, + dnsRecordType: dnsRecordType!, + container: options.container, + containerPort: options.containerPort, + }); const cloudmapService = new cloudmap.Service(this, 'CloudmapService', { namespace: sdNamespace, @@ -799,7 +801,19 @@ export interface CloudMapOptions { * * NOTE: This is used for HealthCheckCustomConfig */ - readonly failureThreshold?: number, + readonly failureThreshold?: number; + + /** + * The container to point to for a SRV record. + * @default - the task definition's default container + */ + readonly container?: ContainerDefinition; + + /** + * The port to point to for a SRV record. + * @default - the default port of the task definition's default container + */ + readonly containerPort?: number; } /** @@ -885,3 +899,42 @@ export enum PropagatedTagSource { */ NONE = 'NONE' } + +/** + * Options for `determineContainerNameAndPort` + */ +interface DetermineContainerNameAndPortOptions { + dnsRecordType: cloudmap.DnsRecordType; + taskDefinition: TaskDefinition; + container?: ContainerDefinition; + containerPort?: number; +} + +/** + * Determine the name of the container and port to target for the service registry. + */ +function determineContainerNameAndPort(options: DetermineContainerNameAndPortOptions) { + // If the record type is SRV, then provide the containerName and containerPort to target. + // We use the name of the default container and the default port of the default container + // unless the user specifies otherwise. + if (options.dnsRecordType === cloudmap.DnsRecordType.SRV) { + // Ensure the user-provided container is from the right task definition. + if (options.container && options.container.taskDefinition != options.taskDefinition) { + throw new Error('Cannot add discovery for a container from another task definition'); + } + + const container = options.container ?? options.taskDefinition.defaultContainer!; + + // Ensure that any port given by the user is mapped. + if (options.containerPort && !container.portMappings.some(mapping => mapping.containerPort === options.containerPort)) { + throw new Error('Cannot add discovery for a container port that has not been mapped'); + } + + return { + containerName: container.containerName, + containerPort: options.containerPort ?? options.taskDefinition.defaultContainer!.containerPort, + }; + } + + return {}; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts b/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts index d88c5ecafd1d7..945832a09b869 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts @@ -2190,6 +2190,268 @@ nodeunitShim({ test.done(); }, + + 'user can select any container and port'(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') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'FargateTaskDef', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + const mainContainer = taskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + mainContainer.addPortMappings({ containerPort: 8000 }); + + const otherContainer = taskDefinition.addContainer('OtherContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + otherContainer.addPortMappings({ containerPort: 8001 }); + + // WHEN + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + container: otherContainer, + containerPort: 8001, + }, + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ECS::Service', { + ServiceRegistries: [ + { + RegistryArn: { 'Fn::GetAtt': ['ServiceCloudmapService046058A4', 'Arn'] }, + ContainerName: 'OtherContainer', + ContainerPort: 8001, + }, + ], + })); + + test.done(); + }, + + 'By default, the container name is the default'(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') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Task', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + taskDefinition.addContainer('main', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }).addPortMappings({ containerPort: 1234 }); + + taskDefinition.addContainer('second', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }).addPortMappings({ containerPort: 4321 }); + + // WHEN + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: {}, + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ECS::Service', { + ServiceRegistries: [{ + ContainerName: 'main', + ContainerPort: undefined, + }], + })); + + test.done(); + }, + + 'For SRV, by default, container name is default container and port is the default container port'(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') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Task', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + taskDefinition.addContainer('main', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }).addPortMappings({ containerPort: 1234 }); + + taskDefinition.addContainer('second', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }).addPortMappings({ containerPort: 4321 }); + + // WHEN + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + }, + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ECS::Service', { + ServiceRegistries: [{ + ContainerName: 'main', + ContainerPort: 1234, + }], + })); + + test.done(); + }, + + 'allows SRV service discovery to select the container and port'(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') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Task', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + taskDefinition.addContainer('main', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }).addPortMappings({ containerPort: 1234 }); + + const secondContainer = taskDefinition.addContainer('second', { + image: ecs.ContainerImage.fromRegistry('some'), + memoryLimitMiB: 512, + }); + secondContainer.addPortMappings({ containerPort: 4321 }); + + // WHEN + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + container: secondContainer, + containerPort: 4321, + }, + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ECS::Service', { + ServiceRegistries: [{ + ContainerName: 'second', + ContainerPort: 4321, + }], + })); + + test.done(); + }, + + 'throws if SRV and container is not part of task definition'(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') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Task', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + // The right container + taskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + + const wrongTaskDefinition = new ecs.Ec2TaskDefinition(stack, 'WrongTaskDef'); + // The wrong container + const wrongContainer = wrongTaskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + + // WHEN + test.throws(() => { + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + container: wrongContainer, + containerPort: 4321, + }, + }); + }, /another task definition/i); + + test.done(); + }, + + 'throws if SRV and the container port is not mapped'(test: Test) { + 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') }); + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Task', { + networkMode: ecs.NetworkMode.BRIDGE, + }); + + const container = taskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + + container.addPortMappings({ containerPort: 8000 }); + + test.throws(() => { + new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + container: container, + containerPort: 4321, + }, + }); + }, /container port.*not.*mapped/i); + + test.done(); + }, }, 'Metric'(test: Test) { diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.expected.json b/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.expected.json new file mode 100644 index 0000000000000..e067e7d75a67d --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.expected.json @@ -0,0 +1,808 @@ +{ + "Resources": { + "Vpc8378EB38": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc" + } + ] + } + }, + "VpcpubSubnet1Subnet410C08CF": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/24", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "pub" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/pubSubnet1" + } + ] + } + }, + "VpcpubSubnet1RouteTableE0483FDA": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/pubSubnet1" + } + ] + } + }, + "VpcpubSubnet1RouteTableAssociation68036D8C": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcpubSubnet1RouteTableE0483FDA" + }, + "SubnetId": { + "Ref": "VpcpubSubnet1Subnet410C08CF" + } + } + }, + "VpcpubSubnet1DefaultRouteF020A9EF": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcpubSubnet1RouteTableE0483FDA" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcpubSubnet2Subnet44A37A0D": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.1.0/24", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "pub" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/pubSubnet2" + } + ] + } + }, + "VpcpubSubnet2RouteTable5A29DF40": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/pubSubnet2" + } + ] + } + }, + "VpcpubSubnet2RouteTableAssociationFB826925": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcpubSubnet2RouteTable5A29DF40" + }, + "SubnetId": { + "Ref": "VpcpubSubnet2Subnet44A37A0D" + } + } + }, + "VpcpubSubnet2DefaultRouteE6D48BA4": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcpubSubnet2RouteTable5A29DF40" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcIGWD7BA715C": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc" + } + ] + } + }, + "VpcVPCGWBF912B6E": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "InternetGatewayId": { + "Ref": "VpcIGWD7BA715C" + } + } + }, + "FargateCluster7CCD5F93": { + "Type": "AWS::ECS::Cluster" + }, + "FargateClustercapacityInstanceSecurityGroupCB3AEDA1": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ/FargateCluster/capacity/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "from 0.0.0.0/0:32768-61000", + "FromPort": 32768, + "IpProtocol": "tcp", + "ToPort": 61000 + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "FargateClustercapacityInstanceRoleBE253D2D": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "ec2.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ] + } + }, + "FargateClustercapacityInstanceRoleDefaultPolicy90B38927": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecs:DeregisterContainerInstance", + "ecs:RegisterContainerInstance", + "ecs:Submit*" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "FargateCluster7CCD5F93", + "Arn" + ] + } + }, + { + "Action": [ + "ecs:Poll", + "ecs:StartTelemetrySession" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "FargateCluster7CCD5F93", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecs:DiscoverPollEndpoint", + "ecr:GetAuthorizationToken", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "FargateClustercapacityInstanceRoleDefaultPolicy90B38927", + "Roles": [ + { + "Ref": "FargateClustercapacityInstanceRoleBE253D2D" + } + ] + } + }, + "FargateClustercapacityInstanceProfile8294296C": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "FargateClustercapacityInstanceRoleBE253D2D" + } + ] + } + }, + "FargateClustercapacityLaunchConfig9B95CCB7": { + "Type": "AWS::AutoScaling::LaunchConfiguration", + "Properties": { + "ImageId": { + "Ref": "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter" + }, + "InstanceType": "t3.micro", + "IamInstanceProfile": { + "Ref": "FargateClustercapacityInstanceProfile8294296C" + }, + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "FargateClustercapacityInstanceSecurityGroupCB3AEDA1", + "GroupId" + ] + } + ], + "UserData": { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\necho ECS_CLUSTER=", + { + "Ref": "FargateCluster7CCD5F93" + }, + " >> /etc/ecs/ecs.config\nsudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP\nsudo service iptables save\necho ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config" + ] + ] + } + } + }, + "DependsOn": [ + "FargateClustercapacityInstanceRoleDefaultPolicy90B38927", + "FargateClustercapacityInstanceRoleBE253D2D" + ] + }, + "FargateClustercapacityASGE4034F96": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "MaxSize": "1", + "MinSize": "1", + "DesiredCapacity": "1", + "LaunchConfigurationName": { + "Ref": "FargateClustercapacityLaunchConfig9B95CCB7" + }, + "Tags": [ + { + "Key": "Name", + "PropagateAtLaunch": true, + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ], + "VPCZoneIdentifier": [ + { + "Ref": "VpcpubSubnet1Subnet410C08CF" + }, + { + "Ref": "VpcpubSubnet2Subnet44A37A0D" + } + ] + }, + "UpdatePolicy": { + "AutoScalingReplacingUpdate": { + "WillReplace": true + }, + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } + } + }, + "FargateClustercapacityDrainECSHookFunctionServiceRoleA28505D9": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ] + } + }, + "FargateClustercapacityDrainECSHookFunctionServiceRoleDefaultPolicy53CD1145": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ec2:DescribeInstances", + "ec2:DescribeInstanceAttribute", + "ec2:DescribeInstanceStatus", + "ec2:DescribeHosts" + ], + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "autoscaling:CompleteLifecycleAction", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":autoscaling:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":autoScalingGroup:*:autoScalingGroupName/", + { + "Ref": "FargateClustercapacityASGE4034F96" + } + ] + ] + } + }, + { + "Action": [ + "ecs:DescribeContainerInstances", + "ecs:DescribeTasks" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "FargateCluster7CCD5F93", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecs:ListContainerInstances", + "ecs:SubmitContainerStateChange", + "ecs:SubmitTaskStateChange" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "FargateCluster7CCD5F93", + "Arn" + ] + } + }, + { + "Action": [ + "ecs:UpdateContainerInstancesState", + "ecs:ListTasks" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "FargateCluster7CCD5F93", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "FargateClustercapacityDrainECSHookFunctionServiceRoleDefaultPolicy53CD1145", + "Roles": [ + { + "Ref": "FargateClustercapacityDrainECSHookFunctionServiceRoleA28505D9" + } + ] + } + }, + "FargateClustercapacityDrainECSHookFunction3E60E6D0": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "import boto3, json, os, time\n\necs = boto3.client('ecs')\nautoscaling = boto3.client('autoscaling')\n\n\ndef lambda_handler(event, context):\n print(json.dumps(event))\n cluster = os.environ['CLUSTER']\n snsTopicArn = event['Records'][0]['Sns']['TopicArn']\n lifecycle_event = json.loads(event['Records'][0]['Sns']['Message'])\n instance_id = lifecycle_event.get('EC2InstanceId')\n if not instance_id:\n print('Got event without EC2InstanceId: %s', json.dumps(event))\n return\n\n instance_arn = container_instance_arn(cluster, instance_id)\n print('Instance %s has container instance ARN %s' % (lifecycle_event['EC2InstanceId'], instance_arn))\n\n if not instance_arn:\n return\n\n while has_tasks(cluster, instance_arn):\n time.sleep(10)\n\n try:\n print('Terminating instance %s' % instance_id)\n autoscaling.complete_lifecycle_action(\n LifecycleActionResult='CONTINUE',\n **pick(lifecycle_event, 'LifecycleHookName', 'LifecycleActionToken', 'AutoScalingGroupName'))\n except Exception as e:\n # Lifecycle action may have already completed.\n print(str(e))\n\n\ndef container_instance_arn(cluster, instance_id):\n \"\"\"Turn an instance ID into a container instance ARN.\"\"\"\n arns = ecs.list_container_instances(cluster=cluster, filter='ec2InstanceId==' + instance_id)['containerInstanceArns']\n if not arns:\n return None\n return arns[0]\n\n\ndef has_tasks(cluster, instance_arn):\n \"\"\"Return True if the instance is running tasks for the given cluster.\"\"\"\n instances = ecs.describe_container_instances(cluster=cluster, containerInstances=[instance_arn])['containerInstances']\n if not instances:\n return False\n instance = instances[0]\n\n if instance['status'] == 'ACTIVE':\n # Start draining, then try again later\n set_container_instance_to_draining(cluster, instance_arn)\n return True\n\n tasks = instance['runningTasksCount'] + instance['pendingTasksCount']\n print('Instance %s has %s tasks' % (instance_arn, tasks))\n\n return tasks > 0\n\n\ndef set_container_instance_to_draining(cluster, instance_arn):\n ecs.update_container_instances_state(\n cluster=cluster,\n containerInstances=[instance_arn], status='DRAINING')\n\n\ndef pick(dct, *keys):\n \"\"\"Pick a subset of a dict.\"\"\"\n return {k: v for k, v in dct.items() if k in keys}\n" + }, + "Role": { + "Fn::GetAtt": [ + "FargateClustercapacityDrainECSHookFunctionServiceRoleA28505D9", + "Arn" + ] + }, + "Environment": { + "Variables": { + "CLUSTER": { + "Ref": "FargateCluster7CCD5F93" + } + } + }, + "Handler": "index.lambda_handler", + "Runtime": "python3.6", + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ], + "Timeout": 310 + }, + "DependsOn": [ + "FargateClustercapacityDrainECSHookFunctionServiceRoleDefaultPolicy53CD1145", + "FargateClustercapacityDrainECSHookFunctionServiceRoleA28505D9" + ] + }, + "FargateClustercapacityDrainECSHookFunctionAllowInvokeawsecsintegFargateClustercapacityLifecycleHookDrainHookTopic07C1229F3B6FF246": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "FargateClustercapacityDrainECSHookFunction3E60E6D0", + "Arn" + ] + }, + "Principal": "sns.amazonaws.com", + "SourceArn": { + "Ref": "FargateClustercapacityLifecycleHookDrainHookTopic390A0E34" + } + } + }, + "FargateClustercapacityDrainECSHookFunctionTopic7D6C4884": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Protocol": "lambda", + "TopicArn": { + "Ref": "FargateClustercapacityLifecycleHookDrainHookTopic390A0E34" + }, + "Endpoint": { + "Fn::GetAtt": [ + "FargateClustercapacityDrainECSHookFunction3E60E6D0", + "Arn" + ] + } + } + }, + "FargateClustercapacityLifecycleHookDrainHookRoleDD26E39B": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "autoscaling.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ] + } + }, + "FargateClustercapacityLifecycleHookDrainHookRoleDefaultPolicyACCDDB70": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "FargateClustercapacityLifecycleHookDrainHookTopic390A0E34" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "FargateClustercapacityLifecycleHookDrainHookRoleDefaultPolicyACCDDB70", + "Roles": [ + { + "Ref": "FargateClustercapacityLifecycleHookDrainHookRoleDD26E39B" + } + ] + } + }, + "FargateClustercapacityLifecycleHookDrainHookTopic390A0E34": { + "Type": "AWS::SNS::Topic", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/FargateCluster/capacity" + } + ] + } + }, + "FargateClustercapacityLifecycleHookDrainHook8AEDE53B": { + "Type": "AWS::AutoScaling::LifecycleHook", + "Properties": { + "AutoScalingGroupName": { + "Ref": "FargateClustercapacityASGE4034F96" + }, + "LifecycleTransition": "autoscaling:EC2_INSTANCE_TERMINATING", + "DefaultResult": "CONTINUE", + "HeartbeatTimeout": 300, + "NotificationTargetARN": { + "Ref": "FargateClustercapacityLifecycleHookDrainHookTopic390A0E34" + }, + "RoleARN": { + "Fn::GetAtt": [ + "FargateClustercapacityLifecycleHookDrainHookRoleDD26E39B", + "Arn" + ] + } + }, + "DependsOn": [ + "FargateClustercapacityLifecycleHookDrainHookRoleDefaultPolicyACCDDB70", + "FargateClustercapacityLifecycleHookDrainHookRoleDD26E39B" + ] + }, + "FargateClusterDefaultServiceDiscoveryNamespace04381E1E": { + "Type": "AWS::ServiceDiscovery::PrivateDnsNamespace", + "Properties": { + "Name": "aws-ecs-integ", + "Vpc": { + "Ref": "Vpc8378EB38" + } + } + }, + "TaskDefTaskRole1EDB4A67": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "TaskDef54694570": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "Essential": true, + "Image": "nginx", + "Memory": 512, + "MemoryReservation": 32, + "Name": "nginx", + "PortMappings": [ + { + "ContainerPort": 80, + "HostPort": 0, + "Protocol": "tcp" + } + ] + }, + { + "Environment": [ + { + "Name": "PORT", + "Value": "81" + } + ], + "Essential": true, + "Image": "nathanpeck/name", + "Memory": 512, + "MemoryReservation": 32, + "Name": "name", + "PortMappings": [ + { + "ContainerPort": 81, + "HostPort": 0, + "Protocol": "tcp" + } + ] + } + ], + "Family": "awsecsintegTaskDef6FDFB69A", + "NetworkMode": "bridge", + "RequiresCompatibilities": [ + "EC2" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "TaskDefTaskRole1EDB4A67", + "Arn" + ] + } + } + }, + "ServiceD69D759B": { + "Type": "AWS::ECS::Service", + "Properties": { + "Cluster": { + "Ref": "FargateCluster7CCD5F93" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 50 + }, + "DesiredCount": 3, + "EnableECSManagedTags": false, + "LaunchType": "EC2", + "SchedulingStrategy": "REPLICA", + "ServiceRegistries": [ + { + "ContainerName": "name", + "ContainerPort": 81, + "RegistryArn": { + "Fn::GetAtt": [ + "ServiceCloudmapService046058A4", + "Arn" + ] + } + } + ], + "TaskDefinition": { + "Ref": "TaskDef54694570" + } + } + }, + "ServiceCloudmapService046058A4": { + "Type": "AWS::ServiceDiscovery::Service", + "Properties": { + "DnsConfig": { + "DnsRecords": [ + { + "TTL": 60, + "Type": "SRV" + } + ], + "NamespaceId": { + "Fn::GetAtt": [ + "FargateClusterDefaultServiceDiscoveryNamespace04381E1E", + "Id" + ] + }, + "RoutingPolicy": "MULTIVALUE" + }, + "HealthCheckCustomConfig": { + "FailureThreshold": 1 + }, + "NamespaceId": { + "Fn::GetAtt": [ + "FargateClusterDefaultServiceDiscoveryNamespace04381E1E", + "Id" + ] + } + } + } + }, + "Parameters": { + "SsmParameterValueawsserviceecsoptimizedamiamazonlinux2recommendedimageidC96584B6F00A464EAD1953AFF4B05118Parameter": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/aws/service/ecs/optimized-ami/amazon-linux-2/recommended/image_id" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.ts b/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.ts new file mode 100644 index 0000000000000..6c1fadbb23666 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.cloudmap-container-port.ts @@ -0,0 +1,70 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as cloudmap from '@aws-cdk/aws-servicediscovery'; +import * as cdk from '@aws-cdk/core'; +import * as ecs from '../../lib'; + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-ecs-integ'); +const vpc = new ec2.Vpc(stack, 'Vpc', { + maxAzs: 2, + subnetConfiguration: [ + { + name: 'pub', + cidrMask: 24, + subnetType: ec2.SubnetType.PUBLIC, + }, + ], +}); +const cluster = new ecs.Cluster(stack, 'FargateCluster', { vpc }); + +const capacity = cluster.addCapacity('capacity', { + instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO), + desiredCapacity: 1, + minCapacity: 1, + maxCapacity: 1, +}); +capacity.connections.allowFromAnyIpv4(ec2.Port.tcpRange(32768, 61000)); + +cluster.addDefaultCloudMapNamespace({ name: 'aws-ecs-integ' }); + +const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', {}); + +// Main container +const mainContainer = taskDefinition.addContainer('nginx', { + image: ecs.ContainerImage.fromRegistry('nginx'), + memoryReservationMiB: 32, + memoryLimitMiB: 512, +}); + +mainContainer.addPortMappings({ + containerPort: 80, + protocol: ecs.Protocol.TCP, +}); + +// Name container with SRV +const nameContainer = taskDefinition.addContainer('name', { + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + environment: { + PORT: '81', + }, + memoryReservationMiB: 32, + memoryLimitMiB: 512, +}); + +nameContainer.addPortMappings({ + containerPort: 81, + protocol: ecs.Protocol.TCP, +}); + +new ecs.Ec2Service(stack, 'Service', { + cluster, + taskDefinition, + desiredCount: 3, + cloudMapOptions: { + container: nameContainer, + containerPort: 81, + dnsRecordType: cloudmap.DnsRecordType.SRV, + }, +}); + +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts b/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts index c7aa1fc633a1d..c982920225214 100644 --- a/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts +++ b/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts @@ -1834,6 +1834,52 @@ nodeunitShim({ test.done(); }, + + 'user can select any container and port'(test: Test) { + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + + cluster.addDefaultCloudMapNamespace({ + name: 'foo.com', + type: cloudmap.NamespaceType.DNS_PRIVATE, + }); + + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + const mainContainer = taskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + mainContainer.addPortMappings({ containerPort: 8000 }); + + const otherContainer = taskDefinition.addContainer('OtherContainer', { + image: ecs.ContainerImage.fromRegistry('hello'), + memoryLimitMiB: 512, + }); + otherContainer.addPortMappings({ containerPort: 8001 }); + + new ecs.FargateService(stack, 'Service', { + cluster, + taskDefinition, + cloudMapOptions: { + dnsRecordType: cloudmap.DnsRecordType.SRV, + container: otherContainer, + containerPort: 8001, + }, + }); + + expect(stack).to(haveResourceLike('AWS::ECS::Service', { + ServiceRegistries: [ + { + RegistryArn: { 'Fn::GetAtt': ['ServiceCloudmapService046058A4', 'Arn'] }, + ContainerName: 'OtherContainer', + ContainerPort: 8001, + }, + ], + })); + + test.done(); + }, }, 'Metric'(test: Test) {