From 17e0623c4af1021dd01ce1bb3e6b92ebaeb5ad2c Mon Sep 17 00:00:00 2001 From: Pahud Date: Tue, 1 Sep 2020 11:49:48 +0800 Subject: [PATCH 1/7] initial support --- packages/@aws-cdk/aws-ecs/lib/cluster.ts | 89 ++++++++++++++++++- .../aws-ecs/test/ec2/integ.bottlerocket.ts | 38 ++++++++ 2 files changed, 124 insertions(+), 3 deletions(-) create mode 100644 packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.ts diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index aa237af807f22..9fe72e58a74c0 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -50,6 +50,20 @@ export interface ClusterProps { readonly containerInsights?: boolean; } +/** + * The machine image type + */ +export enum MachineImageType { + /** + * Amazon ECS-optimized Amazon Linux 2 AMI + */ + AMAZON_LINUX_2, + /** + * Bottlerocket AMI + */ + BOTTLEROCKET +} + /** * A regional grouping of one or more container instances on which you can run tasks and services. */ @@ -171,15 +185,27 @@ export class Cluster extends Resource implements ICluster { * Returns the AutoScalingGroup so you can add autoscaling settings to it. */ public addCapacity(id: string, options: AddCapacityOptions): autoscaling.AutoScalingGroup { + if ((options.machineImage && options.machineImageType) || (!options.machineImage && !options.machineImageType)) { + throw new Error('You must specify either a machineImage or machineImageType, at least one, not both.'); + } + + let machineImage: ec2.IMachineImage; + + machineImage = options.machineImage ?? options.machineImageType === MachineImageType.BOTTLEROCKET ? + new BottleRocketImage() : new EcsOptimizedAmi(); + const autoScalingGroup = new autoscaling.AutoScalingGroup(this, id, { ...options, vpc: this.vpc, - machineImage: options.machineImage || new EcsOptimizedAmi(), + machineImage, updateType: options.updateType || autoscaling.UpdateType.REPLACING_UPDATE, instanceType: options.instanceType, }); - this.addAutoScalingGroup(autoScalingGroup, options); + this.addAutoScalingGroup(autoScalingGroup, { + machineImageType: options.machineImageType, + ...options + }); return autoScalingGroup; } @@ -196,7 +222,14 @@ export class Cluster extends Resource implements ICluster { this.connections.connections.addSecurityGroup(...autoScalingGroup.connections.securityGroups); // Tie instances to cluster - autoScalingGroup.addUserData(`echo ECS_CLUSTER=${this.clusterName} >> /etc/ecs/ecs.config`); + if(options.machineImageType===MachineImageType.BOTTLEROCKET) { + autoScalingGroup.addUserData( + '[settings.ecs]', + `cluster = "${this.clusterName}"` + ) + } else { + autoScalingGroup.addUserData(`echo ECS_CLUSTER=${this.clusterName} >> /etc/ecs/ecs.config`); + } if (!options.canContainersAccessInstanceRole) { // Deny containers access to instance metadata service @@ -487,6 +520,41 @@ export class EcsOptimizedImage implements ec2.IMachineImage { } } +/** + * Construct an Bottlerocket image from the latest AMI published in SSM + */ +class BottleRocketImage implements ec2.IMachineImage { + private readonly amiParameterName: string; + /** + * Bottlerocket AMI variant + * @default - `aws-ecs-1` + */ + private readonly variant?: string; + + /** + * Constructs a new instance of the BottleRocketImage class. + */ + public constructor() { + // only `aws-ecs-1` is currently available + this.variant = 'aws-ecs-1' + + // set the SSM parameter name + this.amiParameterName = `/aws/service/bottlerocket/${this.variant}/x86_64/latest/image_id`; + } + + /** + * Return the correct image + */ + public getImage(scope: Construct): ec2.MachineImageConfig { + const ami = ssm.StringParameter.valueForStringParameter(scope, this.amiParameterName); + return { + imageId: ami, + osType: ec2.OperatingSystemType.LINUX, + userData: ec2.UserData.custom(''), + }; + } +} + /** * A regional grouping of one or more container instances on which you can run tasks and services. */ @@ -678,6 +746,13 @@ export interface AddAutoScalingGroupCapacityOptions { * @default The SNS Topic will not be encrypted. */ readonly topicEncryptionKey?: kms.IKey; + + /** + * Specify the machine image type. + * + * @default MachineImageType.AMAZON_LINUX_2 + */ + readonly machineImageType?: MachineImageType; } /** @@ -689,9 +764,17 @@ export interface AddCapacityOptions extends AddAutoScalingGroupCapacityOptions, */ readonly instanceType: ec2.InstanceType; + /** + * Machine image type. Ignored if `machineImage` is defined. + * + * @default MachineImageType.AMAZON_LINUX_2 + */ + readonly machineImageType?: MachineImageType; + /** * The ECS-optimized AMI variant to use. For more information, see * [Amazon ECS-optimized AMIs](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-optimized_AMI.html). + * Ignored if `MachineImageType` is defined. * * @default - Amazon Linux 2 */ diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.ts b/packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.ts new file mode 100644 index 0000000000000..35656b5d606b4 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.ts @@ -0,0 +1,38 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +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-bottlerocket'); + +const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 }); + +const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + +cluster.addCapacity('asgSpot', { + maxCapacity: 2, + minCapacity: 2, + desiredCapacity: 2, + instanceType: new ec2.InstanceType('c5.xlarge'), + spotPrice: '0.0735', + spotInstanceDraining: true, + machineImageType: ecs.MachineImageType.BOTTLEROCKET, +}); + +// const taskDefinition = new ecs.TaskDefinition(stack, 'Task', { +// compatibility: ecs.Compatibility.EC2, +// }); + +// taskDefinition.addContainer('PHP', { +// image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), +// memoryLimitMiB: 512, +// }).addPortMappings({ +// containerPort: 80, +// }); + +// new ecs.Ec2Service(stack, 'Service', { +// cluster, +// taskDefinition, +// }); + +app.synth(); From b3d95016ce7ba0915e32003f15040793fc60d271 Mon Sep 17 00:00:00 2001 From: Pahud Date: Tue, 1 Sep 2020 22:51:10 +0800 Subject: [PATCH 2/7] feat(ecs): initial bottlerocket support --- packages/@aws-cdk/aws-ecs/lib/cluster.ts | 33 +- .../test/ec2/integ.bottlerocket.expected.json | 842 ++++++++++++++++++ .../aws-ecs/test/ec2/integ.bottlerocket.ts | 27 +- 3 files changed, 866 insertions(+), 36 deletions(-) create mode 100644 packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.expected.json diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index 9fe72e58a74c0..cb36a7fa05bcd 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -185,7 +185,7 @@ export class Cluster extends Resource implements ICluster { * Returns the AutoScalingGroup so you can add autoscaling settings to it. */ public addCapacity(id: string, options: AddCapacityOptions): autoscaling.AutoScalingGroup { - if ((options.machineImage && options.machineImageType) || (!options.machineImage && !options.machineImageType)) { + if ((options.machineImage && options.machineImageType) || (options.machineImage === undefined && options.machineImageType === undefined)) { throw new Error('You must specify either a machineImage or machineImageType, at least one, not both.'); } @@ -222,26 +222,33 @@ export class Cluster extends Resource implements ICluster { this.connections.connections.addSecurityGroup(...autoScalingGroup.connections.securityGroups); // Tie instances to cluster + // Bottlerocket AMI if(options.machineImageType===MachineImageType.BOTTLEROCKET) { autoScalingGroup.addUserData( '[settings.ecs]', `cluster = "${this.clusterName}"` ) + // Enabling SSM + // Source: https://github.com/bottlerocket-os/bottlerocket/blob/develop/QUICKSTART-ECS.md#enabling-ssm + autoScalingGroup.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')) + // required managed policy + autoScalingGroup.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2ContainerServiceforEC2Role')) } else { + // Amazon ECS-optimized AMI for Amazon Linux 2 autoScalingGroup.addUserData(`echo ECS_CLUSTER=${this.clusterName} >> /etc/ecs/ecs.config`); - } - - if (!options.canContainersAccessInstanceRole) { - // Deny containers access to instance metadata service - // Source: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html - autoScalingGroup.addUserData('sudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP'); - autoScalingGroup.addUserData('sudo service iptables save'); - // The following is only for AwsVpc networking mode, but doesn't hurt for the other modes. - autoScalingGroup.addUserData('echo ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config'); - } + + if (!options.canContainersAccessInstanceRole) { + // Deny containers access to instance metadata service + // Source: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html + autoScalingGroup.addUserData('sudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP'); + autoScalingGroup.addUserData('sudo service iptables save'); + // The following is only for AwsVpc networking mode, but doesn't hurt for the other modes. + autoScalingGroup.addUserData('echo ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config'); + } - if (autoScalingGroup.spotPrice && options.spotInstanceDraining) { - autoScalingGroup.addUserData('echo ECS_ENABLE_SPOT_INSTANCE_DRAINING=true >> /etc/ecs/ecs.config'); + if (autoScalingGroup.spotPrice && options.spotInstanceDraining) { + autoScalingGroup.addUserData('echo ECS_ENABLE_SPOT_INSTANCE_DRAINING=true >> /etc/ecs/ecs.config'); + } } // ECS instances must be able to do these things diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.expected.json b/packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.expected.json new file mode 100644 index 0000000000000..3644a517b73af --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.expected.json @@ -0,0 +1,842 @@ +{ + "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-bottlerocket/Vpc" + } + ] + } + }, + "VpcPublicSubnet1Subnet5C2D37C4": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTable6C95E38E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTableAssociation97140677": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet1RouteTable6C95E38E" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + } + } + }, + "VpcPublicSubnet1DefaultRoute3DA9E72A": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet1RouteTable6C95E38E" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcPublicSubnet1EIPD7E02669": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1NATGateway4D7517AA": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet1EIPD7E02669", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet2Subnet691E08A3": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet2RouteTable94F7E489": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet2RouteTableAssociationDD5762D8": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet2RouteTable94F7E489" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + } + } + }, + "VpcPublicSubnet2DefaultRoute97F91067": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet2RouteTable94F7E489" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcPrivateSubnet1Subnet536B997A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableB2C5B500": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableAssociation70C59FA6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet1RouteTableB2C5B500" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + } + } + }, + "VpcPrivateSubnet1DefaultRouteBE02A9ED": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet1RouteTableB2C5B500" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet1NATGateway4D7517AA" + } + } + }, + "VpcPrivateSubnet2Subnet3788AAA1": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.192.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc/PrivateSubnet2" + } + ] + } + }, + "VpcPrivateSubnet2RouteTableA678073B": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc/PrivateSubnet2" + } + ] + } + }, + "VpcPrivateSubnet2RouteTableAssociationA89CAD56": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet2RouteTableA678073B" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + } + }, + "VpcPrivateSubnet2DefaultRoute060D2087": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet2RouteTableA678073B" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet1NATGateway4D7517AA" + } + } + }, + "VpcIGWD7BA715C": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/Vpc" + } + ] + } + }, + "VpcVPCGWBF912B6E": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "InternetGatewayId": { + "Ref": "VpcIGWD7BA715C" + } + } + }, + "EcsCluster97242B84": { + "Type": "AWS::ECS::Cluster" + }, + "EcsClusterbottlerocketasgInstanceSecurityGroupD754BC23": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ-bottlerocket/EcsCluster/bottlerocket-asg/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/EcsCluster/bottlerocket-asg" + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "EcsClusterbottlerocketasgInstanceRole96AA2ACF": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "ec2.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/AmazonSSMManagedInstanceCore" + ] + ] + }, + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role" + ] + ] + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/EcsCluster/bottlerocket-asg" + } + ] + } + }, + "EcsClusterbottlerocketasgInstanceRoleDefaultPolicy8523C598": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecs:DeregisterContainerInstance", + "ecs:RegisterContainerInstance", + "ecs:Submit*" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "EcsCluster97242B84", + "Arn" + ] + } + }, + { + "Action": [ + "ecs:Poll", + "ecs:StartTelemetrySession" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "EcsCluster97242B84", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecs:DiscoverPollEndpoint", + "ecr:GetAuthorizationToken", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterbottlerocketasgInstanceRoleDefaultPolicy8523C598", + "Roles": [ + { + "Ref": "EcsClusterbottlerocketasgInstanceRole96AA2ACF" + } + ] + } + }, + "EcsClusterbottlerocketasgInstanceProfile22A89B9D": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "EcsClusterbottlerocketasgInstanceRole96AA2ACF" + } + ] + } + }, + "EcsClusterbottlerocketasgLaunchConfig644AD24C": { + "Type": "AWS::AutoScaling::LaunchConfiguration", + "Properties": { + "ImageId": { + "Ref": "SsmParameterValueawsservicebottlerocketawsecs1x8664latestimageidC96584B6F00A464EAD1953AFF4B05118Parameter" + }, + "InstanceType": "c5.large", + "IamInstanceProfile": { + "Ref": "EcsClusterbottlerocketasgInstanceProfile22A89B9D" + }, + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "EcsClusterbottlerocketasgInstanceSecurityGroupD754BC23", + "GroupId" + ] + } + ], + "UserData": { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "\n[settings.ecs]\ncluster = \"", + { + "Ref": "EcsCluster97242B84" + }, + "\"" + ] + ] + } + } + }, + "DependsOn": [ + "EcsClusterbottlerocketasgInstanceRoleDefaultPolicy8523C598", + "EcsClusterbottlerocketasgInstanceRole96AA2ACF" + ] + }, + "EcsClusterbottlerocketasgASGCB222A6E": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "MaxSize": "2", + "MinSize": "2", + "LaunchConfigurationName": { + "Ref": "EcsClusterbottlerocketasgLaunchConfig644AD24C" + }, + "Tags": [ + { + "Key": "Name", + "PropagateAtLaunch": true, + "Value": "aws-ecs-integ-bottlerocket/EcsCluster/bottlerocket-asg" + } + ], + "VPCZoneIdentifier": [ + { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + }, + { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + ] + }, + "UpdatePolicy": { + "AutoScalingReplacingUpdate": { + "WillReplace": true + }, + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } + } + }, + "EcsClusterbottlerocketasgDrainECSHookFunctionServiceRole2F16AFAB": { + "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-bottlerocket/EcsCluster/bottlerocket-asg" + } + ] + } + }, + "EcsClusterbottlerocketasgDrainECSHookFunctionServiceRoleDefaultPolicyD5FBB46E": { + "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": "EcsClusterbottlerocketasgASGCB222A6E" + } + ] + ] + } + }, + { + "Action": [ + "ecs:DescribeContainerInstances", + "ecs:DescribeTasks" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "EcsCluster97242B84", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": [ + "ecs:ListContainerInstances", + "ecs:SubmitContainerStateChange", + "ecs:SubmitTaskStateChange" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "EcsCluster97242B84", + "Arn" + ] + } + }, + { + "Action": [ + "ecs:UpdateContainerInstancesState", + "ecs:ListTasks" + ], + "Condition": { + "ArnEquals": { + "ecs:cluster": { + "Fn::GetAtt": [ + "EcsCluster97242B84", + "Arn" + ] + } + } + }, + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterbottlerocketasgDrainECSHookFunctionServiceRoleDefaultPolicyD5FBB46E", + "Roles": [ + { + "Ref": "EcsClusterbottlerocketasgDrainECSHookFunctionServiceRole2F16AFAB" + } + ] + } + }, + "EcsClusterbottlerocketasgDrainECSHookFunction7A8CD0E4": { + "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" + }, + "Handler": "index.lambda_handler", + "Role": { + "Fn::GetAtt": [ + "EcsClusterbottlerocketasgDrainECSHookFunctionServiceRole2F16AFAB", + "Arn" + ] + }, + "Runtime": "python3.6", + "Environment": { + "Variables": { + "CLUSTER": { + "Ref": "EcsCluster97242B84" + } + } + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/EcsCluster/bottlerocket-asg" + } + ], + "Timeout": 310 + }, + "DependsOn": [ + "EcsClusterbottlerocketasgDrainECSHookFunctionServiceRoleDefaultPolicyD5FBB46E", + "EcsClusterbottlerocketasgDrainECSHookFunctionServiceRole2F16AFAB" + ] + }, + "EcsClusterbottlerocketasgDrainECSHookFunctionAllowInvokeawsecsintegbottlerocketEcsClusterbottlerocketasgLifecycleHookDrainHookTopicD05837A873EBB93D": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "EcsClusterbottlerocketasgDrainECSHookFunction7A8CD0E4", + "Arn" + ] + }, + "Principal": "sns.amazonaws.com", + "SourceArn": { + "Ref": "EcsClusterbottlerocketasgLifecycleHookDrainHookTopic64509A74" + } + } + }, + "EcsClusterbottlerocketasgDrainECSHookFunctionTopic1953D6F0": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Protocol": "lambda", + "TopicArn": { + "Ref": "EcsClusterbottlerocketasgLifecycleHookDrainHookTopic64509A74" + }, + "Endpoint": { + "Fn::GetAtt": [ + "EcsClusterbottlerocketasgDrainECSHookFunction7A8CD0E4", + "Arn" + ] + } + } + }, + "EcsClusterbottlerocketasgLifecycleHookDrainHookRoleDE4D94EB": { + "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-bottlerocket/EcsCluster/bottlerocket-asg" + } + ] + } + }, + "EcsClusterbottlerocketasgLifecycleHookDrainHookRoleDefaultPolicy95E06EDB": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "EcsClusterbottlerocketasgLifecycleHookDrainHookTopic64509A74" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterbottlerocketasgLifecycleHookDrainHookRoleDefaultPolicy95E06EDB", + "Roles": [ + { + "Ref": "EcsClusterbottlerocketasgLifecycleHookDrainHookRoleDE4D94EB" + } + ] + } + }, + "EcsClusterbottlerocketasgLifecycleHookDrainHookTopic64509A74": { + "Type": "AWS::SNS::Topic", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-bottlerocket/EcsCluster/bottlerocket-asg" + } + ] + } + }, + "EcsClusterbottlerocketasgLifecycleHookDrainHook59C31812": { + "Type": "AWS::AutoScaling::LifecycleHook", + "Properties": { + "AutoScalingGroupName": { + "Ref": "EcsClusterbottlerocketasgASGCB222A6E" + }, + "LifecycleTransition": "autoscaling:EC2_INSTANCE_TERMINATING", + "DefaultResult": "CONTINUE", + "HeartbeatTimeout": 300, + "NotificationTargetARN": { + "Ref": "EcsClusterbottlerocketasgLifecycleHookDrainHookTopic64509A74" + }, + "RoleARN": { + "Fn::GetAtt": [ + "EcsClusterbottlerocketasgLifecycleHookDrainHookRoleDE4D94EB", + "Arn" + ] + } + }, + "DependsOn": [ + "EcsClusterbottlerocketasgLifecycleHookDrainHookRoleDefaultPolicy95E06EDB", + "EcsClusterbottlerocketasgLifecycleHookDrainHookRoleDE4D94EB" + ] + } + }, + "Parameters": { + "SsmParameterValueawsservicebottlerocketawsecs1x8664latestimageidC96584B6F00A464EAD1953AFF4B05118Parameter": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/aws/service/bottlerocket/aws-ecs-1/x86_64/latest/image_id" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.ts b/packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.ts index 35656b5d606b4..74dc16947a9a4 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.bottlerocket.ts @@ -3,36 +3,17 @@ 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-bottlerocket'); -const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2 }); +const vpc = new ec2.Vpc(stack, 'Vpc', { maxAzs: 2, natGateways: 1 }); const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); -cluster.addCapacity('asgSpot', { - maxCapacity: 2, +cluster.addCapacity('bottlerocket-asg', { minCapacity: 2, - desiredCapacity: 2, - instanceType: new ec2.InstanceType('c5.xlarge'), - spotPrice: '0.0735', - spotInstanceDraining: true, + instanceType: new ec2.InstanceType('c5.large'), machineImageType: ecs.MachineImageType.BOTTLEROCKET, }); -// const taskDefinition = new ecs.TaskDefinition(stack, 'Task', { -// compatibility: ecs.Compatibility.EC2, -// }); - -// taskDefinition.addContainer('PHP', { -// image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), -// memoryLimitMiB: 512, -// }).addPortMappings({ -// containerPort: 80, -// }); - -// new ecs.Ec2Service(stack, 'Service', { -// cluster, -// taskDefinition, -// }); - app.synth(); From 1a6529de542b84b6bb439cf4933c24c11572e713 Mon Sep 17 00:00:00 2001 From: Pahud Date: Tue, 1 Sep 2020 23:10:38 +0800 Subject: [PATCH 3/7] update README --- packages/@aws-cdk/aws-ecs/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/@aws-cdk/aws-ecs/README.md b/packages/@aws-cdk/aws-ecs/README.md index db3ecb9b3d24b..48446bded5109 100644 --- a/packages/@aws-cdk/aws-ecs/README.md +++ b/packages/@aws-cdk/aws-ecs/README.md @@ -146,6 +146,26 @@ cluster.addCapacity('AsgSpot', { }); ``` +### Bottlerocket + +[Bottlerocket](https://aws.amazon.com/bottlerocket/) is a Linux-based open source operating system that is +purpose-built by AWS for running containers. You can launch Amazon ECS container instances with the Bottlerocket AMI. + +> **NOTICE**: The Bottlerocket AMI is in developer preview release for Amazon ECS and is subject to change. + +The following example will create a capacity with self-managed Amazon EC2 capacity of 2 `c5.large` Linux instances running with `Bottlerocket` AMI. + +Note that you must specify either a `machineImage` or `machineImageType`, at least one, not both. + + +```ts +cluster.addCapacity('bottlerocket-asg', { + minCapacity: 2, + instanceType: new ec2.InstanceType('c5.large'), + machineImageType: ecs.MachineImageType.BOTTLEROCKET, +}); +``` + ### SNS Topic Encryption When the `ecs.AddCapacityOptions` that you provide has a non-zero `taskDrainTime` (the default) then an SNS topic and Lambda are created to ensure that the From 82060910b236ce12d751443d16cb08e1ce9e1451 Mon Sep 17 00:00:00 2001 From: Pahud Date: Tue, 1 Sep 2020 23:22:01 +0800 Subject: [PATCH 4/7] update README --- packages/@aws-cdk/aws-ecs/lib/cluster.ts | 24 +++--- .../@aws-cdk/aws-ecs/test/test.ecs-cluster.ts | 74 +++++++++++++++++++ 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index cb36a7fa05bcd..a03e734d13812 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -185,8 +185,8 @@ export class Cluster extends Resource implements ICluster { * Returns the AutoScalingGroup so you can add autoscaling settings to it. */ public addCapacity(id: string, options: AddCapacityOptions): autoscaling.AutoScalingGroup { - if ((options.machineImage && options.machineImageType) || (options.machineImage === undefined && options.machineImageType === undefined)) { - throw new Error('You must specify either a machineImage or machineImageType, at least one, not both.'); + if (options.machineImage && options.machineImageType) { + throw new Error('You can only specify either a machineImage or machineImageType, not both.'); } let machineImage: ec2.IMachineImage; @@ -204,7 +204,7 @@ export class Cluster extends Resource implements ICluster { this.addAutoScalingGroup(autoScalingGroup, { machineImageType: options.machineImageType, - ...options + ...options, }); return autoScalingGroup; @@ -223,20 +223,20 @@ export class Cluster extends Resource implements ICluster { // Tie instances to cluster // Bottlerocket AMI - if(options.machineImageType===MachineImageType.BOTTLEROCKET) { + if (options.machineImageType===MachineImageType.BOTTLEROCKET) { autoScalingGroup.addUserData( '[settings.ecs]', - `cluster = "${this.clusterName}"` - ) + `cluster = "${this.clusterName}"`, + ); // Enabling SSM // Source: https://github.com/bottlerocket-os/bottlerocket/blob/develop/QUICKSTART-ECS.md#enabling-ssm - autoScalingGroup.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')) + autoScalingGroup.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')); // required managed policy - autoScalingGroup.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2ContainerServiceforEC2Role')) - } else { + autoScalingGroup.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2ContainerServiceforEC2Role')); + } else { // Amazon ECS-optimized AMI for Amazon Linux 2 autoScalingGroup.addUserData(`echo ECS_CLUSTER=${this.clusterName} >> /etc/ecs/ecs.config`); - + if (!options.canContainersAccessInstanceRole) { // Deny containers access to instance metadata service // Source: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html @@ -543,7 +543,7 @@ class BottleRocketImage implements ec2.IMachineImage { */ public constructor() { // only `aws-ecs-1` is currently available - this.variant = 'aws-ecs-1' + this.variant = 'aws-ecs-1'; // set the SSM parameter name this.amiParameterName = `/aws/service/bottlerocket/${this.variant}/x86_64/latest/image_id`; @@ -756,7 +756,7 @@ export interface AddAutoScalingGroupCapacityOptions { /** * Specify the machine image type. - * + * * @default MachineImageType.AMAZON_LINUX_2 */ readonly machineImageType?: MachineImageType; diff --git a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts index 3e3b1eca4c85a..02630e92a1ceb 100644 --- a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts +++ b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts @@ -1473,6 +1473,80 @@ export = { 'ClusterSettings should not be defined', ); + test.done(); + }, + 'cluster capacity with bottlerocket AMI'(test: Test) { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'test'); + + const cluster = new ecs.Cluster(stack, 'EcsCluster'); + cluster.addCapacity('bottlerocket-asg', { + instanceType: new ec2.InstanceType('c5.large'), + machineImageType: ecs.MachineImageType.BOTTLEROCKET, + }); + + // THEN + expect(stack).to(haveResource('AWS::ECS::Cluster')); + expect(stack).to(haveResource('AWS::AutoScaling::AutoScalingGroup')); + expect(stack).to(haveResourceLike('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: { + 'Fn::Join': [ + '', + [ + 'ec2.', + { + Ref: 'AWS::URLSuffix', + }, + ], + ], + }, + }, + }, + ], + Version: '2012-10-17', + }, + ManagedPolicyArns: [ + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::aws:policy/AmazonSSMManagedInstanceCore', + ], + ], + }, + { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role', + ], + ], + }, + ], + Tags: [ + { + Key: 'Name', + Value: 'test/EcsCluster/bottlerocket-asg', + }, + ], + }), + ); + test.done(); }, }; From 9b4353efc8d2b80d2fc7311c513388a88880d168 Mon Sep 17 00:00:00 2001 From: Pahud Date: Wed, 2 Sep 2020 17:33:10 +0800 Subject: [PATCH 5/7] fix --- packages/@aws-cdk/aws-ecs/lib/cluster.ts | 77 +++++++++++------------- 1 file changed, 36 insertions(+), 41 deletions(-) diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index a03e734d13812..fba18630a4041 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -186,20 +186,17 @@ export class Cluster extends Resource implements ICluster { */ public addCapacity(id: string, options: AddCapacityOptions): autoscaling.AutoScalingGroup { if (options.machineImage && options.machineImageType) { - throw new Error('You can only specify either a machineImage or machineImageType, not both.'); + throw new Error('You can only specify either machineImage or machineImageType, not both.'); } - let machineImage: ec2.IMachineImage; - - machineImage = options.machineImage ?? options.machineImageType === MachineImageType.BOTTLEROCKET ? + const machineImage = options.machineImage ?? options.machineImageType === MachineImageType.BOTTLEROCKET ? new BottleRocketImage() : new EcsOptimizedAmi(); const autoScalingGroup = new autoscaling.AutoScalingGroup(this, id, { - ...options, vpc: this.vpc, machineImage, updateType: options.updateType || autoscaling.UpdateType.REPLACING_UPDATE, - instanceType: options.instanceType, + ...options, }); this.addAutoScalingGroup(autoScalingGroup, { @@ -222,33 +219,37 @@ export class Cluster extends Resource implements ICluster { this.connections.connections.addSecurityGroup(...autoScalingGroup.connections.securityGroups); // Tie instances to cluster - // Bottlerocket AMI - if (options.machineImageType===MachineImageType.BOTTLEROCKET) { - autoScalingGroup.addUserData( - '[settings.ecs]', - `cluster = "${this.clusterName}"`, - ); - // Enabling SSM - // Source: https://github.com/bottlerocket-os/bottlerocket/blob/develop/QUICKSTART-ECS.md#enabling-ssm - autoScalingGroup.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')); - // required managed policy - autoScalingGroup.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2ContainerServiceforEC2Role')); - } else { - // Amazon ECS-optimized AMI for Amazon Linux 2 - autoScalingGroup.addUserData(`echo ECS_CLUSTER=${this.clusterName} >> /etc/ecs/ecs.config`); - - if (!options.canContainersAccessInstanceRole) { - // Deny containers access to instance metadata service - // Source: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html - autoScalingGroup.addUserData('sudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP'); - autoScalingGroup.addUserData('sudo service iptables save'); - // The following is only for AwsVpc networking mode, but doesn't hurt for the other modes. - autoScalingGroup.addUserData('echo ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config'); - } - - if (autoScalingGroup.spotPrice && options.spotInstanceDraining) { - autoScalingGroup.addUserData('echo ECS_ENABLE_SPOT_INSTANCE_DRAINING=true >> /etc/ecs/ecs.config'); + switch (options.machineImageType) { + // Bottlerocket AMI + case MachineImageType.BOTTLEROCKET: { + autoScalingGroup.addUserData( + // Connect to the cluster + // Source: https://github.com/bottlerocket-os/bottlerocket/blob/develop/QUICKSTART-ECS.md#connecting-to-your-cluster + '[settings.ecs]', + `cluster = "${this.clusterName}"`, + ); + // Enabling SSM + // Source: https://github.com/bottlerocket-os/bottlerocket/blob/develop/QUICKSTART-ECS.md#enabling-ssm + autoScalingGroup.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore')); + // required managed policy + autoScalingGroup.role.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2ContainerServiceforEC2Role')); + break; } + default: + // Amazon ECS-optimized AMI for Amazon Linux 2 + autoScalingGroup.addUserData(`echo ECS_CLUSTER=${this.clusterName} >> /etc/ecs/ecs.config`); + if (!options.canContainersAccessInstanceRole) { + // Deny containers access to instance metadata service + // Source: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html + autoScalingGroup.addUserData('sudo iptables --insert FORWARD 1 --in-interface docker+ --destination 169.254.169.254/32 --jump DROP'); + autoScalingGroup.addUserData('sudo service iptables save'); + // The following is only for AwsVpc networking mode, but doesn't hurt for the other modes. + autoScalingGroup.addUserData('echo ECS_AWSVPC_BLOCK_IMDS=true >> /etc/ecs/ecs.config'); + } + + if (autoScalingGroup.spotPrice && options.spotInstanceDraining) { + autoScalingGroup.addUserData('echo ECS_ENABLE_SPOT_INSTANCE_DRAINING=true >> /etc/ecs/ecs.config'); + } } // ECS instances must be able to do these things @@ -530,7 +531,7 @@ export class EcsOptimizedImage implements ec2.IMachineImage { /** * Construct an Bottlerocket image from the latest AMI published in SSM */ -class BottleRocketImage implements ec2.IMachineImage { +export class BottleRocketImage implements ec2.IMachineImage { private readonly amiParameterName: string; /** * Bottlerocket AMI variant @@ -754,6 +755,7 @@ export interface AddAutoScalingGroupCapacityOptions { */ readonly topicEncryptionKey?: kms.IKey; + /** * Specify the machine image type. * @@ -771,17 +773,10 @@ export interface AddCapacityOptions extends AddAutoScalingGroupCapacityOptions, */ readonly instanceType: ec2.InstanceType; - /** - * Machine image type. Ignored if `machineImage` is defined. - * - * @default MachineImageType.AMAZON_LINUX_2 - */ - readonly machineImageType?: MachineImageType; - /** * The ECS-optimized AMI variant to use. For more information, see * [Amazon ECS-optimized AMIs](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-optimized_AMI.html). - * Ignored if `MachineImageType` is defined. + * You must define either `machineImage` or `machineImageType`, not both. * * @default - Amazon Linux 2 */ From db78b1ea6e1994518159518776ecfb38bad12940 Mon Sep 17 00:00:00 2001 From: Pahud Date: Wed, 2 Sep 2020 22:20:43 +0800 Subject: [PATCH 6/7] add tests --- packages/@aws-cdk/aws-ecs/README.md | 34 +++++++-------- packages/@aws-cdk/aws-ecs/lib/cluster.ts | 34 ++++++++++++--- .../@aws-cdk/aws-ecs/test/test.ecs-cluster.ts | 41 +++++++++++++++++++ 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/packages/@aws-cdk/aws-ecs/README.md b/packages/@aws-cdk/aws-ecs/README.md index 48446bded5109..d63f9974cc76c 100644 --- a/packages/@aws-cdk/aws-ecs/README.md +++ b/packages/@aws-cdk/aws-ecs/README.md @@ -129,22 +129,6 @@ cluster.addAutoScalingGroup(autoScalingGroup); If you omit the property `vpc`, the construct will create a new VPC with two AZs. -### Spot Instances - -To add spot instances into the cluster, you must specify the `spotPrice` in the `ecs.AddCapacityOptions` and optionally enable the `spotInstanceDraining` property. - -```ts -// Add an AutoScalingGroup with spot instances to the existing cluster -cluster.addCapacity('AsgSpot', { - maxCapacity: 2, - minCapacity: 2, - desiredCapacity: 2, - instanceType: new ec2.InstanceType('c5.xlarge'), - spotPrice: '0.0735', - // Enable the Automated Spot Draining support for Amazon ECS - spotInstanceDraining: true, -}); -``` ### Bottlerocket @@ -157,6 +141,7 @@ The following example will create a capacity with self-managed Amazon EC2 capaci Note that you must specify either a `machineImage` or `machineImageType`, at least one, not both. +The following example adds Bottlerocket capacity to the cluster: ```ts cluster.addCapacity('bottlerocket-asg', { @@ -166,6 +151,23 @@ cluster.addCapacity('bottlerocket-asg', { }); ``` +### Spot Instances + +To add spot instances into the cluster, you must specify the `spotPrice` in the `ecs.AddCapacityOptions` and optionally enable the `spotInstanceDraining` property. + +```ts +// Add an AutoScalingGroup with spot instances to the existing cluster +cluster.addCapacity('AsgSpot', { + maxCapacity: 2, + minCapacity: 2, + desiredCapacity: 2, + instanceType: new ec2.InstanceType('c5.xlarge'), + spotPrice: '0.0735', + // Enable the Automated Spot Draining support for Amazon ECS + spotInstanceDraining: true, +}); +``` + ### SNS Topic Encryption When the `ecs.AddCapacityOptions` that you provide has a non-zero `taskDrainTime` (the default) then an SNS topic and Lambda are created to ensure that the diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts index fba18630a4041..420e765ad9b9c 100644 --- a/packages/@aws-cdk/aws-ecs/lib/cluster.ts +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -528,23 +528,45 @@ export class EcsOptimizedImage implements ec2.IMachineImage { } } +/** + * Amazon ECS variant + */ +export enum BottlerocketEcsVariant { + /** + * aws-ecs-1 variant + */ + AWS_ECS_1 = 'aws-ecs-1' + +} + +/** + * Properties for BottleRocketImage + */ +export interface BottleRocketImageProps { + /** + * The Amazon ECS variant to use. + * Only `aws-ecs-1` is currently available + * + * @default - BottlerocketEcsVariant.AWS_ECS_1 + */ + readonly variant?: BottlerocketEcsVariant; +} + /** * Construct an Bottlerocket image from the latest AMI published in SSM */ export class BottleRocketImage implements ec2.IMachineImage { private readonly amiParameterName: string; /** - * Bottlerocket AMI variant - * @default - `aws-ecs-1` + * Amazon ECS variant for Bottlerocket AMI */ - private readonly variant?: string; + private readonly variant: string; /** * Constructs a new instance of the BottleRocketImage class. */ - public constructor() { - // only `aws-ecs-1` is currently available - this.variant = 'aws-ecs-1'; + public constructor(props: BottleRocketImageProps = {}) { + this.variant = props.variant ?? BottlerocketEcsVariant.AWS_ECS_1; // set the SSM parameter name this.amiParameterName = `/aws/service/bottlerocket/${this.variant}/x86_64/latest/image_id`; diff --git a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts index 02630e92a1ceb..23ba24226ef53 100644 --- a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts +++ b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts @@ -1475,6 +1475,28 @@ export = { test.done(); }, + 'BottleRocketImage() returns correct AMI'(test: Test) { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'test'); + + // WHEN + new ecs.BottleRocketImage().getImage(stack); + + // THEN + const assembly = app.synth(); + const parameters = assembly.getStackByName(stack.stackName).template.Parameters; + test.ok(Object.entries(parameters).some( + ([k, v]) => k.startsWith('SsmParameterValueawsservicebottlerocketawsecs') && + (v as any).Default.includes('/bottlerocket/'), + ), 'Bottlerocket AMI should be in ssm parameters'); + test.ok(Object.entries(parameters).some( + ([k, v]) => k.startsWith('SsmParameterValueawsservicebottlerocketawsecs') && + (v as any).Default.includes('/aws-ecs-1/'), + ), 'ecs variant should be in ssm parameters'); + test.done(); + }, + 'cluster capacity with bottlerocket AMI'(test: Test) { // GIVEN const app = new cdk.App(); @@ -1489,6 +1511,25 @@ export = { // THEN expect(stack).to(haveResource('AWS::ECS::Cluster')); expect(stack).to(haveResource('AWS::AutoScaling::AutoScalingGroup')); + expect(stack).to(haveResource('AWS::AutoScaling::LaunchConfiguration', { + ImageId: { + Ref: 'SsmParameterValueawsservicebottlerocketawsecs1x8664latestimageidC96584B6F00A464EAD1953AFF4B05118Parameter', + }, + UserData: { + 'Fn::Base64': { + 'Fn::Join': [ + '', + [ + '\n[settings.ecs]\ncluster = "', + { + Ref: 'EcsCluster97242B84', + }, + '"', + ], + ], + }, + }, + })); expect(stack).to(haveResourceLike('AWS::IAM::Role', { AssumeRolePolicyDocument: { Statement: [ From 3ae9576d0df93e571250fc355cef2bbd73b06a43 Mon Sep 17 00:00:00 2001 From: Pahud Date: Wed, 2 Sep 2020 22:36:33 +0800 Subject: [PATCH 7/7] add more test --- .../@aws-cdk/aws-ecs/test/test.ecs-cluster.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts index 23ba24226ef53..0818b7ce4372d 100644 --- a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts +++ b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts @@ -1587,7 +1587,22 @@ export = { ], }), ); + test.done(); + }, + 'throws when machineImage and machineImageType both specified'(test: Test) { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'test'); + const cluster = new ecs.Cluster(stack, 'EcsCluster'); + // THEN + test.throws(() => { + cluster.addCapacity('bottlerocket-asg', { + instanceType: new ec2.InstanceType('c5.large'), + machineImageType: ecs.MachineImageType.BOTTLEROCKET, + machineImage: new ecs.EcsOptimizedAmi(), + }); + }, /You can only specify either machineImage or machineImageType, not both./); test.done(); }, };