diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index 5c6e910e4507e..5b9da054a9fe2 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -126,14 +126,9 @@ export class Cluster extends Resource implements ICluster { public readonly clusterName: string; /** - * The cluster-level (FARGATE, FARGATE_SPOT) capacity providers. + * The names of both ASG and Fargate capacity providers associated with the cluster. */ - private _fargateCapacityProviders: string[] = []; - - /** - * The EC2 Auto Scaling Group capacity providers associated with the cluster. - */ - private _asgCapacityProviders: AsgCapacityProvider[] = []; + private _capacityProviderNames: string[] = []; /** * The AWS Cloud Map namespace to associate with the cluster. @@ -173,7 +168,7 @@ export class Cluster extends Resource implements ICluster { clusterSettings = [{ name: 'containerInsights', value: props.containerInsights ? ContainerInsights.ENABLED : ContainerInsights.DISABLED }]; } - this._fargateCapacityProviders = props.capacityProviders ?? []; + this._capacityProviderNames = props.capacityProviders ?? []; if (props.enableFargateCapacityProviders) { this.enableFargateCapacityProviders(); } @@ -189,7 +184,6 @@ export class Cluster extends Resource implements ICluster { const cluster = new CfnCluster(this, 'Resource', { clusterName: this.physicalName, clusterSettings, - capacityProviders: Lazy.list({ produce: () => this._fargateCapacityProviders }, { omitEmpty: true }), configuration: this._executeCommandConfiguration && this.renderExecuteCommandConfiguration(), }); @@ -216,7 +210,7 @@ export class Cluster extends Resource implements ICluster { // since it's harmless, but we'd prefer not to add unexpected new // resources to the stack which could surprise users working with // brown-field CDK apps and stacks. - Aspects.of(this).add(new MaybeCreateCapacityProviderAssociations(this, id, this._asgCapacityProviders)); + Aspects.of(this).add(new MaybeCreateCapacityProviderAssociations(this, id, this._capacityProviderNames)); } /** @@ -224,8 +218,8 @@ export class Cluster extends Resource implements ICluster { */ public enableFargateCapacityProviders() { for (const provider of ['FARGATE', 'FARGATE_SPOT']) { - if (!this._fargateCapacityProviders.includes(provider)) { - this._fargateCapacityProviders.push(provider); + if (!this._capacityProviderNames.includes(provider)) { + this._capacityProviderNames.push(provider); } } } @@ -329,7 +323,7 @@ export class Cluster extends Resource implements ICluster { */ public addAsgCapacityProvider(provider: AsgCapacityProvider, options: AddAutoScalingGroupCapacityOptions = {}) { // Don't add the same capacity provider more than once. - if (this._asgCapacityProviders.includes(provider)) { + if (this._capacityProviderNames.includes(provider.capacityProviderName)) { return; } @@ -340,7 +334,7 @@ export class Cluster extends Resource implements ICluster { taskDrainTime: provider.enableManagedTerminationProtection ? Duration.seconds(0) : options.taskDrainTime, }); - this._asgCapacityProviders.push(provider); + this._capacityProviderNames.push(provider.capacityProviderName); } /** @@ -462,8 +456,8 @@ export class Cluster extends Resource implements ICluster { throw new Error('CapacityProvider not supported'); } - if (!this._fargateCapacityProviders.includes(provider)) { - this._fargateCapacityProviders.push(provider); + if (!this._capacityProviderNames.includes(provider)) { + this._capacityProviderNames.push(provider); } } @@ -1340,22 +1334,24 @@ export class AsgCapacityProvider extends CoreConstruct { class MaybeCreateCapacityProviderAssociations implements IAspect { private scope: CoreConstruct; private id: string; - private capacityProviders: AsgCapacityProvider[] + private capacityProviders: string[] + private resource?: CfnClusterCapacityProviderAssociations - constructor(scope: CoreConstruct, id: string, capacityProviders: AsgCapacityProvider[]) { + constructor(scope: CoreConstruct, id: string, capacityProviders: string[] ) { this.scope = scope; this.id = id; this.capacityProviders = capacityProviders; } + public visit(node: IConstruct): void { if (node instanceof Cluster) { - const providers = this.capacityProviders.map(p => p.capacityProviderName).filter(p => p !== 'FARGATE' && p !== 'FARGATE_SPOT'); - if (providers.length > 0) { - new CfnClusterCapacityProviderAssociations(this.scope, this.id, { + if (this.capacityProviders.length > 0 && !this.resource) { + const resource = new CfnClusterCapacityProviderAssociations(this.scope, this.id, { cluster: node.clusterName, defaultCapacityProviderStrategy: [], - capacityProviders: Lazy.list({ produce: () => providers }), + capacityProviders: Lazy.list({ produce: () => this.capacityProviders }), }); + this.resource = resource; } } } diff --git a/packages/@aws-cdk/aws-ecs/test/cluster.test.ts b/packages/@aws-cdk/aws-ecs/test/cluster.test.ts index a580cb5dca197..cd144f87bfb12 100644 --- a/packages/@aws-cdk/aws-ecs/test/cluster.test.ts +++ b/packages/@aws-cdk/aws-ecs/test/cluster.test.ts @@ -1709,6 +1709,10 @@ nodeunitShim({ // THEN expect(stack).to(haveResource('AWS::ECS::Cluster', { + CapacityProviders: ABSENT, + })); + + expect(stack).to(haveResource('AWS::ECS::ClusterCapacityProviderAssociations', { CapacityProviders: ['FARGATE_SPOT'], })); @@ -1727,6 +1731,10 @@ nodeunitShim({ // THEN expect(stack).to(haveResource('AWS::ECS::Cluster', { + CapacityProviders: ABSENT, + })); + + expect(stack).to(haveResource('AWS::ECS::ClusterCapacityProviderAssociations', { CapacityProviders: ['FARGATE', 'FARGATE_SPOT'], })); @@ -1744,6 +1752,10 @@ nodeunitShim({ // THEN expect(stack).to(haveResource('AWS::ECS::Cluster', { + CapacityProviders: ABSENT, + })); + + expect(stack).to(haveResource('AWS::ECS::ClusterCapacityProviderAssociations', { CapacityProviders: ['FARGATE', 'FARGATE_SPOT'], })); @@ -1762,6 +1774,10 @@ nodeunitShim({ // THEN expect(stack).to(haveResource('AWS::ECS::Cluster', { + CapacityProviders: ABSENT, + })); + + expect(stack).to(haveResource('AWS::ECS::ClusterCapacityProviderAssociations', { CapacityProviders: ['FARGATE'], })); @@ -1780,6 +1796,10 @@ nodeunitShim({ // THEN expect(stack).to(haveResource('AWS::ECS::Cluster', { + CapacityProviders: ABSENT, + })); + + expect(stack).to(haveResource('AWS::ECS::ClusterCapacityProviderAssociations', { CapacityProviders: ['FARGATE'], })); @@ -1928,7 +1948,6 @@ nodeunitShim({ enableManagedTerminationProtection: false, }); - // These should not be added at the association level cluster.enableFargateCapacityProviders(); // Ensure not added twice @@ -1941,6 +1960,8 @@ nodeunitShim({ Ref: 'EcsCluster97242B84', }, CapacityProviders: [ + 'FARGATE', + 'FARGATE_SPOT', { Ref: 'providerD3FF4D3A', }, diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.capacity-provider.expected.json b/packages/@aws-cdk/aws-ecs/test/ec2/integ.capacity-provider.expected.json index d4e008750fe93..555d9acc46764 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/integ.capacity-provider.expected.json +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.capacity-provider.expected.json @@ -362,6 +362,8 @@ "Type": "AWS::ECS::ClusterCapacityProviderAssociations", "Properties": { "CapacityProviders": [ + "FARGATE", + "FARGATE_SPOT", { "Ref": "EC2CapacityProvider5A2E35CD" } @@ -582,7 +584,6 @@ "LaunchConfigurationName": { "Ref": "ASGLaunchConfigC00AF12B" }, - "NewInstancesProtectedFromScaleIn": true, "Tags": [ { "Key": "Name", @@ -605,6 +606,292 @@ } } }, + "ASGDrainECSHookFunctionServiceRoleC12963BB": { + "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": "integ-ec2-capacity-provider/ASG" + } + ] + } + }, + "ASGDrainECSHookFunctionServiceRoleDefaultPolicy16848A27": { + "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": "ASG46ED3070" + } + ] + ] + } + }, + { + "Action": [ + "ecs:DescribeContainerInstances", + "ecs:DescribeTasks" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "EC2CPClusterD5F0FD32", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecs:ListContainerInstances", + "ecs:SubmitContainerStateChange", + "ecs:SubmitTaskStateChange" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "EC2CPClusterD5F0FD32", + "Arn" + ] + } + }, + { + "Action": [ + "ecs:UpdateContainerInstancesState", + "ecs:ListTasks" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "EC2CPClusterD5F0FD32", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ASGDrainECSHookFunctionServiceRoleDefaultPolicy16848A27", + "Roles": [ + { + "Ref": "ASGDrainECSHookFunctionServiceRoleC12963BB" + } + ] + } + }, + "ASGDrainECSHookFunction5F24CF4D": { + "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 task_arns = container_instance_task_arns(cluster, instance_arn)\n \n if task_arns:\n print('Instance ARN %s has task ARNs %s' % (instance_arn, ', '.join(task_arns)))\n\n while has_tasks(cluster, instance_arn, task_arns):\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\ndef container_instance_task_arns(cluster, instance_arn):\n \"\"\"Fetch tasks for a container instance ARN.\"\"\"\n arns = ecs.list_tasks(cluster=cluster, containerInstance=instance_arn)['taskArns']\n return arns\n\ndef has_tasks(cluster, instance_arn, task_arns):\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 task_count = None\n\n if task_arns:\n # Fetch details for tasks running on the container instance\n tasks = ecs.describe_tasks(cluster=cluster, tasks=task_arns)['tasks']\n if tasks:\n # Consider any non-stopped tasks as running\n task_count = sum(task['lastStatus'] != 'STOPPED' for task in tasks) + instance['pendingTasksCount']\n \n if not task_count:\n # Fallback to instance task counts if detailed task information is unavailable\n task_count = instance['runningTasksCount'] + instance['pendingTasksCount']\n \n print('Instance %s has %s tasks' % (instance_arn, task_count))\n\n return task_count > 0\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": [ + "ASGDrainECSHookFunctionServiceRoleC12963BB", + "Arn" + ] + }, + "Environment": { + "Variables": { + "CLUSTER": { + "Ref": "EC2CPClusterD5F0FD32" + } + } + }, + "Handler": "index.lambda_handler", + "Runtime": "python3.6", + "Tags": [ + { + "Key": "Name", + "Value": "integ-ec2-capacity-provider/ASG" + } + ], + "Timeout": 310 + }, + "DependsOn": [ + "ASGDrainECSHookFunctionServiceRoleDefaultPolicy16848A27", + "ASGDrainECSHookFunctionServiceRoleC12963BB" + ] + }, + "ASGDrainECSHookFunctionAllowInvokeintegec2capacityproviderASGLifecycleHookDrainHookTopic4714B3C1EB63E78F": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "ASGDrainECSHookFunction5F24CF4D", + "Arn" + ] + }, + "Principal": "sns.amazonaws.com", + "SourceArn": { + "Ref": "ASGLifecycleHookDrainHookTopicA8AD4ACB" + } + } + }, + "ASGDrainECSHookFunctionTopicD6FC59F7": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Protocol": "lambda", + "TopicArn": { + "Ref": "ASGLifecycleHookDrainHookTopicA8AD4ACB" + }, + "Endpoint": { + "Fn::GetAtt": [ + "ASGDrainECSHookFunction5F24CF4D", + "Arn" + ] + } + } + }, + "ASGLifecycleHookDrainHookRoleD640316C": { + "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": "integ-ec2-capacity-provider/ASG" + } + ] + } + }, + "ASGLifecycleHookDrainHookRoleDefaultPolicy3EEFDE57": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "ASGLifecycleHookDrainHookTopicA8AD4ACB" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "ASGLifecycleHookDrainHookRoleDefaultPolicy3EEFDE57", + "Roles": [ + { + "Ref": "ASGLifecycleHookDrainHookRoleD640316C" + } + ] + } + }, + "ASGLifecycleHookDrainHookTopicA8AD4ACB": { + "Type": "AWS::SNS::Topic", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "integ-ec2-capacity-provider/ASG" + } + ] + } + }, + "ASGLifecycleHookDrainHookFE4AFEBE": { + "Type": "AWS::AutoScaling::LifecycleHook", + "Properties": { + "AutoScalingGroupName": { + "Ref": "ASG46ED3070" + }, + "LifecycleTransition": "autoscaling:EC2_INSTANCE_TERMINATING", + "DefaultResult": "CONTINUE", + "HeartbeatTimeout": 300, + "NotificationTargetARN": { + "Ref": "ASGLifecycleHookDrainHookTopicA8AD4ACB" + }, + "RoleARN": { + "Fn::GetAtt": [ + "ASGLifecycleHookDrainHookRoleD640316C", + "Arn" + ] + } + }, + "DependsOn": [ + "ASGLifecycleHookDrainHookRoleDefaultPolicy3EEFDE57", + "ASGLifecycleHookDrainHookRoleD640316C" + ] + }, "EC2CapacityProvider5A2E35CD": { "Type": "AWS::ECS::CapacityProvider", "Properties": { @@ -616,7 +903,7 @@ "Status": "ENABLED", "TargetCapacity": 100 }, - "ManagedTerminationProtection": "ENABLED" + "ManagedTerminationProtection": "DISABLED" } } }, diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.capacity-provider.ts b/packages/@aws-cdk/aws-ecs/test/ec2/integ.capacity-provider.ts index f82ce6a9f9f56..3cc93ebaf2eb2 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/integ.capacity-provider.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.capacity-provider.ts @@ -10,6 +10,7 @@ const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 }); const cluster = new ecs.Cluster(stack, 'EC2CPCluster', { vpc, + enableFargateCapacityProviders: true, }); const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef'); @@ -27,6 +28,8 @@ const autoScalingGroup = new autoscaling.AutoScalingGroup(stack, 'ASG', { const cp = new ecs.AsgCapacityProvider(stack, 'EC2CapacityProvider', { autoScalingGroup, + // This is to allow cdk destroy to work; otherwise deletion will hang bc ASG cannot be deleted + enableManagedTerminationProtection: false, }); cluster.addAsgCapacityProvider(cp); @@ -43,4 +46,3 @@ new ecs.Ec2Service(stack, 'EC2Service', { }); app.synth(); - 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 33a5f2baf28d4..f1fdbedc01064 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 @@ -148,6 +148,10 @@ nodeunitShim({ // THEN expect(stack).to(haveResource('AWS::ECS::Cluster', { + CapacityProviders: ABSENT, + })); + + expect(stack).to(haveResource('AWS::ECS::ClusterCapacityProviderAssociations', { CapacityProviders: ['FARGATE', 'FARGATE_SPOT'], })); @@ -234,6 +238,10 @@ nodeunitShim({ // THEN expect(stack).to(haveResource('AWS::ECS::Cluster', { + CapacityProviders: ABSENT, + })); + + expect(stack).to(haveResource('AWS::ECS::ClusterCapacityProviderAssociations', { CapacityProviders: ['FARGATE', 'FARGATE_SPOT'], })); diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/integ.capacity-providers.expected.json b/packages/@aws-cdk/aws-ecs/test/fargate/integ.capacity-providers.expected.json index c0281dc8e14ae..7eb0eada4f244 100644 --- a/packages/@aws-cdk/aws-ecs/test/fargate/integ.capacity-providers.expected.json +++ b/packages/@aws-cdk/aws-ecs/test/fargate/integ.capacity-providers.expected.json @@ -356,12 +356,19 @@ } }, "FargateCPCluster668E71F2": { - "Type": "AWS::ECS::Cluster", + "Type": "AWS::ECS::Cluster" + }, + "FargateCPClusterBFD66A36": { + "Type": "AWS::ECS::ClusterCapacityProviderAssociations", "Properties": { "CapacityProviders": [ "FARGATE", "FARGATE_SPOT" - ] + ], + "Cluster": { + "Ref": "FargateCPCluster668E71F2" + }, + "DefaultCapacityProviderStrategy": [] } }, "TaskDefTaskRole1EDB4A67": {