From ae03ddb4dba5524391b0c8133d84769da2603ee7 Mon Sep 17 00:00:00 2001 From: Hsing-Hui Hsu Date: Tue, 6 Nov 2018 06:19:17 -0800 Subject: [PATCH] feat: add a new construct library for ECS (#1058) Add a new construct library to start services on ECS, both on EC2 and Fargate-based clusters. Containers can be started from images that are publicly available on DockerHub, images in ECR repositories, and images built directly from sources that are stored in source code next to the CDK app. ECS services can be used as load balancer targets, and there are higher-level constructs available to make it easy to start a service behind a load balancer. BREAKING CHANGE: the ec2.Connections object has been changed to be able to manage multiple security groups. The relevant property has been changed from `securityGroup` to `securityGroups` (an array of security group objects). --- .gitignore | 1 + examples/cdk-examples-typescript/.gitignore | 3 +- .../hello-cdk-ecs-declarative/cdk.json | 3 + .../fargate-service.yml | 8 + .../hello-cdk-ecs/index.ts | 37 + .../hello-cdk-fargate/index.ts | 29 + examples/cdk-examples-typescript/package.json | 2 + packages/@aws-cdk/assets/lib/asset.ts | 2 +- .../lib/base-scalable-attribute.ts | 6 +- .../aws-autoscaling/lib/auto-scaling-group.ts | 2 +- .../lib/scalable-table-attribute.ts | 4 +- packages/@aws-cdk/aws-ec2/lib/connections.ts | 125 ++- .../@aws-cdk/aws-ec2/lib/security-group.ts | 2 +- .../@aws-cdk/aws-ec2/test/test.connections.ts | 239 ++---- .../aws-ec2/test/test.security-group.ts | 175 ++++ .../@aws-cdk/aws-ecr/lib/repository-ref.ts | 27 +- packages/@aws-cdk/aws-ecs/.gitignore | 3 +- packages/@aws-cdk/aws-ecs/.npmignore | 4 +- packages/@aws-cdk/aws-ecs/README.md | 245 +++++- .../@aws-cdk/aws-ecs/lib/base/base-service.ts | 242 ++++++ .../aws-ecs/lib/base/scalable-task-count.ts | 103 +++ .../aws-ecs/lib/base/task-definition.ts | 426 ++++++++++ packages/@aws-cdk/aws-ecs/lib/cluster.ts | 312 +++++++ .../aws-ecs/lib/container-definition.ts | 619 ++++++++++++++ .../@aws-cdk/aws-ecs/lib/container-image.ts | 48 ++ .../@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts | 292 +++++++ .../aws-ecs/lib/ec2/ec2-task-definition.ts | 38 + .../aws-ecs/lib/fargate/fargate-service.ts | 109 +++ .../lib/fargate/fargate-task-definition.ts | 60 ++ .../lib/images/adopt-repository/handler.js | 102 +++ .../aws-ecs/lib/images/asset-image.ts | 129 +++ .../@aws-cdk/aws-ecs/lib/images/dockerhub.ts | 26 + packages/@aws-cdk/aws-ecs/lib/images/ecr.ts | 20 + packages/@aws-cdk/aws-ecs/lib/index.ts | 28 + .../@aws-cdk/aws-ecs/lib/linux-parameters.ts | 253 ++++++ .../aws-ecs/lib/load-balanced-ecs-service.ts | 103 +++ .../load-balanced-fargate-service-applet.ts | 96 +++ .../lib/load-balanced-fargate-service.ts | 126 +++ .../aws-ecs/lib/log-drivers/aws-log-driver.ts | 91 ++ .../aws-ecs/lib/log-drivers/log-driver.ts | 12 + packages/@aws-cdk/aws-ecs/lib/util.ts | 9 + packages/@aws-cdk/aws-ecs/package.json | 19 +- .../aws-ecs/test/demo-image/Dockerfile | 5 + .../@aws-cdk/aws-ecs/test/demo-image/index.py | 33 + .../test/ec2/integ.lb-awsvpc-nw.expected.json | 725 ++++++++++++++++ .../aws-ecs/test/ec2/integ.lb-awsvpc-nw.ts | 45 + .../test/ec2/integ.lb-bridge-nw.expected.json | 688 +++++++++++++++ .../aws-ecs/test/ec2/integ.lb-bridge-nw.ts | 47 + .../aws-ecs/test/ec2/test.ec2-service.ts | 497 +++++++++++ .../test/ec2/test.ec2-task-definition.ts | 260 ++++++ .../fargate/integ.asset-image.expected.json | 804 ++++++++++++++++++ .../aws-ecs/test/fargate/integ.asset-image.ts | 27 + .../fargate/integ.lb-awsvpc-nw.expected.json | 634 ++++++++++++++ .../test/fargate/integ.lb-awsvpc-nw.ts | 45 + .../test/fargate/test.fargate-service.ts | 131 +++ .../fargate/test.fargate-task-definition.ts | 28 + .../@aws-cdk/aws-ecs/test/test.asset-image.ts | 148 ++++ .../aws-ecs/test/test.container-definition.ts | 355 ++++++++ .../@aws-cdk/aws-ecs/test/test.ecs-cluster.ts | 195 +++++ packages/@aws-cdk/aws-ecs/test/test.ecs.ts | 8 - packages/@aws-cdk/aws-ecs/test/test.l3s.ts | 45 + .../aws-ecs/test/test.task-definition.ts | 25 + .../lib/load-balancer.ts | 4 +- .../lib/alb/application-listener.ts | 6 +- .../lib/alb/application-load-balancer.ts | 4 +- .../test/helpers.ts | 2 +- .../@aws-cdk/aws-lambda/lib/lambda-ref.ts | 9 +- packages/@aws-cdk/aws-lambda/lib/lambda.ts | 2 +- .../aws-lambda/test/test.vpc-lambda.ts | 4 +- .../@aws-cdk/aws-quickstarts/lib/database.ts | 2 +- packages/@aws-cdk/aws-quickstarts/lib/rdgw.ts | 2 +- packages/@aws-cdk/aws-rds/lib/cluster-ref.ts | 2 +- packages/@aws-cdk/aws-rds/lib/cluster.ts | 2 +- packages/@aws-cdk/cdk/lib/context.ts | 4 +- packages/@aws-cdk/cx-api/lib/cxapi.ts | 42 +- packages/aws-cdk/lib/api/toolkit-info.ts | 100 ++- packages/aws-cdk/lib/api/util/sdk.ts | 9 + packages/aws-cdk/lib/assets.ts | 13 +- packages/aws-cdk/lib/docker.ts | 220 +++++ packages/aws-cdk/lib/logging.ts | 2 +- packages/aws-cdk/lib/os.ts | 98 +++ packages/aws-cdk/lib/util/please-hold.ts | 26 + scripts/build-typescript.sh | 42 + scripts/dependencies.py | 82 ++ scripts/regen-l1.sh | 6 + tools/cdk-integ-tools/lib/integ-helpers.ts | 30 +- 86 files changed, 9392 insertions(+), 246 deletions(-) create mode 100644 examples/cdk-examples-typescript/hello-cdk-ecs-declarative/cdk.json create mode 100644 examples/cdk-examples-typescript/hello-cdk-ecs-declarative/fargate-service.yml create mode 100644 examples/cdk-examples-typescript/hello-cdk-ecs/index.ts create mode 100644 examples/cdk-examples-typescript/hello-cdk-fargate/index.ts create mode 100644 packages/@aws-cdk/aws-ec2/test/test.security-group.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/base/base-service.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/base/scalable-task-count.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/base/task-definition.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/cluster.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/container-definition.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/container-image.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/ec2/ec2-task-definition.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/fargate/fargate-task-definition.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/images/adopt-repository/handler.js create mode 100644 packages/@aws-cdk/aws-ecs/lib/images/asset-image.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/images/dockerhub.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/images/ecr.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/linux-parameters.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/load-balanced-ecs-service.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/load-balanced-fargate-service-applet.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/load-balanced-fargate-service.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/log-drivers/aws-log-driver.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/log-drivers/log-driver.ts create mode 100644 packages/@aws-cdk/aws-ecs/lib/util.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/demo-image/Dockerfile create mode 100644 packages/@aws-cdk/aws-ecs/test/demo-image/index.py create mode 100644 packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.expected.json create mode 100644 packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.expected.json create mode 100644 packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-task-definition.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/fargate/integ.asset-image.expected.json create mode 100644 packages/@aws-cdk/aws-ecs/test/fargate/integ.asset-image.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/fargate/integ.lb-awsvpc-nw.expected.json create mode 100644 packages/@aws-cdk/aws-ecs/test/fargate/integ.lb-awsvpc-nw.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-task-definition.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/test.asset-image.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/test.container-definition.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts delete mode 100644 packages/@aws-cdk/aws-ecs/test/test.ecs.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/test.l3s.ts create mode 100644 packages/@aws-cdk/aws-ecs/test/test.task-definition.ts create mode 100644 packages/aws-cdk/lib/docker.ts create mode 100644 packages/aws-cdk/lib/os.ts create mode 100644 packages/aws-cdk/lib/util/please-hold.ts create mode 100755 scripts/build-typescript.sh create mode 100644 scripts/dependencies.py create mode 100755 scripts/regen-l1.sh diff --git a/.gitignore b/.gitignore index 7b89f50668180..5a03c2b774932 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ pack coverage .nyc_output .LAST_BUILD +*.swp diff --git a/examples/cdk-examples-typescript/.gitignore b/examples/cdk-examples-typescript/.gitignore index e88e10e324457..d098b17a42417 100644 --- a/examples/cdk-examples-typescript/.gitignore +++ b/examples/cdk-examples-typescript/.gitignore @@ -1,2 +1,3 @@ .LAST_BUILD -*.snk \ No newline at end of file +hello-cdk-ecs/cdk.json +*.snk diff --git a/examples/cdk-examples-typescript/hello-cdk-ecs-declarative/cdk.json b/examples/cdk-examples-typescript/hello-cdk-ecs-declarative/cdk.json new file mode 100644 index 0000000000000..e953d82380eba --- /dev/null +++ b/examples/cdk-examples-typescript/hello-cdk-ecs-declarative/cdk.json @@ -0,0 +1,3 @@ +{ + "app": "fargate-service.yml" +} diff --git a/examples/cdk-examples-typescript/hello-cdk-ecs-declarative/fargate-service.yml b/examples/cdk-examples-typescript/hello-cdk-ecs-declarative/fargate-service.yml new file mode 100644 index 0000000000000..036197b367e6b --- /dev/null +++ b/examples/cdk-examples-typescript/hello-cdk-ecs-declarative/fargate-service.yml @@ -0,0 +1,8 @@ +# applet is loaded from the local ./test-applet.js file +applets: + LoadBalancedFargateService: + type: @aws-cdk/aws-ecs:LoadBalancedFargateServiceApplet + properties: + image: 'amazon/amazon-ecs-sample' + cpu: "2048" + memoryMiB: "1024" diff --git a/examples/cdk-examples-typescript/hello-cdk-ecs/index.ts b/examples/cdk-examples-typescript/hello-cdk-ecs/index.ts new file mode 100644 index 0000000000000..9d3c4b0d7d5c8 --- /dev/null +++ b/examples/cdk-examples-typescript/hello-cdk-ecs/index.ts @@ -0,0 +1,37 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import { InstanceType } from '@aws-cdk/aws-ec2'; +import ecs = require('@aws-cdk/aws-ecs'); +import cdk = require('@aws-cdk/cdk'); + +class BonjourECS extends cdk.Stack { + constructor(parent: cdk.App, name: string, props?: cdk.StackProps) { + super(parent, name, props); + + // For better iteration speed, it might make sense to put this VPC into + // a separate stack and import it here. We then have two stacks to + // deploy, but VPC creation is slow so we'll only have to do that once + // and can iterate quickly on consuming stacks. Not doing that for now. + const vpc = new ec2.VpcNetwork(this, 'MyVpc', { maxAZs: 2 }); + const cluster = new ecs.Cluster(this, 'Ec2Cluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ + instanceType: new InstanceType("t2.xlarge"), + instanceCount: 3, + }); + + // Instantiate ECS Service with just cluster and image + const ecsService = new ecs.LoadBalancedEc2Service(this, "Ec2Service", { + cluster, + memoryLimitMiB: 512, + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + }); + + // Output the DNS where you can access your service + new cdk.Output(this, 'LoadBalancerDNS', { value: ecsService.loadBalancer.dnsName }); + } +} + +const app = new cdk.App(); + +new BonjourECS(app, 'Bonjour'); + +app.run(); diff --git a/examples/cdk-examples-typescript/hello-cdk-fargate/index.ts b/examples/cdk-examples-typescript/hello-cdk-fargate/index.ts new file mode 100644 index 0000000000000..7df99aec374c3 --- /dev/null +++ b/examples/cdk-examples-typescript/hello-cdk-fargate/index.ts @@ -0,0 +1,29 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import ecs = require('@aws-cdk/aws-ecs'); +import cdk = require('@aws-cdk/cdk'); + +class BonjourFargate extends cdk.Stack { + constructor(parent: cdk.App, name: string, props?: cdk.StackProps) { + super(parent, name, props); + + // Create VPC and Fargate Cluster + // NOTE: Limit AZs to avoid reaching resource quotas + const vpc = new ec2.VpcNetwork(this, 'MyVpc', { maxAZs: 2 }); + const cluster = new ecs.Cluster(this, 'Cluster', { vpc }); + + // Instantiate Fargate Service with just cluster and image + const fargateService = new ecs.LoadBalancedFargateService(this, "FargateService", { + cluster, + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + }); + + // Output the DNS where you can access your service + new cdk.Output(this, 'LoadBalancerDNS', { value: fargateService.loadBalancer.dnsName }); + } +} + +const app = new cdk.App(); + +new BonjourFargate(app, 'Bonjour'); + +app.run(); diff --git a/examples/cdk-examples-typescript/package.json b/examples/cdk-examples-typescript/package.json index dfe93f1e1d10e..94a3a8845454e 100644 --- a/examples/cdk-examples-typescript/package.json +++ b/examples/cdk-examples-typescript/package.json @@ -28,7 +28,9 @@ "@aws-cdk/aws-cognito": "^0.14.1", "@aws-cdk/aws-dynamodb": "^0.14.1", "@aws-cdk/aws-ec2": "^0.14.1", + "@aws-cdk/aws-ecs": "^0.14.1", "@aws-cdk/aws-elasticloadbalancing": "^0.14.1", + "@aws-cdk/aws-elasticloadbalancingv2": "^0.14.1", "@aws-cdk/aws-iam": "^0.14.1", "@aws-cdk/aws-lambda": "^0.14.1", "@aws-cdk/aws-neptune": "^0.14.1", diff --git a/packages/@aws-cdk/assets/lib/asset.ts b/packages/@aws-cdk/assets/lib/asset.ts index ffef5ebcb3cef..ba26f3a737269 100644 --- a/packages/@aws-cdk/assets/lib/asset.ts +++ b/packages/@aws-cdk/assets/lib/asset.ts @@ -124,7 +124,7 @@ export class Asset extends cdk.Construct { // for tooling to be able to package and upload a directory to the // s3 bucket and plug in the bucket name and key in the correct // parameters. - const asset: cxapi.AssetMetadataEntry = { + const asset: cxapi.FileAssetMetadataEntry = { path: this.assetPath, id: this.uniqueId, packaging: props.packaging, diff --git a/packages/@aws-cdk/aws-applicationautoscaling/lib/base-scalable-attribute.ts b/packages/@aws-cdk/aws-applicationautoscaling/lib/base-scalable-attribute.ts index 4f7ebc66e55b2..94aa6e8764cd2 100644 --- a/packages/@aws-cdk/aws-applicationautoscaling/lib/base-scalable-attribute.ts +++ b/packages/@aws-cdk/aws-applicationautoscaling/lib/base-scalable-attribute.ts @@ -62,21 +62,21 @@ export abstract class BaseScalableAttribute extends cdk.Construct { /** * Scale out or in based on time */ - protected scaleOnSchedule(id: string, props: ScalingSchedule) { + protected doScaleOnSchedule(id: string, props: ScalingSchedule) { this.target.scaleOnSchedule(id, props); } /** * Scale out or in based on a metric value */ - protected scaleOnMetric(id: string, props: BasicStepScalingPolicyProps) { + protected doScaleOnMetric(id: string, props: BasicStepScalingPolicyProps) { this.target.scaleOnMetric(id, props); } /** * Scale out or in in order to keep a metric around a target value */ - protected scaleToTrackMetric(id: string, props: BasicTargetTrackingScalingPolicyProps) { + protected doScaleToTrackMetric(id: string, props: BasicTargetTrackingScalingPolicyProps) { this.target.scaleToTrackMetric(id, props); } } diff --git a/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts b/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts index 584240c470701..2a075b16a9ca3 100644 --- a/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts +++ b/packages/@aws-cdk/aws-autoscaling/lib/auto-scaling-group.ts @@ -183,7 +183,7 @@ export class AutoScalingGroup extends cdk.Construct implements cdk.ITaggable, el vpc: props.vpc, allowAllOutbound: props.allowAllOutbound !== false }); - this.connections = new ec2.Connections({ securityGroup: this.securityGroup }); + this.connections = new ec2.Connections({ securityGroups: [this.securityGroup] }); this.securityGroups.push(this.securityGroup); this.tags = new TagManager(this, {initialTags: props.tags}); this.tags.setTag(NAME_TAG, this.path, { overwrite: false }); diff --git a/packages/@aws-cdk/aws-dynamodb/lib/scalable-table-attribute.ts b/packages/@aws-cdk/aws-dynamodb/lib/scalable-table-attribute.ts index 7a7c1f4dcbbcd..a0fb5e83bf946 100644 --- a/packages/@aws-cdk/aws-dynamodb/lib/scalable-table-attribute.ts +++ b/packages/@aws-cdk/aws-dynamodb/lib/scalable-table-attribute.ts @@ -9,7 +9,7 @@ export class ScalableTableAttribute extends appscaling.BaseScalableAttribute { * Scale out or in based on time */ public scaleOnSchedule(id: string, action: appscaling.ScalingSchedule) { - super.scaleOnSchedule(id, action); + super.doScaleOnSchedule(id, action); } /** @@ -24,7 +24,7 @@ export class ScalableTableAttribute extends appscaling.BaseScalableAttribute { ? appscaling.PredefinedMetric.DynamoDBWriteCapacityUtilization : appscaling.PredefinedMetric.DynamoDBReadCapacityUtilization; - super.scaleToTrackMetric('Tracking', { + super.doScaleToTrackMetric('Tracking', { policyName: props.policyName, disableScaleIn: props.disableScaleIn, scaleInCooldownSec: props.scaleInCooldownSec, diff --git a/packages/@aws-cdk/aws-ec2/lib/connections.ts b/packages/@aws-cdk/aws-ec2/lib/connections.ts index b1bce135e4f44..cd416a4c7ee1e 100644 --- a/packages/@aws-cdk/aws-ec2/lib/connections.ts +++ b/packages/@aws-cdk/aws-ec2/lib/connections.ts @@ -36,11 +36,11 @@ export interface ConnectionsProps { securityGroupRule?: ISecurityGroupRule; /** - * What securityGroup this object is managing connections for + * What securityGroup(s) this object is managing connections for * - * @default No security + * @default No security groups */ - securityGroup?: SecurityGroupRef; + securityGroups?: SecurityGroupRef[]; /** * Default port range for initiating connections to and from this object @@ -59,68 +59,102 @@ export interface ConnectionsProps { * establishing connectivity between security groups, it will automatically * add rules in both security groups * + * This object can manage one or more security groups. */ -export class Connections { +export class Connections implements IConnectable { + public readonly connections: Connections; + + /** + * The default port configured for this connection peer, if available + */ + public readonly defaultPortRange?: IPortRange; + /** * Underlying securityGroup for this Connections object, if present * * May be empty if this Connections object is not managing a SecurityGroup, * but simply representing a Connectable peer. */ - public readonly securityGroup?: SecurityGroupRef; + private readonly _securityGroups = new ReactiveList(); /** * The rule that defines how to represent this peer in a security group */ - public readonly securityGroupRule: ISecurityGroupRule; + private readonly _securityGroupRules = new ReactiveList(); - /** - * The default port configured for this connection peer, if available - */ - public readonly defaultPortRange?: IPortRange; + private skip: boolean = false; - constructor(props: ConnectionsProps) { - if (!props.securityGroupRule && !props.securityGroup) { - throw new Error('Connections: require one of securityGroupRule or securityGroup'); + constructor(props: ConnectionsProps = {}) { + this.connections = this; + this._securityGroups.push(...(props.securityGroups || [])); + + this._securityGroupRules.push(...this._securityGroups.asArray()); + if (props.securityGroupRule) { + this._securityGroupRules.push(props.securityGroupRule); } - this.securityGroupRule = props.securityGroupRule || props.securityGroup!; - this.securityGroup = props.securityGroup; this.defaultPortRange = props.defaultPortRange; } + public get securityGroups(): SecurityGroupRef[] { + return this._securityGroups.asArray(); + } + + /** + * Add a security group to the list of security groups managed by this object + */ + public addSecurityGroup(...securityGroups: SecurityGroupRef[]) { + for (const securityGroup of securityGroups) { + this._securityGroups.push(securityGroup); + this._securityGroupRules.push(securityGroup); + } + } + /** * Allow connections to the peer on the given port */ public allowTo(other: IConnectable, portRange: IPortRange, description?: string) { - if (this.securityGroup) { - this.securityGroup.addEgressRule(other.connections.securityGroupRule, portRange, description); - } - if (other.connections.securityGroup) { - other.connections.securityGroup.addIngressRule(this.securityGroupRule, portRange, description); + if (this.skip) { return; } - } + this._securityGroups.forEachAndForever(securityGroup => { + other.connections._securityGroupRules.forEachAndForever(rule => { + securityGroup.addEgressRule(rule, portRange, description); + }); + }); + + this.skip = true; + other.connections.allowFrom(this, portRange, description); + this.skip = false; } /** * Allow connections from the peer on the given port */ public allowFrom(other: IConnectable, portRange: IPortRange, description?: string) { - if (this.securityGroup) { - this.securityGroup.addIngressRule(other.connections.securityGroupRule, portRange, description); - } - if (other.connections.securityGroup) { - other.connections.securityGroup.addEgressRule(this.securityGroupRule, portRange, description); - } + if (this.skip) { return; } + + this._securityGroups.forEachAndForever(securityGroup => { + other.connections._securityGroupRules.forEachAndForever(rule => { + securityGroup.addIngressRule(rule, portRange, description); + }); + }); + + this.skip = true; + other.connections.allowTo(this, portRange, description); + this.skip = false; } /** * Allow hosts inside the security group to connect to each other on the given port */ public allowInternally(portRange: IPortRange, description?: string) { - if (this.securityGroup) { - this.securityGroup.addIngressRule(this.securityGroupRule, portRange, description); - } + this._securityGroups.forEachAndForever(securityGroup => { + this._securityGroupRules.forEachAndForever(rule => { + securityGroup.addIngressRule(rule, portRange, description); + // FIXME: this seems required but we didn't use to have it. Research. + // securityGroup.addEgressRule(rule, portRange, description); + }); + }); } /** @@ -192,3 +226,34 @@ export class Connections { this.allowTo(other, this.defaultPortRange, description); } } + +type Action = (x: T) => void; + +class ReactiveList { + private readonly elements = new Array(); + private readonly listeners = new Array>(); + + public push(...xs: T[]) { + this.elements.push(...xs); + for (const listener of this.listeners) { + for (const x of xs) { + listener(x); + } + } + } + + public forEachAndForever(listener: Action) { + for (const element of this.elements) { + listener(element); + } + this.listeners.push(listener); + } + + public asArray(): T[] { + return this.elements.slice(); + } + + public get length(): number { + return this.elements.length; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ec2/lib/security-group.ts b/packages/@aws-cdk/aws-ec2/lib/security-group.ts index 49b187bc107bd..c2d64f1eaa544 100644 --- a/packages/@aws-cdk/aws-ec2/lib/security-group.ts +++ b/packages/@aws-cdk/aws-ec2/lib/security-group.ts @@ -24,7 +24,7 @@ export abstract class SecurityGroupRef extends Construct implements ISecurityGro public abstract readonly securityGroupId: string; public readonly canInlineRule = false; - public readonly connections = new Connections({ securityGroup: this }); + public readonly connections = new Connections({ securityGroups: [this] }); /** * FIXME: Where to place this?? diff --git a/packages/@aws-cdk/aws-ec2/test/test.connections.ts b/packages/@aws-cdk/aws-ec2/test/test.connections.ts index 6a56884152f91..eea6a340b7a50 100644 --- a/packages/@aws-cdk/aws-ec2/test/test.connections.ts +++ b/packages/@aws-cdk/aws-ec2/test/test.connections.ts @@ -3,231 +3,162 @@ import { Stack } from '@aws-cdk/cdk'; import { Test } from 'nodeunit'; import { - AllTraffic, - AnyIPv4, - AnyIPv6, Connections, - IcmpAllTypeCodes, - IcmpAllTypesAndCodes, - IcmpPing, - IcmpTypeAndCode, IConnectable, - PrefixList, SecurityGroup, SecurityGroupRef, TcpAllPorts, TcpPort, - TcpPortFromAttribute, - TcpPortRange, - UdpAllPorts, - UdpPort, - UdpPortFromAttribute, - UdpPortRange, VpcNetwork } from "../lib"; export = { - 'security group can allows all outbound traffic by default'(test: Test) { + 'peering between two security groups does not recursive infinitely'(test: Test) { // GIVEN - const stack = new Stack(); + const stack = new Stack(undefined, 'TestStack', { env: { account: '12345678', region: 'dummy' }}); + const vpc = new VpcNetwork(stack, 'VPC'); + const sg1 = new SecurityGroup(stack, 'SG1', { vpc }); + const sg2 = new SecurityGroup(stack, 'SG2', { vpc }); - // WHEN - new SecurityGroup(stack, 'SG1', { vpc, allowAllOutbound: true }); + const conn1 = new SomethingConnectable(new Connections({ securityGroups: [sg1] })); + const conn2 = new SomethingConnectable(new Connections({ securityGroups: [sg2] })); - // THEN - expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { - SecurityGroupEgress: [ - { - CidrIp: "0.0.0.0/0", - Description: "Allow all outbound traffic by default", - IpProtocol: "-1" - } - ], - })); + // WHEN + conn1.connections.allowTo(conn2, new TcpPort(80), 'Test'); + // THEN -- it finishes! test.done(); }, - 'no new outbound rule is added if we are allowing all traffic anyway'(test: Test) { + '(imported) SecurityGroup can be used as target of .allowTo()'(test: Test) { // GIVEN const stack = new Stack(); const vpc = new VpcNetwork(stack, 'VPC'); + const sg1 = new SecurityGroup(stack, 'SomeSecurityGroup', { vpc, allowAllOutbound: false }); + const somethingConnectable = new SomethingConnectable(new Connections({ securityGroups: [sg1] })); + + const securityGroup = SecurityGroupRef.import(stack, 'ImportedSG', { securityGroupId: 'sg-12345' }); // WHEN - const sg = new SecurityGroup(stack, 'SG1', { vpc, allowAllOutbound: true }); - sg.addEgressRule(new AnyIPv4(), new TcpPort(86), 'This does not show up'); + somethingConnectable.connections.allowTo(securityGroup, new TcpAllPorts(), 'Connect there'); - // THEN - expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { - SecurityGroupEgress: [ - { - CidrIp: "0.0.0.0/0", - Description: "Allow all outbound traffic by default", - IpProtocol: "-1" - }, - ], + // THEN: rule to generated security group to connect to imported + expect(stack).to(haveResource("AWS::EC2::SecurityGroupEgress", { + GroupId: { "Fn::GetAtt": [ "SomeSecurityGroupEF219AD6", "GroupId" ] }, + IpProtocol: "tcp", + Description: "Connect there", + DestinationSecurityGroupId: "sg-12345", + FromPort: 0, + ToPort: 65535 + })); + + // THEN: rule to imported security group to allow connections from generated + expect(stack).to(haveResource("AWS::EC2::SecurityGroupIngress", { + IpProtocol: "tcp", + Description: "Connect there", + FromPort: 0, + GroupId: "sg-12345", + SourceSecurityGroupId: { "Fn::GetAtt": [ "SomeSecurityGroupEF219AD6", "GroupId" ] }, + ToPort: 65535 })); test.done(); }, - 'security group disallow outbound traffic by default'(test: Test) { + 'security groups added to connections after rule still gets rule'(test: Test) { // GIVEN const stack = new Stack(); const vpc = new VpcNetwork(stack, 'VPC'); + const sg1 = new SecurityGroup(stack, 'SecurityGroup1', { vpc, allowAllOutbound: false }); + const sg2 = new SecurityGroup(stack, 'SecurityGroup2', { vpc, allowAllOutbound: false }); + const connections = new Connections({ securityGroups: [sg1] }); // WHEN - new SecurityGroup(stack, 'SG1', { vpc, allowAllOutbound: false }); + connections.allowFromAnyIPv4(new TcpPort(88)); + connections.addSecurityGroup(sg2); // THEN expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { - SecurityGroupEgress: [ + GroupDescription: "SecurityGroup1", + SecurityGroupIngress: [ { - CidrIp: "255.255.255.255/32", - Description: "Disallow all traffic", - FromPort: 252, - IpProtocol: "icmp", - ToPort: 86 + CidrIp: "0.0.0.0/0", + FromPort: 88, + ToPort: 88 } - ], + ] })); - test.done(); - }, - - 'bogus outbound rule disappears if another rule is added'(test: Test) { - // GIVEN - const stack = new Stack(); - const vpc = new VpcNetwork(stack, 'VPC'); - - // WHEN - const sg = new SecurityGroup(stack, 'SG1', { vpc, allowAllOutbound: false }); - sg.addEgressRule(new AnyIPv4(), new TcpPort(86), 'This replaces the other one'); - - // THEN expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { - SecurityGroupEgress: [ + GroupDescription: "SecurityGroup2", + SecurityGroupIngress: [ { CidrIp: "0.0.0.0/0", - Description: "This replaces the other one", - FromPort: 86, - IpProtocol: "tcp", - ToPort: 86 + FromPort: 88, + ToPort: 88 } - ], + ] })); test.done(); }, - 'all outbound rule cannot be added after creation'(test: Test) { + 'when security groups are added to target they also get the rule'(test: Test) { // GIVEN const stack = new Stack(); const vpc = new VpcNetwork(stack, 'VPC'); + const sg1 = new SecurityGroup(stack, 'SecurityGroup1', { vpc, allowAllOutbound: false }); + const sg2 = new SecurityGroup(stack, 'SecurityGroup2', { vpc, allowAllOutbound: false }); + const sg3 = new SecurityGroup(stack, 'SecurityGroup3', { vpc, allowAllOutbound: false }); + const connections1 = new Connections({ securityGroups: [sg1] }); + const connections2 = new Connections({ securityGroups: [sg2] }); + const connectable = new SomethingConnectable(connections2); // WHEN - const sg = new SecurityGroup(stack, 'SG1', { vpc, allowAllOutbound: false }); - test.throws(() => { - sg.addEgressRule(new AnyIPv4(), new AllTraffic(), 'All traffic'); - }, /Cannot add/); - - test.done(); - }, - - 'peering between two security groups does not recursive infinitely'(test: Test) { - // GIVEN - const stack = new Stack(undefined, 'TestStack', { env: { account: '12345678', region: 'dummy' }}); - - const vpc = new VpcNetwork(stack, 'VPC'); - const sg1 = new SecurityGroup(stack, 'SG1', { vpc }); - const sg2 = new SecurityGroup(stack, 'SG2', { vpc }); - - const conn1 = new SomethingConnectable(new Connections({ securityGroup: sg1 })); - const conn2 = new SomethingConnectable(new Connections({ securityGroup: sg2 })); - - // WHEN - conn1.connections.allowTo(conn2, new TcpPort(80), 'Test'); + connections1.allowTo(connectable, new TcpPort(88)); + connections2.addSecurityGroup(sg3); // THEN - test.done(); - }, - - '(imported) SecurityGroup can be used as target of .allowTo()'(test: Test) { - // GIVEN - const stack = new Stack(); - const vpc = new VpcNetwork(stack, 'VPC'); - const sg1 = new SecurityGroup(stack, 'SomeSecurityGroup', { vpc, allowAllOutbound: false }); - const somethingConnectable = new SomethingConnectable(new Connections({ securityGroup: sg1 })); - - const securityGroup = SecurityGroupRef.import(stack, 'ImportedSG', { securityGroupId: 'sg-12345' }); - - // WHEN - somethingConnectable.connections.allowTo(securityGroup, new TcpAllPorts(), 'Connect there'); - - // THEN: rule to generated security group to connect to imported - expect(stack).to(haveResource("AWS::EC2::SecurityGroupEgress", { - GroupId: { "Fn::GetAtt": [ "SomeSecurityGroupEF219AD6", "GroupId" ] }, - IpProtocol: "tcp", - Description: "Connect there", - DestinationSecurityGroupId: "sg-12345", - FromPort: 0, - ToPort: 65535 + expect(stack).to(haveResource('AWS::EC2::SecurityGroupIngress', { + GroupId: { "Fn::GetAtt": [ "SecurityGroup23BE86BB7", "GroupId" ] }, + SourceSecurityGroupId: { "Fn::GetAtt": [ "SecurityGroup1F554B36F", "GroupId" ] }, + FromPort: 88, + ToPort: 88 })); - // THEN: rule to imported security group to allow connections from generated - expect(stack).to(haveResource("AWS::EC2::SecurityGroupIngress", { - IpProtocol: "tcp", - Description: "Connect there", - FromPort: 0, - GroupId: "sg-12345", - SourceSecurityGroupId: { "Fn::GetAtt": [ "SomeSecurityGroupEF219AD6", "GroupId" ] }, - ToPort: 65535 + expect(stack).to(haveResource('AWS::EC2::SecurityGroupIngress', { + GroupId: { "Fn::GetAtt": [ "SecurityGroup3E5E374B9", "GroupId" ] }, + SourceSecurityGroupId: { "Fn::GetAtt": [ "SecurityGroup1F554B36F", "GroupId" ] }, + FromPort: 88, + ToPort: 88 })); test.done(); }, - 'peer between all types of peers and port range types'(test: Test) { + 'multiple security groups allows internally between them'(test: Test) { // GIVEN - const stack = new Stack(undefined, 'TestStack', { env: { account: '12345678', region: 'dummy' }}); + const stack = new Stack(); const vpc = new VpcNetwork(stack, 'VPC'); - const sg = new SecurityGroup(stack, 'SG', { vpc }); - - const peers = [ - new SecurityGroup(stack, 'PeerGroup', { vpc }), - new AnyIPv4(), - new AnyIPv6(), - new PrefixList('pl-012345'), - ]; - - const ports = [ - new TcpPort(1234), - new TcpPortFromAttribute("tcp-test-port!"), - new TcpAllPorts(), - new TcpPortRange(80, 90), - new UdpPort(2345), - new UdpPortFromAttribute("udp-test-port!"), - new UdpAllPorts(), - new UdpPortRange(85, 95), - new IcmpTypeAndCode(5, 1), - new IcmpAllTypeCodes(8), - new IcmpAllTypesAndCodes(), - new IcmpPing(), - new AllTraffic() - ]; + const sg1 = new SecurityGroup(stack, 'SecurityGroup1', { vpc, allowAllOutbound: false }); + const sg2 = new SecurityGroup(stack, 'SecurityGroup2', { vpc, allowAllOutbound: false }); + const connections = new Connections({ securityGroups: [sg1] }); // WHEN - for (const peer of peers) { - for (const port of ports) { - sg.connections.allowTo(peer, port); - } - } + connections.allowInternally(new TcpPort(88)); + connections.addSecurityGroup(sg2); - // THEN -- no crash + // THEN + expect(stack).to(haveResource('AWS::EC2::SecurityGroupIngress', { + GroupId: { "Fn::GetAtt": [ "SecurityGroup1F554B36F", "GroupId" ] }, + SourceSecurityGroupId: { "Fn::GetAtt": [ "SecurityGroup1F554B36F", "GroupId" ] }, + FromPort: 88, + ToPort: 88 + })); test.done(); - } + }, }; class SomethingConnectable implements IConnectable { diff --git a/packages/@aws-cdk/aws-ec2/test/test.security-group.ts b/packages/@aws-cdk/aws-ec2/test/test.security-group.ts new file mode 100644 index 0000000000000..0219f16350f7f --- /dev/null +++ b/packages/@aws-cdk/aws-ec2/test/test.security-group.ts @@ -0,0 +1,175 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import { Stack } from '@aws-cdk/cdk'; +import { Test } from 'nodeunit'; + +import { + AllTraffic, + AnyIPv4, + AnyIPv6, + IcmpAllTypeCodes, + IcmpAllTypesAndCodes, + IcmpPing, + IcmpTypeAndCode, + PrefixList, + SecurityGroup, + TcpAllPorts, + TcpPort, + TcpPortFromAttribute, + TcpPortRange, + UdpAllPorts, + UdpPort, + UdpPortFromAttribute, + UdpPortRange, + VpcNetwork +} from "../lib"; + +export = { + 'security group can allows all outbound traffic by default'(test: Test) { + // GIVEN + const stack = new Stack(); + const vpc = new VpcNetwork(stack, 'VPC'); + + // WHEN + new SecurityGroup(stack, 'SG1', { vpc, allowAllOutbound: true }); + + // THEN + expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [ + { + CidrIp: "0.0.0.0/0", + Description: "Allow all outbound traffic by default", + IpProtocol: "-1" + } + ], + })); + + test.done(); + }, + + 'no new outbound rule is added if we are allowing all traffic anyway'(test: Test) { + // GIVEN + const stack = new Stack(); + const vpc = new VpcNetwork(stack, 'VPC'); + + // WHEN + const sg = new SecurityGroup(stack, 'SG1', { vpc, allowAllOutbound: true }); + sg.addEgressRule(new AnyIPv4(), new TcpPort(86), 'This does not show up'); + + // THEN + expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [ + { + CidrIp: "0.0.0.0/0", + Description: "Allow all outbound traffic by default", + IpProtocol: "-1" + }, + ], + })); + + test.done(); + }, + + 'security group disallow outbound traffic by default'(test: Test) { + // GIVEN + const stack = new Stack(); + const vpc = new VpcNetwork(stack, 'VPC'); + + // WHEN + new SecurityGroup(stack, 'SG1', { vpc, allowAllOutbound: false }); + + // THEN + expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [ + { + CidrIp: "255.255.255.255/32", + Description: "Disallow all traffic", + FromPort: 252, + IpProtocol: "icmp", + ToPort: 86 + } + ], + })); + + test.done(); + }, + + 'bogus outbound rule disappears if another rule is added'(test: Test) { + // GIVEN + const stack = new Stack(); + const vpc = new VpcNetwork(stack, 'VPC'); + + // WHEN + const sg = new SecurityGroup(stack, 'SG1', { vpc, allowAllOutbound: false }); + sg.addEgressRule(new AnyIPv4(), new TcpPort(86), 'This replaces the other one'); + + // THEN + expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + SecurityGroupEgress: [ + { + CidrIp: "0.0.0.0/0", + Description: "This replaces the other one", + FromPort: 86, + IpProtocol: "tcp", + ToPort: 86 + } + ], + })); + + test.done(); + }, + + 'all outbound rule cannot be added after creation'(test: Test) { + // GIVEN + const stack = new Stack(); + const vpc = new VpcNetwork(stack, 'VPC'); + + // WHEN + const sg = new SecurityGroup(stack, 'SG1', { vpc, allowAllOutbound: false }); + test.throws(() => { + sg.addEgressRule(new AnyIPv4(), new AllTraffic(), 'All traffic'); + }, /Cannot add/); + + test.done(); + }, + + 'peer between all types of peers and port range types'(test: Test) { + // GIVEN + const stack = new Stack(undefined, 'TestStack', { env: { account: '12345678', region: 'dummy' }}); + const vpc = new VpcNetwork(stack, 'VPC'); + const sg = new SecurityGroup(stack, 'SG', { vpc }); + + const peers = [ + new SecurityGroup(stack, 'PeerGroup', { vpc }), + new AnyIPv4(), + new AnyIPv6(), + new PrefixList('pl-012345'), + ]; + + const ports = [ + new TcpPort(1234), + new TcpPortFromAttribute("tcp-test-port!"), + new TcpAllPorts(), + new TcpPortRange(80, 90), + new UdpPort(2345), + new UdpPortFromAttribute("udp-test-port!"), + new UdpAllPorts(), + new UdpPortRange(85, 95), + new IcmpTypeAndCode(5, 1), + new IcmpAllTypeCodes(8), + new IcmpAllTypesAndCodes(), + new IcmpPing(), + new AllTraffic() + ]; + + // WHEN + for (const peer of peers) { + for (const port of ports) { + sg.connections.allowTo(peer, port); + } + } + + // THEN -- no crash + + test.done(); + } +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts b/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts index f1acfaa91c822..2e41764d145c0 100644 --- a/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts +++ b/packages/@aws-cdk/aws-ecr/lib/repository-ref.ts @@ -44,6 +44,31 @@ export abstract class RepositoryRef extends cdk.Construct { const parts = cdk.ArnUtils.parse(this.repositoryArn); return `${parts.account}.dkr.ecr.${parts.region}.amazonaws.com/${parts.resourceName}`; } + + /** + * Grant the given principal identity permissions to perform the actions on this repository + */ + public grant(identity?: iam.IPrincipal, ...actions: string[]) { + if (!identity) { + return; + } + identity.addToPolicy(new iam.PolicyStatement() + .addResource(this.repositoryArn) + .addActions(...actions)); + } + + /** + * Grant the given identity permissions to use the images in this repository + */ + public grantUseImage(identity?: iam.IPrincipal) { + this.grant(identity, "ecr:BatchCheckLayerAvailability", "ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage"); + + if (identity) { + identity.addToPolicy(new iam.PolicyStatement() + .addActions("ecr:GetAuthorizationToken", "logs:CreateLogStream", "logs:PutLogEvents") + .addAllResources()); + } + } } export interface RepositoryRefProps { @@ -66,4 +91,4 @@ class ImportedRepository extends RepositoryRef { public addToResourcePolicy(_statement: iam.PolicyStatement) { // FIXME: Add annotation about policy we dropped on the floor } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/.gitignore b/packages/@aws-cdk/aws-ecs/.gitignore index 5433c34b70acc..ead522a97b70f 100644 --- a/packages/@aws-cdk/aws-ecs/.gitignore +++ b/packages/@aws-cdk/aws-ecs/.gitignore @@ -13,4 +13,5 @@ dist coverage .nycrc .LAST_PACKAGE -*.snk \ No newline at end of file +!lib/images/adopt-repository/* +*.snk diff --git a/packages/@aws-cdk/aws-ecs/.npmignore b/packages/@aws-cdk/aws-ecs/.npmignore index b757d55c46996..d26c71701070b 100644 --- a/packages/@aws-cdk/aws-ecs/.npmignore +++ b/packages/@aws-cdk/aws-ecs/.npmignore @@ -12,5 +12,5 @@ dist # Include .jsii !.jsii - -*.snk \ No newline at end of file +!lib/adopt-repository/* +*.snk diff --git a/packages/@aws-cdk/aws-ecs/README.md b/packages/@aws-cdk/aws-ecs/README.md index 0b281e1184740..5781b1575cecb 100644 --- a/packages/@aws-cdk/aws-ecs/README.md +++ b/packages/@aws-cdk/aws-ecs/README.md @@ -1,2 +1,243 @@ -## The CDK Construct Library for AWS Elastic Container Service (ECS) -This module is part of the [AWS Cloud Development Kit](https://github.com/awslabs/aws-cdk) project. +## AWS Elastic Container Service (ECS) Construct Library + +This package contains constructs for working with **AWS Elastic Container +Service** (ECS). The simplest example of using this library looks like this: + +```ts +// Create an ECS cluster +const cluster = new ecs.Cluster(this, 'Cluster', { + vpc, +}); + +// Add capacity to it +cluster.addDefaultAutoScalingGroupCapacity({ + instanceType: new InstanceType("t2.xlarge"), + instanceCount: 3, +}); + +// Instantiate ECS Service with an automatic load balancer +const ecsService = new ecs.LoadBalancedEc2Service(this, 'Service', { + cluster, + memoryLimitMiB: 512, + image: ecs.ContainerImage.fromDockerHub("amazon/amazon-ecs-sample"), +}); +``` + +### Fargate vs ECS + +There are two sets of constructs in this library; one to run tasks on ECS and +one to run Tasks on Fargate. + +- Use the `Ec2TaskDefinition` and `Ec2Service` constructs to run tasks on EC2 instances running in your account. +- Use the `FargateTaskDefinition` and `FargateService` constructs to run tasks on + instances that are managed for you by AWS. + +Here are the main differences: + +- **EC2**: instances are under your control. Complete control of task to host + allocation. Required to specify at least a memory reseration or limit for + every container. Can use Host, Bridge and AwsVpc networking modes. Can attach + Classic Load Balancer. Can share volumes between container and host. +- **Fargate**: tasks run on AWS-managed instances, AWS manages task to host + allocation for you. Requires specification of memory and cpu sizes at the + taskdefinition level. Only supports AwsVpc networking modes and + Application/Network Load Balancers. Only the AWS log driver is supported. + Many host features are not supported such as adding kernel capabilities + and mounting host devices/volumes inside the container. + +For more information on EC2 vs Fargate and networking see the AWS Documentation: +[AWS Fargate](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/AWS_Fargate.html) and +[Task Networking](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-networking.html). + +### Clusters + +A `Cluster` defines the infrastructure to run your +tasks on. You can run many tasks on a single cluster. + +To create a cluster that can run Fargate tasks, go: + +```ts +const cluster = new ecs.Cluster(this, 'Cluster', { + vpc: vpc +}); +``` + +If you wish to use tasks with EC2 launch-type, you also have to add capacity to +your cluster in order for tasks to be scheduled on your instances. Typically, +you will add an AutoScalingGroup with instances running the latest +ECS-optimized AMI to the cluster. There is a method to build and add such an +AutoScalingGroup automatically, or you can supply a customized AutoScalingGroup +that you construct yourself. It's possible to add multiple AutoScalingGroups +with various instance types if you want to. + +Creating an ECS cluster and adding capacity to it looks like this: + +```ts +const cluster = new ecs.Cluster(this, 'Cluster', { + vpc: vpc +}); + +// Either add default capacity +cluster.addDefaultAutoScalingGroupCapacity({ + instanceType: new ec2.InstanceType("t2.xlarge"), + instanceCount: 3, +}); + +// Or add customized capacity. Be sure to start the ECS-optimized AMI. +const autoScalingGroup = new autoscaling.AutoScalingGroup(this, 'ASG', { + vpc, + instanceType: new ec2.InstanceType('t2.xlarge'), + machineImage: new EcsOptimizedAmi(), + desiredCapacity: 3, + // ... other options here ... +}); + +cluster.addAutoScalingGroupCapacity(autoScalingGroup); +``` + +### Task definitions +A Task Definition describes what a single copy of a **Task** should look like. +A task definition has one or more containers; typically, it has one +main container (the *default container* is the first one that's added +to the task definition, and it will be marked *essential*) and optionally +some supporting containers which are used to support the main container, +doings things like upload logs or metrics to monitoring services. + +To run a task or service with EC2 launch type, use the `Ec2TaskDefinition`. For Fargate tasks/services, use the +`FargateTaskDefinition`. These classes provide a simplified API that only contain +properties relevant for that specific launch type. + +For a `FargateTaskDefinition`, specify the task size (`memoryMiB` and `cpu`): + +```ts +const fargateTaskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDef', { + memoryMiB: '512' + cpu: 256, +}); +``` +To add containers to a Task Definition, call `addContainer()`: + +```ts +const container = fargateTaskDefinition.addContainer(this, { + // Use an image from DockerHub + image: ecs.ContainerImage.fromDockerHub("amazon/amazon-ecs-sample"), + // ... other options here ... +}); +``` + +For a `Ec2TaskDefinition`: + +```ts +const ec2TaskDefinition = new ecs.Ec2TaskDefinition(this, 'TaskDef', { + networkMode: bridge +}); + +const container = ec2TaskDefinition.addContainer(this, { + // Use an image from DockerHub + image: ecs.ContainerImage.fromDockerHub("amazon/amazon-ecs-sample"), + memoryLimitMiB: 1024 + // ... other options here ... +}); +``` + +You can specify container properties when you add them to the task definition, or with various methods, e.g.: + +```ts +container.addPortMappings({ + containerPort: 3000 +}) +``` + +If you wish to use a TaskDefinition that can be used with either EC2 or +Fargate launch types, there is also the `TaskDefinition` construct. + +When creating a Task Definition you have to specify what kind of +tasks you intend to run: EC2, Fargate, or both: + +```ts +const taskDefinition = new ecs.TaskDefinition(this, 'TaskDef', { + memoryMiB: '512' + cpu: 256, + networkMode: 'awsvpc', + compatibility: ecs.Compatibility.Ec2AndFargate, +}); +``` + +#### Images + +Images supply the software that runs inside the container. Images can be +obtained from either DockerHub or from ECR repositories: + +* `ecs.ContainerImage.fromDockerHub(imageName)`: use a publicly available image from + DockerHub. +* `ecs.ContaienrImage.fromEcrRepository(repo, tag)`: use the given ECR repository as the image + to start. +* `ecs.ContainerImage.fromAsset({ directory: './image' })`: build and upload an + image directly from a `Dockerfile` in your source directory. + +### Service + +A `Service` instantiates a `TaskDefinition` on a `Cluster` a given number of +times, optionally associating them with a load balancer. Tasks that fail will +automatically be restarted. + +```ts +const taskDefinition; + +const service = new ecs.FargateService(this, 'Service', { + cluster, + taskDefinition, + desiredCount: 5 +}); +``` + +#### Include a load balancer + +`Services` are load balancing targets and can be directly attached to load +balancers: + +```ts +const service = new ecs.FargateService(this, 'Service', { /* ... */ }); + +const lb = new elbv2.ApplicationLoadBalancer(this, 'LB', { vpc, internetFacing: true }); +const listener = lb.addListener('Listener', { port: 80 }); +listener.addTargets('ECS', { + port: 80, + targets: [service] +}); +``` + +There are two higher-level constructs available which include a load balancer for you: + +* `LoadBalancedFargateService` +* `LoadBalancedEc2Service` + +### Task AutoScaling + +You can configure the task count of a service to match demand. Task AutoScaling is +configured by calling `autoScaleTaskCount()`: + +```ts +const scaling = service.autoScaleTaskCount({ maxCapacity: 10 }); +scaling.scaleOnCpuUtilization('CpuScaling', { + targetUtilizationPercent: 50 +}); +``` + +Task AutoScaling is powered by *Application AutoScaling*. Refer to that for +more information. + +### Instance AutoScaling + +If you're running on Fargate, AWS will manage the physical machines that your +containers are running on for you. If you're running an ECS cluster however, +your EC2 instances might fill up as your number of Tasks goes up. + +To avoid placement errors, you will want to configure AutoScaling for your +EC2 instance group so that your instance count scales with demand. + +### Roadmap + +- [ ] Instance AutoScaling +- [ ] Service Discovery Integration +- [ ] Private registry authentication \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts new file mode 100644 index 0000000000000..2883955def9f6 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts @@ -0,0 +1,242 @@ +import appscaling = require('@aws-cdk/aws-applicationautoscaling'); +import cloudwatch = require('@aws-cdk/aws-cloudwatch'); +import ec2 = require('@aws-cdk/aws-ec2'); +import elbv2 = require('@aws-cdk/aws-elasticloadbalancingv2'); +import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); +import { NetworkMode, TaskDefinition } from '../base/task-definition'; +import { cloudformation } from '../ecs.generated'; +import { ScalableTaskCount } from './scalable-task-count'; + +/** + * Basic service properties + */ +export interface BaseServiceProps { + /** + * Number of desired copies of running tasks + * + * @default 1 + */ + desiredCount?: number; + + /** + * A name for the service. + * + * @default CloudFormation-generated name + */ + serviceName?: string; + + /** + * The maximum number of tasks, specified as a percentage of the Amazon ECS + * service's DesiredCount value, that can run in a service during a + * deployment. + * + * @default 200 + */ + maximumPercent?: number; + + /** + * The minimum number of tasks, specified as a percentage of + * the Amazon ECS service's DesiredCount value, that must + * continue to run and remain healthy during a deployment. + * + * @default 50 + */ + minimumHealthyPercent?: number; + + /** + * Time after startup to ignore unhealthy load balancer checks. + * + * @default ??? FIXME + */ + healthCheckGracePeriodSeconds?: number; +} + +/** + * Base class for Ecs and Fargate services + */ +export abstract class BaseService extends cdk.Construct + implements elbv2.IApplicationLoadBalancerTarget, elbv2.INetworkLoadBalancerTarget, cdk.IDependable { + + /** + * CloudFormation resources generated by this service + */ + public readonly dependencyElements: cdk.IDependable[]; + + /** + * Manage allowed network traffic for this service + */ + public readonly connections: ec2.Connections = new ec2.Connections(); + + /** + * ARN of this service + */ + public readonly serviceArn: string; + + /** + * Name of this service + */ + public readonly serviceName: string; + + /** + * Name of this service's cluster + */ + public readonly clusterName: string; + + /** + * Task definition this service is associated with + */ + public readonly taskDefinition: TaskDefinition; + + protected loadBalancers = new Array(); + protected networkConfiguration?: cloudformation.ServiceResource.NetworkConfigurationProperty; + private readonly resource: cloudformation.ServiceResource; + private scalableTaskCount?: ScalableTaskCount; + + constructor(parent: cdk.Construct, + name: string, + props: BaseServiceProps, + additionalProps: any, + clusterName: string, + taskDefinition: TaskDefinition) { + super(parent, name); + + this.taskDefinition = taskDefinition; + + this.resource = new cloudformation.ServiceResource(this, "Service", { + desiredCount: props.desiredCount || 1, + serviceName: props.serviceName, + loadBalancers: new cdk.Token(() => this.loadBalancers), + deploymentConfiguration: { + maximumPercent: props.maximumPercent || 200, + minimumHealthyPercent: props.minimumHealthyPercent || 50 + }, + /* role: never specified, supplanted by Service Linked Role */ + networkConfiguration: new cdk.Token(() => this.networkConfiguration), + ...additionalProps + }); + this.serviceArn = this.resource.serviceArn; + this.serviceName = this.resource.serviceName; + this.dependencyElements = [this.resource]; + this.clusterName = clusterName; + } + + /** + * Called when the service is attached to an ALB + * + * Don't call this function directly. Instead, call listener.addTarget() + * to add this service to a load balancer. + */ + public attachToApplicationTargetGroup(targetGroup: elbv2.ApplicationTargetGroup): elbv2.LoadBalancerTargetProps { + const ret = this.attachToELBv2(targetGroup); + + // Open up security groups. For dynamic port mapping, we won't know the port range + // in advance so we need to open up all ports. + const port = this.taskDefinition.defaultContainer!.ingressPort; + const portRange = port === 0 ? EPHEMERAL_PORT_RANGE : new ec2.TcpPort(port); + targetGroup.registerConnectable(this, portRange); + + return ret; + } + + /** + * Called when the service is attached to an NLB + * + * Don't call this function directly. Instead, call listener.addTarget() + * to add this service to a load balancer. + */ + public attachToNetworkTargetGroup(targetGroup: elbv2.NetworkTargetGroup): elbv2.LoadBalancerTargetProps { + return this.attachToELBv2(targetGroup); + } + + /** + * Enable autoscaling for the number of tasks in this service + */ + public autoScaleTaskCount(props: appscaling.EnableScalingProps) { + if (this.scalableTaskCount) { + throw new Error('AutoScaling of task count already enabled for this service'); + } + + return this.scalableTaskCount = new ScalableTaskCount(this, 'TaskCount', { + serviceNamespace: appscaling.ServiceNamespace.Ecs, + resourceId: `service/${this.clusterName}/${this.resource.serviceName}`, + dimension: 'ecs:service:DesiredCount', + role: this.makeAutoScalingRole(), + ...props + }); + } + + /** + * Return the given named metric for this Service + */ + public metric(metricName: string, props?: cloudwatch.MetricCustomization): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace: 'AWS/ECS', + metricName, + dimensions: { ServiceName: this.serviceName }, + ...props + }); + } + + /** + * Set up AWSVPC networking for this construct + */ + // tslint:disable-next-line:max-line-length + protected configureAwsVpcNetworking(vpc: ec2.VpcNetworkRef, assignPublicIp?: boolean, vpcPlacement?: ec2.VpcPlacementStrategy, securityGroup?: ec2.SecurityGroupRef) { + if (vpcPlacement === undefined) { + vpcPlacement = { subnetsToUse: assignPublicIp ? ec2.SubnetType.Public : ec2.SubnetType.Private }; + } + if (securityGroup === undefined) { + securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', { vpc }); + } + const subnets = vpc.subnets(vpcPlacement); + this.connections.addSecurityGroup(securityGroup); + + this.networkConfiguration = { + awsvpcConfiguration: { + assignPublicIp: assignPublicIp ? 'ENABLED' : 'DISABLED', + subnets: subnets.map(x => x.subnetId), + securityGroups: new cdk.Token(() => [securityGroup!.securityGroupId]), + } + }; + } + + /** + * Shared logic for attaching to an ELBv2 + */ + private attachToELBv2(targetGroup: elbv2.ITargetGroup): elbv2.LoadBalancerTargetProps { + if (this.taskDefinition.networkMode === NetworkMode.None) { + throw new Error("Cannot use a load balancer if NetworkMode is None. Use Bridge, Host or AwsVpc instead."); + } + + this.loadBalancers.push({ + targetGroupArn: targetGroup.targetGroupArn, + containerName: this.taskDefinition.defaultContainer!.id, + containerPort: this.taskDefinition.defaultContainer!.containerPort, + }); + + this.resource.addDependency(targetGroup.listenerDependency()); + + const targetType = this.taskDefinition.networkMode === NetworkMode.AwsVpc ? elbv2.TargetType.Ip : elbv2.TargetType.Instance; + return { targetType }; + } + + /** + * Generate the role that will be used for autoscaling this service + */ + private makeAutoScalingRole(): iam.IRole { + // Use a Service Linked Role. + return iam.Role.import(this, 'ScalingRole', { + roleArn: cdk.ArnUtils.fromComponents({ + service: 'iam', + resource: 'role/aws-service-role/ecs.application-autoscaling.amazonaws.com', + resourceName: 'AWSServiceRoleForApplicationAutoScaling_ECSService', + }) + }); + } +} + +/** + * The port range to open up for dynamic port mapping + */ +const EPHEMERAL_PORT_RANGE = new ec2.TcpPortRange(32768, 65535); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/lib/base/scalable-task-count.ts b/packages/@aws-cdk/aws-ecs/lib/base/scalable-task-count.ts new file mode 100644 index 0000000000000..1796f7c1d2443 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/base/scalable-task-count.ts @@ -0,0 +1,103 @@ +import appscaling = require('@aws-cdk/aws-applicationautoscaling'); +import cloudwatch = require('@aws-cdk/aws-cloudwatch'); + +/** + * Scalable attribute representing task count + */ +export class ScalableTaskCount extends appscaling.BaseScalableAttribute { + /** + * Scale out or in based on time + */ + public scaleOnSchedule(id: string, props: appscaling.ScalingSchedule) { + return super.doScaleOnSchedule(id, props); + } + + /** + * Scale out or in based on a metric value + */ + public scaleOnMetric(id: string, props: appscaling.BasicStepScalingPolicyProps) { + return super.doScaleOnMetric(id, props); + } + + /** + * Scale out or in to achieve a target CPU utilization + */ + public scaleOnCpuUtilization(id: string, props: CpuUtilizationScalingProps) { + return super.doScaleToTrackMetric(id, { + predefinedMetric: appscaling.PredefinedMetric.ECSServiceAverageCPUUtilization, + policyName: props.policyName, + disableScaleIn: props.disableScaleIn, + targetValue: props.targetUtilizationPercent, + scaleInCooldownSec: props.scaleInCooldownSec, + scaleOutCooldownSec: props.scaleOutCooldownSec, + }); + } + + /** + * Scale out or in to achieve a target memory utilization utilization + */ + public scaleOnMemoryUtilization(id: string, props: CpuUtilizationScalingProps) { + return super.doScaleToTrackMetric(id, { + predefinedMetric: appscaling.PredefinedMetric.ECSServiceAverageMemoryUtilization, + targetValue: props.targetUtilizationPercent, + policyName: props.policyName, + disableScaleIn: props.disableScaleIn, + scaleInCooldownSec: props.scaleInCooldownSec, + scaleOutCooldownSec: props.scaleOutCooldownSec, + }); + } + + /** + * Scale out or in to track a custom metric + */ + public scaleToTrackCustomMetric(id: string, props: TrackCustomMetricProps) { + return super.doScaleToTrackMetric(id, { + customMetric: props.metric, + targetValue: props.targetValue, + policyName: props.policyName, + disableScaleIn: props.disableScaleIn, + scaleInCooldownSec: props.scaleInCooldownSec, + scaleOutCooldownSec: props.scaleOutCooldownSec, + }); + } +} + +/** + * Properties for enabling scaling based on CPU utilization + */ +export interface CpuUtilizationScalingProps extends appscaling.BaseTargetTrackingProps { + /** + * Target average CPU utilization across the task + */ + targetUtilizationPercent: number; +} + +/** + * Properties for enabling scaling based on memory utilization + */ +export interface MemoryUtilizationScalingProps extends appscaling.BaseTargetTrackingProps { + /** + * Target average memory utilization across the task + */ + targetUtilizationPercent: number; +} + +/** + * Properties to target track a custom metric + */ +export interface TrackCustomMetricProps extends appscaling.BaseTargetTrackingProps { + /** + * Metric to track + * + * The metric must represent utilization; that is, you will always get the following behavior: + * + * - metric > targetValue => scale out + * - metric < targetValue => scale in + */ + metric: cloudwatch.Metric; + + /** + * The target value to achieve for the metric + */ + targetValue: number; +} diff --git a/packages/@aws-cdk/aws-ecs/lib/base/task-definition.ts b/packages/@aws-cdk/aws-ecs/lib/base/task-definition.ts new file mode 100644 index 0000000000000..c90edf81d98a4 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/base/task-definition.ts @@ -0,0 +1,426 @@ +import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); +import { ContainerDefinition, ContainerDefinitionProps } from '../container-definition'; +import { cloudformation } from '../ecs.generated'; +import { isEc2Compatible, isFargateCompatible } from '../util'; + +/** + * Properties common to all Task definitions + */ +export interface CommonTaskDefinitionProps { + /** + * Namespace for task definition versions + * + * @default Automatically generated name + */ + family?: string; + + /** + * The IAM role assumed by the ECS agent. + * + * The role will be used to retrieve container images from ECR and + * create CloudWatch log groups. + * + * @default An execution role will be automatically created if you use ECR images in your task definition + */ + executionRole?: iam.Role; + + /** + * The IAM role assumable by your application code running inside the container + * + * @default A task role is automatically created for you + */ + taskRole?: iam.Role; + + /** + * See: https://docs.aws.amazon.com/AmazonECS/latest/developerguide//task_definition_parameters.html#volumes + */ + volumes?: Volume[]; +} + +/** + * Properties for generic task definitions + */ +export interface TaskDefinitionProps extends CommonTaskDefinitionProps { + /** + * The Docker networking mode to use for the containers in the task. + * + * On Fargate, the only supported networking mode is AwsVpc. + * + * @default NetworkMode.Bridge for EC2 tasks, AwsVpc for Fargate tasks. + */ + networkMode?: NetworkMode; + + /** + * An array of placement constraint objects to use for the task. You can + * specify a maximum of 10 constraints per task (this limit includes + * constraints in the task definition and those specified at run time). + * + * Not supported in Fargate. + */ + placementConstraints?: PlacementConstraint[]; + + /** + * What launch types this task definition should be compatible with. + */ + compatibility: Compatibility; + + /** + * The number of cpu units used by the task. + * Valid values, which determines your range of valid values for the memory parameter: + * 256 (.25 vCPU) - Available memory values: 0.5GB, 1GB, 2GB + * 512 (.5 vCPU) - Available memory values: 1GB, 2GB, 3GB, 4GB + * 1024 (1 vCPU) - Available memory values: 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB + * 2048 (2 vCPU) - Available memory values: Between 4GB and 16GB in 1GB increments + * 4096 (4 vCPU) - Available memory values: Between 8GB and 30GB in 1GB increments + */ + cpu?: string; + + /** + * The amount (in MiB) of memory used by the task. + * + * This field is required and you must use one of the following values, which determines your range of valid values + * for the cpu parameter: + * + * 0.5GB, 1GB, 2GB - Available cpu values: 256 (.25 vCPU) + * + * 1GB, 2GB, 3GB, 4GB - Available cpu values: 512 (.5 vCPU) + * + * 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB - Available cpu values: 1024 (1 vCPU) + * + * Between 4GB and 16GB in 1GB increments - Available cpu values: 2048 (2 vCPU) + * + * Between 8GB and 30GB in 1GB increments - Available cpu values: 4096 (4 vCPU) + */ + memoryMiB?: string; +} + +/** + * Base class for Ecs and Fargate task definitions + */ +export class TaskDefinition extends cdk.Construct { + /** + * The family name of this task definition + */ + public readonly family: string; + + /** + * ARN of this task definition + */ + public readonly taskDefinitionArn: string; + + /** + * Task role used by this task definition + */ + public readonly taskRole: iam.Role; + + /** + * Network mode used by this task definition + */ + public readonly networkMode: NetworkMode; + + /** + * Default container for this task + * + * Load balancers will send traffic to this container. The first + * essential container that is added to this task will become the default + * container. + */ + public defaultContainer?: ContainerDefinition; + + /** + * What launching modes this task is compatible with + */ + public compatibility: Compatibility; + + /** + * All containers + */ + protected readonly containers = new Array(); + + /** + * All volumes + */ + private readonly volumes: cloudformation.TaskDefinitionResource.VolumeProperty[] = []; + + /** + * Execution role for this task definition + * + * Will be created as needed. + */ + private executionRole?: iam.Role; + + /** + * Placement constraints for task instances + */ + private readonly placementConstraints = new Array(); + + constructor(parent: cdk.Construct, name: string, props: TaskDefinitionProps) { + super(parent, name); + + this.family = props.family || this.uniqueId; + this.compatibility = props.compatibility; + + if (props.volumes) { + props.volumes.forEach(v => this.addVolume(v)); + } + + this.networkMode = props.networkMode !== undefined ? props.networkMode : + isFargateCompatible(this.compatibility) ? NetworkMode.AwsVpc : NetworkMode.Bridge; + if (isFargateCompatible(this.compatibility) && this.networkMode !== NetworkMode.AwsVpc) { + throw new Error(`Fargate tasks can only have AwsVpc network mode, got: ${this.networkMode}`); + } + + if (props.placementConstraints && props.placementConstraints.length > 0 && isFargateCompatible(this.compatibility)) { + throw new Error('Cannot set placement constraints on tasks that run on Fargate'); + } + + if (isFargateCompatible(this.compatibility) && (!props.cpu || !props.memoryMiB)) { + throw new Error(`Fargate-compatible tasks require both CPU (${props.cpu}) and memory (${props.memoryMiB}) specifications`); + } + + this.executionRole = props.executionRole; + + this.taskRole = props.taskRole || new iam.Role(this, 'TaskRole', { + assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'), + }); + + const taskDef = new cloudformation.TaskDefinitionResource(this, 'Resource', { + containerDefinitions: new cdk.Token(() => this.containers.map(x => x.renderContainerDefinition())), + volumes: new cdk.Token(() => this.volumes), + executionRoleArn: new cdk.Token(() => this.executionRole && this.executionRole.roleArn), + family: this.family, + taskRoleArn: this.taskRole.roleArn, + requiresCompatibilities: [ + ...(isEc2Compatible(props.compatibility) ? ["EC2"] : []), + ...(isFargateCompatible(props.compatibility) ? ["FARGATE"] : []), + ], + networkMode: this.networkMode, + placementConstraints: !isFargateCompatible(this.compatibility) ? new cdk.Token(this.placementConstraints) : undefined, + cpu: props.cpu, + memory: props.memoryMiB, + }); + + if (props.placementConstraints) { + props.placementConstraints.forEach(pc => this.addPlacementConstraint(pc)); + } + + this.taskDefinitionArn = taskDef.taskDefinitionArn; + } + + /** + * Add a policy statement to the Task Role + */ + public addToTaskRolePolicy(statement: iam.PolicyStatement) { + this.taskRole.addToPolicy(statement); + } + + /** + * Add a policy statement to the Execution Role + */ + public addToExecutionRolePolicy(statement: iam.PolicyStatement) { + this.obtainExecutionRole().addToPolicy(statement); + } + + /** + * Create a new container to this task definition + */ + public addContainer(id: string, props: ContainerDefinitionProps) { + const container = new ContainerDefinition(this, id, this, props); + this.containers.push(container); + if (this.defaultContainer === undefined && container.essential) { + this.defaultContainer = container; + } + + return container; + } + + /** + * Add a volume to this task definition + */ + public addVolume(volume: Volume) { + this.volumes.push(volume); + } + + /** + * Validate this task definition + */ + public validate(): string[] { + const ret = super.validate(); + + if (isEc2Compatible(this.compatibility)) { + // EC2 mode validations + + // Container sizes + for (const container of this.containers) { + if (!container.memoryLimitSpecified) { + ret.push(`ECS Container ${container.id} must have at least one of 'memoryLimitMiB' or 'memoryReservationMiB' specified`); + } + } + } + return ret; + } + + /** + * Constrain where tasks can be placed + */ + public addPlacementConstraint(constraint: PlacementConstraint) { + if (isFargateCompatible(this.compatibility)) { + throw new Error('Cannot set placement constraints on tasks that run on Fargate'); + } + const pc = this.renderPlacementConstraint(constraint); + this.placementConstraints.push(pc); + } + + /** + * Extend this TaskDefinition with the given extension + * + * Extension can be used to apply a packaged modification to + * a task definition. + */ + public addExtension(extension: ITaskDefinitionExtension) { + extension.extend(this); + } + + /** + * Create the execution role if it doesn't exist + */ + public obtainExecutionRole(): iam.IRole { + if (!this.executionRole) { + this.executionRole = new iam.Role(this, 'ExecutionRole', { + assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'), + }); + } + return this.executionRole; + } + + /** + * Render the placement constraints + */ + private renderPlacementConstraint(pc: PlacementConstraint): cloudformation.TaskDefinitionResource.TaskDefinitionPlacementConstraintProperty { + return { + type: pc.type, + expression: pc.expression + }; + } +} + +/** + * The Docker networking mode to use for the containers in the task. + */ +export enum NetworkMode { + /** + * The task's containers do not have external connectivity and port mappings can't be specified in the container definition. + */ + None = 'none', + + /** + * The task utilizes Docker's built-in virtual network which runs inside each container instance. + */ + Bridge = 'bridge', + + /** + * The task is allocated an elastic network interface. + */ + AwsVpc = 'awsvpc', + + /** + * The task bypasses Docker's built-in virtual network and maps container ports directly to the EC2 instance's network interface directly. + * + * In this mode, you can't run multiple instantiations of the same task on a + * single container instance when port mappings are used. + */ + Host = 'host', +} + +/** + * Volume definition + */ +export interface Volume { + /** + * Path on the host + */ + host?: Host; + + /** + * A name for the volume + */ + name?: string; + // FIXME add dockerVolumeConfiguration +} + +/** + * A volume host + */ +export interface Host { + /** + * Source path on the host + */ + sourcePath?: string; +} + +/** + * A constraint on how instances should be placed + */ +export interface PlacementConstraint { + /** + * The type of constraint + */ + type: PlacementConstraintType; + + /** + * Additional information for the constraint + */ + expression?: string; +} + +/** + * A placement constraint type + */ +export enum PlacementConstraintType { + /** + * Place each task on a different instance + */ + DistinctInstance = "distinctInstance", + + /** + * Place tasks only on instances matching the expression in 'expression' + */ + MemberOf = "memberOf" +} + +/** + * Task compatibility + */ +export enum Compatibility { + /** + * Task should be launchable on EC2 clusters + */ + Ec2, + + /** + * Task should be launchable on Fargate clusters + */ + Fargate, + + /** + * Task should be launchable on both types of clusters + */ + Ec2AndFargate +} + +/** + * An extension for Task Definitions + * + * Classes that want to make changes to a TaskDefinition (such as + * adding helper containers) can implement this interface, and can + * then be "added" to a TaskDefinition like so: + * + * taskDefinition.addExtension(new MyExtension("some_parameter")); + */ +export interface ITaskDefinitionExtension { + /** + * Apply the extension to the given TaskDefinition + */ + extend(taskDefinition: TaskDefinition): void; +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/lib/cluster.ts b/packages/@aws-cdk/aws-ecs/lib/cluster.ts new file mode 100644 index 0000000000000..8e2c8466e20f7 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/cluster.ts @@ -0,0 +1,312 @@ +import autoscaling = require('@aws-cdk/aws-autoscaling'); +import cloudwatch = require ('@aws-cdk/aws-cloudwatch'); +import ec2 = require('@aws-cdk/aws-ec2'); +import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); +import { cloudformation } from './ecs.generated'; + +/** + * Properties to define an ECS cluster + */ +export interface ClusterProps { + /** + * A name for the cluster. + * + * @default CloudFormation-generated name + */ + clusterName?: string; + + /** + * The VPC where your ECS instances will be running or your ENIs will be deployed + */ + vpc: ec2.VpcNetworkRef; +} + +/** + * A container cluster that runs on your EC2 instances + */ +export class Cluster extends cdk.Construct implements ICluster { + /** + * Import an existing cluster + */ + public static import(parent: cdk.Construct, name: string, props: ImportedClusterProps): ICluster { + return new ImportedCluster(parent, name, props); + } + + /** + * Connections manager for the EC2 cluster + */ + public readonly connections: ec2.Connections = new ec2.Connections(); + + /** + * The VPC this cluster was created in. + */ + public readonly vpc: ec2.VpcNetworkRef; + + /** + * The ARN of this cluster + */ + public readonly clusterArn: string; + + /** + * The name of this cluster + */ + public readonly clusterName: string; + + /** + * Whether the cluster has EC2 capacity associated with it + */ + private _hasEc2Capacity: boolean = false; + + constructor(parent: cdk.Construct, name: string, props: ClusterProps) { + super(parent, name); + + const cluster = new cloudformation.ClusterResource(this, 'Resource', {clusterName: props.clusterName}); + + this.vpc = props.vpc; + this.clusterArn = cluster.clusterArn; + this.clusterName = cluster.clusterName; + } + + /** + * Add a default-configured AutoScalingGroup running the ECS-optimized AMI to this Cluster + */ + public addDefaultAutoScalingGroupCapacity(options: AddDefaultAutoScalingGroupOptions) { + const autoScalingGroup = new autoscaling.AutoScalingGroup(this, 'DefaultAutoScalingGroup', { + vpc: this.vpc, + instanceType: options.instanceType, + machineImage: new EcsOptimizedAmi(), + updateType: autoscaling.UpdateType.ReplacingUpdate, + minSize: 0, + maxSize: options.instanceCount || 1, + desiredCapacity: options.instanceCount || 1 + }); + + this.addAutoScalingGroupCapacity(autoScalingGroup); + } + + /** + * Add compute capacity to this ECS cluster in the form of an AutoScalingGroup + */ + public addAutoScalingGroupCapacity(autoScalingGroup: autoscaling.AutoScalingGroup, options: AddAutoScalingGroupCapacityOptions = {}) { + this._hasEc2Capacity = true; + this.connections.connections.addSecurityGroup(...autoScalingGroup.connections.securityGroups); + + // Tie instances to cluster + autoScalingGroup.addUserData(`echo ECS_CLUSTER=${this.clusterName} >> /etc/ecs/ecs.config`); + + if (!options.containersAccessInstanceRole) { + // 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'); + } + + // ECS instances must be able to do these things + // Source: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/instance_IAM_role.html + autoScalingGroup.addToRolePolicy(new iam.PolicyStatement().addActions( + "ecs:CreateCluster", + "ecs:DeregisterContainerInstance", + "ecs:DiscoverPollEndpoint", + "ecs:Poll", + "ecs:RegisterContainerInstance", + "ecs:StartTelemetrySession", + "ecs:Submit*", + "ecr:GetAuthorizationToken", + "logs:CreateLogStream", + "logs:PutLogEvents" + ).addAllResources()); + } + + /** + * Whether the cluster has EC2 capacity associated with it + */ + public get hasEc2Capacity(): boolean { + return this._hasEc2Capacity; + } + + /** + * Export the Cluster + */ + public export(): ImportedClusterProps { + return { + clusterName: new cdk.Output(this, 'ClusterName', { value: this.clusterName }).makeImportValue().toString(), + vpc: this.vpc.export(), + securityGroups: this.connections.securityGroups.map(sg => sg.export()), + hasEc2Capacity: this.hasEc2Capacity, + }; + } + + /** + * Metric for cluster CPU reservation + * + * @default average over 5 minutes + */ + public metricCpuReservation(props?: cloudwatch.MetricCustomization): cloudwatch.Metric { + return this.metric('CPUReservation', props); + } + + /** + * Metric for cluster Memory reservation + * + * @default average over 5 minutes + */ + public metricMemoryReservation(props?: cloudwatch.MetricCustomization): cloudwatch.Metric { + return this.metric('MemoryReservation', props ); + } + + /** + * Return the given named metric for this Cluster + */ + public metric(metricName: string, props?: cloudwatch.MetricCustomization): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace: 'AWS/ECS', + metricName, + dimensions: { ClusterName: this.clusterName }, + ...props + }); + } +} + +/** + * Construct a Linux machine image from the latest ECS Optimized AMI published in SSM + */ +export class EcsOptimizedAmi implements ec2.IMachineImageSource { + private static AmiParameterName = "/aws/service/ecs/optimized-ami/amazon-linux/recommended"; + + /** + * Return the correct image + */ + public getImage(parent: cdk.Construct): ec2.MachineImage { + const ssmProvider = new cdk.SSMParameterProvider(parent, { + parameterName: EcsOptimizedAmi.AmiParameterName + }); + + const json = ssmProvider.parameterValue("{\"image_id\": \"\"}"); + const ami = JSON.parse(json).image_id; + + return new ec2.MachineImage(ami, new ec2.LinuxOS()); + } +} + +/** + * An ECS cluster + */ +export interface ICluster { + /** + * Name of the cluster + */ + readonly clusterName: string; + + /** + * VPC that the cluster instances are running in + */ + readonly vpc: ec2.VpcNetworkRef; + + /** + * Connections manager of the cluster instances + */ + readonly connections: ec2.Connections; + + /** + * Whether the cluster has EC2 capacity associated with it + */ + readonly hasEc2Capacity: boolean; +} + +/** + * Properties to import an ECS cluster + */ +export interface ImportedClusterProps { + /** + * Name of the cluster + */ + clusterName: string; + + /** + * VPC that the cluster instances are running in + */ + vpc: ec2.VpcNetworkRefProps; + + /** + * Security group of the cluster instances + */ + securityGroups: ec2.SecurityGroupRefProps[]; + + /** + * Whether the given cluster has EC2 capacity + * + * @default true + */ + hasEc2Capacity?: boolean; +} + +/** + * An Cluster that has been imported + */ +class ImportedCluster extends cdk.Construct implements ICluster { + /** + * Name of the cluster + */ + public readonly clusterName: string; + + /** + * VPC that the cluster instances are running in + */ + public readonly vpc: ec2.VpcNetworkRef; + + /** + * Security group of the cluster instances + */ + public readonly connections = new ec2.Connections(); + + /** + * Whether the cluster has EC2 capacity + */ + public readonly hasEc2Capacity: boolean; + + constructor(parent: cdk.Construct, name: string, props: ImportedClusterProps) { + super(parent, name); + this.clusterName = props.clusterName; + this.vpc = ec2.VpcNetworkRef.import(this, "vpc", props.vpc); + this.hasEc2Capacity = props.hasEc2Capacity !== false; + + let i = 1; + for (const sgProps of props.securityGroups) { + this.connections.addSecurityGroup(ec2.SecurityGroupRef.import(this, `SecurityGroup${i}`, sgProps)); + i++; + } + } +} + +/** + * Properties for adding an autoScalingGroup + */ +export interface AddAutoScalingGroupCapacityOptions { + /** + * Whether or not the containers can access the instance role + * + * @default false + */ + containersAccessInstanceRole?: boolean; +} + +/** + * Properties for adding autoScalingGroup + */ +export interface AddDefaultAutoScalingGroupOptions { + + /** + * The type of EC2 instance to launch into your Autoscaling Group + */ + instanceType: ec2.InstanceType; + + /** + * Number of container instances registered in your ECS Cluster + * + * @default 1 + */ + instanceCount?: number; +} diff --git a/packages/@aws-cdk/aws-ecs/lib/container-definition.ts b/packages/@aws-cdk/aws-ecs/lib/container-definition.ts new file mode 100644 index 0000000000000..5b03605ff5c97 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/container-definition.ts @@ -0,0 +1,619 @@ +import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); +import { NetworkMode, TaskDefinition } from './base/task-definition'; +import { IContainerImage } from './container-image'; +import { cloudformation } from './ecs.generated'; +import { LinuxParameters } from './linux-parameters'; +import { LogDriver } from './log-drivers/log-driver'; + +/** + * Properties of a container definition + */ +export interface ContainerDefinitionProps { + /** + * The image to use for a container. + * + * You can use images in the Docker Hub registry or specify other + * repositories (repository-url/image:tag). + * TODO: Update these to specify using classes of IContainerImage + */ + image: IContainerImage; + + /** + * The CMD value to pass to the container. + * + * If you provide a shell command as a single string, you have to quote command-line arguments. + * + * @default CMD value built into container image + */ + command?: string[]; + + /** + * The minimum number of CPU units to reserve for the container. + */ + cpu?: number; + + /** + * Indicates whether networking is disabled within the container. + * + * @default false + */ + disableNetworking?: boolean; + + /** + * A list of DNS search domains that are provided to the container. + * + * @default No search domains + */ + dnsSearchDomains?: string[]; + + /** + * A list of DNS servers that Amazon ECS provides to the container. + * + * @default Default DNS servers + */ + dnsServers?: string[]; + + /** + * A key-value map of labels for the container. + * + * @default No labels + */ + dockerLabels?: {[key: string]: string }; + + /** + * A list of custom labels for SELinux and AppArmor multi-level security systems. + * + * @default No security labels + */ + dockerSecurityOptions?: string[]; + + /** + * The ENTRYPOINT value to pass to the container. + * + * @see https://docs.docker.com/engine/reference/builder/#entrypoint + * @default Entry point configured in container + */ + entryPoint?: string[]; + + /** + * The environment variables to pass to the container. + * + * @default No environment variables + */ + environment?: {[key: string]: string}; + + /** + * Indicates whether the task stops if this container fails. + * + * If you specify true and the container fails, all other containers in the + * task stop. If you specify false and the container fails, none of the other + * containers in the task is affected. + * + * You must have at least one essential container in a task. + * + * @default true + */ + essential?: boolean; + + /** + * A list of hostnames and IP address mappings to append to the /etc/hosts file on the container. + * + * @default No extra hosts + */ + extraHosts?: {[name: string]: string}; + + /** + * Container health check. + * + * @default Health check configuration from container + */ + healthCheck?: HealthCheck; + + /** + * The name that Docker uses for the container hostname. + * + * @default Automatic hostname + */ + hostname?: string; + + /** + * The hard limit (in MiB) of memory to present to the container. + * + * If your container attempts to exceed the allocated memory, the container + * is terminated. + * + * At least one of memoryLimitMiB and memoryReservationMiB is required for non-Fargate services. + */ + memoryLimitMiB?: number; + + /** + * The soft limit (in MiB) of memory to reserve for the container. + * + * When system memory is under contention, Docker attempts to keep the + * container memory within the limit. If the container requires more memory, + * it can consume up to the value specified by the Memory property or all of + * the available memory on the container instance—whichever comes first. + * + * At least one of memoryLimitMiB and memoryReservationMiB is required for non-Fargate services. + */ + memoryReservationMiB?: number; + + /** + * Indicates whether the container is given full access to the host container instance. + * + * @default false + */ + privileged?: boolean; + + /** + * Indicates whether the container's root file system is mounted as read only. + * + * @default false + */ + readonlyRootFilesystem?: boolean; + + /** + * The user name to use inside the container. + * + * @default root + */ + user?: string; + + /** + * The working directory in the container to run commands in. + * + * @default / + */ + workingDirectory?: string; + + /** + * Configures a custom log driver for the container. + */ + logging?: LogDriver; +} + +/** + * A definition for a single container in a Task + */ +export class ContainerDefinition extends cdk.Construct { + /** + * Access Linux Parameters + */ + public readonly linuxParameters = new LinuxParameters(); + + /** + * The configured mount points + */ + public readonly mountPoints = new Array(); + + /** + * The configured port mappings + */ + public readonly portMappings = new Array(); + + /** + * The configured volumes + */ + public readonly volumesFrom = new Array(); + + /** + * The configured ulimits + */ + public readonly ulimits = new Array(); + + /** + * Whether or not this container is essential + */ + public readonly essential: boolean; + + /** + * Whether there was at least one memory limit specified in this definition + */ + public readonly memoryLimitSpecified: boolean; + + /** + * The task definition this container definition is part of + */ + public readonly taskDefinition: TaskDefinition; + + /** + * The configured container links + */ + private readonly links = new Array(); + + constructor(parent: cdk.Construct, id: string, taskDefinition: TaskDefinition, private readonly props: ContainerDefinitionProps) { + super(parent, id); + this.essential = props.essential !== undefined ? props.essential : true; + this.taskDefinition = taskDefinition; + this.memoryLimitSpecified = props.memoryLimitMiB !== undefined || props.memoryReservationMiB !== undefined; + + props.image.bind(this); + } + + /** + * Add a link from this container to a different container + * The link parameter allows containers to communicate with each other without the need for port mappings. + * Only supported if the network mode of a task definition is set to bridge. + * Warning: The --link flag is a legacy feature of Docker. It may eventually be removed. + */ + public addLink(container: ContainerDefinition, alias?: string) { + if (this.taskDefinition.networkMode !== NetworkMode.Bridge) { + throw new Error(`You must use network mode Bridge to add container links.`); + } + if (alias !== undefined) { + this.links.push(`${container.id}:${alias}`); + } else { + this.links.push(`${container.id}`); + } + } + + /** + * Add one or more mount points to this container. + */ + public addMountPoints(...mountPoints: MountPoint[]) { + this.mountPoints.push(...mountPoints); + } + + /** + * Mount temporary disc space to a container. + * This adds the correct container mountPoint and task definition volume. + */ + public addScratch(scratch: ScratchSpace) { + const mountPoint = { + containerPath: scratch.containerPath, + readOnly: scratch.readOnly, + sourceVolume: scratch.name + }; + + const volume = { + host: { + sourcePath: scratch.sourcePath + }, + name: scratch.name + }; + + this.taskDefinition.addVolume(volume); + this.addMountPoints(mountPoint); + } + + /** + * Add one or more port mappings to this container + */ + public addPortMappings(...portMappings: PortMapping[]) { + for (const pm of portMappings) { + if (this.taskDefinition.networkMode === NetworkMode.AwsVpc || this.taskDefinition.networkMode === NetworkMode.Host) { + if (pm.containerPort !== pm.hostPort && pm.hostPort !== undefined) { + throw new Error(`Host port ${pm.hostPort} does not match container port ${pm.containerPort}.`); + } + } + if (this.taskDefinition.networkMode === NetworkMode.Bridge) { + if (pm.hostPort === undefined) { + pm.hostPort = 0; + } + } + } + this.portMappings.push(...portMappings); + } + + /** + * Add one or more ulimits to this container + */ + public addUlimits(...ulimits: Ulimit[]) { + this.ulimits.push(...ulimits); + } + + /** + * Add one or more volumes to this container + */ + public addVolumesFrom(...volumesFrom: VolumeFrom[]) { + this.volumesFrom.push(...volumesFrom); + } + + /** + * Add a statement to the Task Definition's Execution policy + */ + public addToExecutionPolicy(statement: iam.PolicyStatement) { + this.taskDefinition.addToExecutionRolePolicy(statement); + } + + /** + * Ingress Port is needed to set the security group ingress for the task/service + */ + public get ingressPort(): number { + if (this.portMappings.length === 0) { + throw new Error(`Container ${this.id} hasn't defined any ports. Call addPortMappings().`); + } + const defaultPortMapping = this.portMappings[0]; + + if (defaultPortMapping.hostPort !== undefined && defaultPortMapping.hostPort !== 0) { + return defaultPortMapping.hostPort; + } + + if (this.taskDefinition.networkMode === NetworkMode.Bridge) { + return 0; + } + return defaultPortMapping.containerPort; + } + + /** + * Return the port that the container will be listening on by default + */ + public get containerPort(): number { + if (this.portMappings.length === 0) { + throw new Error(`Container ${this.id} hasn't defined any ports. Call addPortMappings().`); + } + const defaultPortMapping = this.portMappings[0]; + return defaultPortMapping.containerPort; + } + + /** + * Render this container definition to a CloudFormation object + */ + public renderContainerDefinition(): cloudformation.TaskDefinitionResource.ContainerDefinitionProperty { + return { + command: this.props.command, + cpu: this.props.cpu, + disableNetworking: this.props.disableNetworking, + dnsSearchDomains: this.props.dnsSearchDomains, + dnsServers: this.props.dnsServers, + dockerLabels: this.props.dockerLabels, + dockerSecurityOptions: this.props.dockerSecurityOptions, + entryPoint: this.props.entryPoint, + essential: this.essential, + hostname: this.props.hostname, + image: this.props.image.imageName, + memory: this.props.memoryLimitMiB, + memoryReservation: this.props.memoryReservationMiB, + mountPoints: this.mountPoints.map(renderMountPoint), + name: this.id, + portMappings: this.portMappings.map(renderPortMapping), + privileged: this.props.privileged, + readonlyRootFilesystem: this.props.readonlyRootFilesystem, + repositoryCredentials: undefined, // FIXME + ulimits: this.ulimits.map(renderUlimit), + user: this.props.user, + volumesFrom: this.volumesFrom.map(renderVolumeFrom), + workingDirectory: this.props.workingDirectory, + logConfiguration: this.props.logging && this.props.logging.renderLogDriver(), + environment: this.props.environment && renderKV(this.props.environment, 'name', 'value'), + extraHosts: this.props.extraHosts && renderKV(this.props.extraHosts, 'hostname', 'ipAddress'), + healthCheck: this.props.healthCheck && renderHealthCheck(this.props.healthCheck), + links: this.links, + linuxParameters: this.linuxParameters.renderLinuxParameters(), + }; + } +} + +/** + * Container health check configuration + */ +export interface HealthCheck { + /** + * Command to run, as the binary path and arguments. + * + * If you provide a shell command as a single string, you have to quote command-line arguments. + */ + command: string[]; + + /** + * Time period in seconds between each health check execution. + * + * You may specify between 5 and 300 seconds. + * + * @default 30 + */ + intervalSeconds?: number; + + /** + * Number of times to retry a failed health check before the container is considered unhealthy. + * + * You may specify between 1 and 10 retries. + * + * @default 3 + */ + retries?: number; + + /** + * Grace period after startup before failed health checks count. + * + * You may specify between 0 and 300 seconds. + * + * @default No start period + */ + startPeriod?: number; + + /** + * The time period in seconds to wait for a health check to succeed before it is considered a failure. + * + * You may specify between 2 and 60 seconds. + * + * @default 5 + */ + timeout?: number; +} + +function renderKV(env: {[key: string]: string}, keyName: string, valueName: string): any { + const ret = []; + for (const [key, value] of Object.entries(env)) { + ret.push({ [keyName]: key, [valueName]: value }); + } + return ret; +} + +function renderHealthCheck(hc: HealthCheck): cloudformation.TaskDefinitionResource.HealthCheckProperty { + return { + command: getHealthCheckCommand(hc), + interval: hc.intervalSeconds !== undefined ? hc.intervalSeconds : 30, + retries: hc.retries !== undefined ? hc.retries : 3, + startPeriod: hc.startPeriod, + timeout: hc.timeout !== undefined ? hc.timeout : 5, + }; +} + +function getHealthCheckCommand(hc: HealthCheck): string[] { + const cmd = hc.command; + const hcCommand = new Array(); + + if (cmd.length === 0) { + throw new Error(`At least one argument must be supplied for health check command.`); + } + + if (cmd.length === 1) { + hcCommand.push('CMD-SHELL', cmd[0]); + return hcCommand; + } + + if (cmd[0] !== "CMD" || cmd[0] !== 'CMD-SHELL') { + hcCommand.push('CMD'); + } + + return hcCommand.concat(cmd); +} + +/** + * Container ulimits. + * + * Correspond to ulimits options on docker run. + * + * NOTE: Does not work for Windows containers. + */ +export interface Ulimit { + /** + * What resource to enforce a limit on + */ + name: UlimitName, + + /** + * Soft limit of the resource + */ + softLimit: number, + + /** + * Hard limit of the resource + */ + hardLimit: number, +} + +/** + * Type of resource to set a limit on + */ +export enum UlimitName { + Core = "core", + Cpu = "cpu", + Data = "data", + Fsize = "fsize", + Locks = "locks", + Memlock = "memlock", + Msgqueue = "msgqueue", + Nice = "nice", + Nofile = "nofile", + Nproc = "nproc", + Rss = "rss", + Rtprio = "rtprio", + Rttime = "rttime", + Sigpending = "sigpending", + Stack = "stack" +} + +function renderUlimit(ulimit: Ulimit): cloudformation.TaskDefinitionResource.UlimitProperty { + return { + name: ulimit.name, + softLimit: ulimit.softLimit, + hardLimit: ulimit.hardLimit, + }; +} + +/** + * Map a host port to a container port + */ +export interface PortMapping { + /** + * Port inside the container + */ + containerPort: number; + + /** + * Port on the host + * + * In AwsVpc or Host networking mode, leave this out or set it to the + * same value as containerPort. + * + * In Bridge networking mode, leave this out or set it to non-reserved + * non-ephemeral port. + */ + hostPort?: number; + + /** + * Protocol + * + * @default Tcp + */ + protocol?: Protocol +} + +/** + * Network protocol + */ +export enum Protocol { + /** + * TCP + */ + Tcp = "tcp", + + /** + * UDP + */ + Udp = "udp", +} + +function renderPortMapping(pm: PortMapping): cloudformation.TaskDefinitionResource.PortMappingProperty { + return { + containerPort: pm.containerPort, + hostPort: pm.hostPort, + protocol: pm.protocol || Protocol.Tcp, + }; +} + +export interface ScratchSpace { + containerPath: string, + readOnly: boolean, + sourcePath: string + name: string, +} + +export interface MountPoint { + containerPath: string, + readOnly: boolean, + sourceVolume: string, +} + +function renderMountPoint(mp: MountPoint): cloudformation.TaskDefinitionResource.MountPointProperty { + return { + containerPath: mp.containerPath, + readOnly: mp.readOnly, + sourceVolume: mp.sourceVolume, + }; +} + +/** + * A volume from another container + */ +export interface VolumeFrom { + /** + * Name of the source container + */ + sourceContainer: string, + + /** + * Whether the volume is read only + */ + readOnly: boolean, +} + +function renderVolumeFrom(vf: VolumeFrom): cloudformation.TaskDefinitionResource.VolumeFromProperty { + return { + sourceContainer: vf.sourceContainer, + readOnly: vf.readOnly, + }; +} diff --git a/packages/@aws-cdk/aws-ecs/lib/container-image.ts b/packages/@aws-cdk/aws-ecs/lib/container-image.ts new file mode 100644 index 0000000000000..a441d3285318e --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/container-image.ts @@ -0,0 +1,48 @@ +import ecr = require('@aws-cdk/aws-ecr'); +import cdk = require('@aws-cdk/cdk'); + +import { ContainerDefinition } from './container-definition'; +import { AssetImage, AssetImageProps } from './images/asset-image'; +import { DockerHubImage } from './images/dockerhub'; +import { EcrImage } from './images/ecr'; + +/** + * A container image + */ +export interface IContainerImage { + /** + * Name of the image + */ + readonly imageName: string; + + /** + * Called when the image is used by a ContainerDefinition + */ + bind(containerDefinition: ContainerDefinition): void; +} + +/** + * Constructs for types of container images + */ +export class ContainerImage { + /** + * Reference an image on DockerHub + */ + public static fromDockerHub(name: string) { + return new DockerHubImage(name); + } + + /** + * Reference an image in an ECR repository + */ + public static fromEcrRepository(repository: ecr.RepositoryRef, tag: string = 'latest') { + return new EcrImage(repository, tag); + } + + /** + * Reference an image that's constructed directly from sources on disk + */ + public static fromAsset(parent: cdk.Construct, id: string, props: AssetImageProps) { + return new AssetImage(parent, id, props); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts new file mode 100644 index 0000000000000..8625e5ac6ff91 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts @@ -0,0 +1,292 @@ +import cloudwatch = require ('@aws-cdk/aws-cloudwatch'); +import ec2 = require('@aws-cdk/aws-ec2'); +import elb = require('@aws-cdk/aws-elasticloadbalancing'); +import cdk = require('@aws-cdk/cdk'); +import { BaseService, BaseServiceProps } from '../base/base-service'; +import { NetworkMode, TaskDefinition } from '../base/task-definition'; +import { ICluster } from '../cluster'; +import { cloudformation } from '../ecs.generated'; +import { isEc2Compatible } from '../util'; + +/** + * Properties to define an ECS service + */ +export interface Ec2ServiceProps extends BaseServiceProps { + /** + * Cluster where service will be deployed + */ + cluster: ICluster; + + /** + * Task Definition used for running tasks in the service + */ + taskDefinition: TaskDefinition; + + /** + * In what subnets to place the task's ENIs + * + * (Only applicable in case the TaskDefinition is configured for AwsVpc networking) + * + * @default Private subnets + */ + vpcPlacement?: ec2.VpcPlacementStrategy; + + /** + * Existing security group to use for the task's ENIs + * + * (Only applicable in case the TaskDefinition is configured for AwsVpc networking) + * + * @default A new security group is created + */ + securityGroup?: ec2.SecurityGroupRef; + + /** + * Whether to start services on distinct instances + * + * @default true + */ + placeOnDistinctInstances?: boolean; + + /** + * Deploy exactly one task on each instance in your cluster. + * + * When using this strategy, do not specify a desired number of tasks or any + * task placement strategies. + * + * @default false + */ + daemon?: boolean; +} + +/** + * Start a service on an ECS cluster + */ +export class Ec2Service extends BaseService implements elb.ILoadBalancerTarget { + /** + * Name of the cluster + */ + public readonly clusterName: string; + + private readonly constraints: cloudformation.ServiceResource.PlacementConstraintProperty[]; + private readonly strategies: cloudformation.ServiceResource.PlacementStrategyProperty[]; + private readonly daemon: boolean; + private readonly cluster: ICluster; + + constructor(parent: cdk.Construct, name: string, props: Ec2ServiceProps) { + if (props.daemon && props.desiredCount !== undefined) { + throw new Error('Daemon mode launches one task on every instance. Don\'t supply desiredCount.'); + } + + if (!isEc2Compatible(props.taskDefinition.compatibility)) { + throw new Error('Supplied TaskDefinition is not configured for compatibility with EC2'); + } + + super(parent, name, props, { + cluster: props.cluster.clusterName, + taskDefinition: props.taskDefinition.taskDefinitionArn, + launchType: 'EC2', + placementConstraints: new cdk.Token(() => this.constraints), + placementStrategies: new cdk.Token(() => this.strategies), + schedulingStrategy: props.daemon ? 'DAEMON' : 'REPLICA', + }, props.cluster.clusterName, props.taskDefinition); + + this.cluster = props.cluster; + this.clusterName = props.cluster.clusterName; + this.constraints = []; + this.strategies = []; + this.daemon = props.daemon || false; + + if (props.taskDefinition.networkMode === NetworkMode.AwsVpc) { + this.configureAwsVpcNetworking(props.cluster.vpc, false, props.vpcPlacement, props.securityGroup); + } else { + // Either None, Bridge or Host networking. Copy SecurityGroup from ASG. + validateNoNetworkingProps(props); + this.connections.addSecurityGroup(...props.cluster.connections.securityGroups); + } + + if (props.placeOnDistinctInstances) { + this.constraints.push({ type: 'distinctInstance' }); + } + + if (!this.taskDefinition.defaultContainer) { + throw new Error('A TaskDefinition must have at least one essential container'); + } + } + + /** + * Place services only on instances matching the given query expression + * + * You can specify multiple expressions in one call. The tasks will only + * be placed on instances matching all expressions. + * + * @see https://docs.aws.amazon.com/AmazonECS/latest/developerguide/cluster-query-language.html + */ + public placeOnMemberOf(...expressions: string[]) { + for (const expression of expressions) { + this.constraints.push({ type: 'memberOf', expression }); + } + } + + /** + * Try to place tasks spread across instance attributes. + * + * You can use one of the built-in attributes found on `BuiltInAttributes` + * or supply your own custom instance attributes. If more than one attribute + * is supplied, spreading is done in order. + * + * @default attributes instanceId + */ + public placeSpreadAcross(...fields: string[]) { + if (this.daemon) { + throw new Error("Can't configure spreading placement for a service with daemon=true"); + } + + if (fields.length === 0) { + fields = [BuiltInAttributes.InstanceId]; + } + for (const field of fields) { + this.strategies.push({ type: 'spread', field }); + } + } + + /** + * Try to place tasks on instances with the least amount of indicated resource available + * + * This ensures the total consumption of this resource is lowest. + */ + public placePackedBy(resource: BinPackResource) { + if (this.daemon) { + throw new Error("Can't configure packing placement for a service with daemon=true"); + } + + this.strategies.push({ type: 'binpack', field: resource }); + } + + /** + * Place tasks randomly across the available instances. + */ + public placeRandomly() { + if (this.daemon) { + throw new Error("Can't configure random placement for a service with daemon=true"); + } + + this.strategies.push({ type: 'random' }); + } + + /** + * Register this service as the target of a Classic Load Balancer + * + * Don't call this. Call `loadBalancer.addTarget()` instead. + */ + public attachToClassicLB(loadBalancer: elb.LoadBalancer): void { + if (this.taskDefinition.networkMode === NetworkMode.Bridge) { + throw new Error("Cannot use a Classic Load Balancer if NetworkMode is Bridge. Use Host or AwsVpc instead."); + } + if (this.taskDefinition.networkMode === NetworkMode.None) { + throw new Error("Cannot use a load balancer if NetworkMode is None. Use Host or AwsVpc instead."); + } + + this.loadBalancers.push({ + loadBalancerName: loadBalancer.loadBalancerName, + containerName: this.taskDefinition.defaultContainer!.id, + containerPort: this.taskDefinition.defaultContainer!.containerPort, + }); + } + + /** + * Return the given named metric for this Service + */ + public metric(metricName: string, props?: cloudwatch.MetricCustomization): cloudwatch.Metric { + return new cloudwatch.Metric({ + namespace: 'AWS/ECS', + metricName, + dimensions: { ClusterName: this.clusterName, ServiceName: this.serviceName }, + ...props + }); + } + + /** + * Metric for cluster Memory utilization + * + * @default average over 5 minutes + */ + public metricMemoryUtilization(props?: cloudwatch.MetricCustomization): cloudwatch.Metric { + return this.metric('MemoryUtilization', props ); + } + + /** + * Metric for cluster CPU utilization + * + * @default average over 5 minutes + */ + public metricCpuUtilization(props?: cloudwatch.MetricCustomization): cloudwatch.Metric { + return this.metric('CPUUtilization', props); + } + + /** + * Validate this Ec2Service + */ + public validate(): string[] { + const ret = super.validate(); + if (!this.cluster.hasEc2Capacity) { + ret.push('Cluster for this service needs Ec2 capacity. Call addXxxCapacity() on the cluster.'); + } + return ret; + } +} + +/** + * Validate combinations of networking arguments + */ +function validateNoNetworkingProps(props: Ec2ServiceProps) { + if (props.vpcPlacement !== undefined || props.securityGroup !== undefined) { + throw new Error('vpcPlacement and securityGroup can only be used in AwsVpc networking mode'); + } +} + +/** + * Built-in container instance attributes + */ +export class BuiltInAttributes { + /** + * The Instance ID of the instance + */ + public static readonly InstanceId = 'instanceId'; + + /** + * The AZ where the instance is running + */ + public static readonly AvailabilityZone = 'attribute:ecs.availability-zone'; + + /** + * The AMI ID of the instance + */ + public static readonly AmiId = 'attribute:ecs.ami-id'; + + /** + * The instance type + */ + public static readonly InstanceType = 'attribute:ecs.instance-type'; + + /** + * The OS type + * + * Either 'linux' or 'windows'. + */ + public static readonly OsType = 'attribute:ecs.os-type'; +} + +/** + * Instance resource used for bin packing + */ +export enum BinPackResource { + /** + * Fill up hosts' CPU allocations first + */ + Cpu = 'cpu', + + /** + * Fill up hosts' memory allocations first + */ + Memory = 'memory', +} diff --git a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-task-definition.ts b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-task-definition.ts new file mode 100644 index 0000000000000..86d67a1728704 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-task-definition.ts @@ -0,0 +1,38 @@ +import cdk = require('@aws-cdk/cdk'); +import { CommonTaskDefinitionProps, Compatibility, NetworkMode, PlacementConstraint, TaskDefinition } from '../base/task-definition'; + +/** + * Properties to define an ECS task definition + */ +export interface Ec2TaskDefinitionProps extends CommonTaskDefinitionProps { + /** + * The Docker networking mode to use for the containers in the task. + * + * On Fargate, the only supported networking mode is AwsVpc. + * + * @default NetworkMode.Bridge for EC2 tasks, AwsVpc for Fargate tasks. + */ + networkMode?: NetworkMode; + + /** + * An array of placement constraint objects to use for the task. You can + * specify a maximum of 10 constraints per task (this limit includes + * constraints in the task definition and those specified at run time). + * + * Not supported in Fargate. + */ + placementConstraints?: PlacementConstraint[]; +} + +/** + * Define Tasks to run on an ECS cluster + */ +export class Ec2TaskDefinition extends TaskDefinition { + constructor(parent: cdk.Construct, name: string, props: Ec2TaskDefinitionProps = {}) { + super(parent, name, { + ...props, + compatibility: Compatibility.Ec2, + placementConstraints: props.placementConstraints, + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts b/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts new file mode 100644 index 0000000000000..44e451de48914 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts @@ -0,0 +1,109 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import cdk = require('@aws-cdk/cdk'); +import { BaseService, BaseServiceProps } from '../base/base-service'; +import { TaskDefinition } from '../base/task-definition'; +import { ICluster } from '../cluster'; +import { isFargateCompatible } from '../util'; + +/** + * Properties to define a Fargate service + */ +export interface FargateServiceProps extends BaseServiceProps { + /** + * Cluster where service will be deployed + */ + cluster: ICluster; // should be required? do we assume 'default' exists? + + /** + * Task Definition used for running tasks in the service + */ + taskDefinition: TaskDefinition; + + /** + * Assign public IP addresses to each task + * + * @default false + */ + assignPublicIp?: boolean; + + /** + * In what subnets to place the task's ENIs + * + * @default Private subnet if assignPublicIp, public subnets otherwise + */ + vpcPlacement?: ec2.VpcPlacementStrategy; + + /** + * Existing security group to use for the tasks + * + * @default A new security group is created + */ + securityGroup?: ec2.SecurityGroupRef; + + /** + * Fargate platform version to run this service on + * + * Unless you have specific compatibility requirements, you don't need to + * specify this. + * + * @default Latest + */ + platformVersion?: FargatePlatformVersion; +} + +/** + * Start a service on an ECS cluster + */ +export class FargateService extends BaseService { + constructor(parent: cdk.Construct, name: string, props: FargateServiceProps) { + if (!isFargateCompatible(props.taskDefinition.compatibility)) { + throw new Error('Supplied TaskDefinition is not configured for compatibility with Fargate'); + } + + super(parent, name, props, { + cluster: props.cluster.clusterName, + taskDefinition: props.taskDefinition.taskDefinitionArn, + launchType: 'FARGATE', + platformVersion: props.platformVersion, + }, props.cluster.clusterName, props.taskDefinition); + + this.configureAwsVpcNetworking(props.cluster.vpc, props.assignPublicIp, props.vpcPlacement, props.securityGroup); + + if (!props.taskDefinition.defaultContainer) { + throw new Error('A TaskDefinition must have at least one essential container'); + } + } +} + +/** + * Fargate platform version + * + * @see https://docs.aws.amazon.com/AmazonECS/latest/developerguide/platform_versions.html + */ +export enum FargatePlatformVersion { + /** + * The latest, recommended platform version + */ + Latest = 'LATEST', + + /** + * Version 1.2 + * + * Supports private registries. + */ + Version1_2 = '1.2.0', + + /** + * Version 1.1.0 + * + * Supports task metadata, health checks, service discovery. + */ + Version1_1 = '1.1.0', + + /** + * Initial release + * + * Based on Amazon Linux 2017.09. + */ + Version1_0 = '1.0.0', +} diff --git a/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-task-definition.ts b/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-task-definition.ts new file mode 100644 index 0000000000000..2c700e03ffd71 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-task-definition.ts @@ -0,0 +1,60 @@ +import cdk = require('@aws-cdk/cdk'); +import { CommonTaskDefinitionProps, Compatibility, NetworkMode, TaskDefinition } from '../base/task-definition'; + +/** + * Properties to define a Fargate Task + */ +export interface FargateTaskDefinitionProps extends CommonTaskDefinitionProps { + /** + * The number of cpu units used by the task. + * Valid values, which determines your range of valid values for the memory parameter: + * 256 (.25 vCPU) - Available memory values: 0.5GB, 1GB, 2GB + * 512 (.5 vCPU) - Available memory values: 1GB, 2GB, 3GB, 4GB + * 1024 (1 vCPU) - Available memory values: 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB + * 2048 (2 vCPU) - Available memory values: Between 4GB and 16GB in 1GB increments + * 4096 (4 vCPU) - Available memory values: Between 8GB and 30GB in 1GB increments + * + * @default 256 + */ + cpu?: string; + + /** + * The amount (in MiB) of memory used by the task. + * + * This field is required and you must use one of the following values, which determines your range of valid values + * for the cpu parameter: + * + * 0.5GB, 1GB, 2GB - Available cpu values: 256 (.25 vCPU) + * + * 1GB, 2GB, 3GB, 4GB - Available cpu values: 512 (.5 vCPU) + * + * 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB - Available cpu values: 1024 (1 vCPU) + * + * Between 4GB and 16GB in 1GB increments - Available cpu values: 2048 (2 vCPU) + * + * Between 8GB and 30GB in 1GB increments - Available cpu values: 4096 (4 vCPU) + * + * @default 512 + */ + memoryMiB?: string; +} + +/** + * A definition for Tasks on a Fargate cluster + */ +export class FargateTaskDefinition extends TaskDefinition { + /** + * The configured network mode + */ + public readonly networkMode = NetworkMode.AwsVpc; + + constructor(parent: cdk.Construct, name: string, props: FargateTaskDefinitionProps = {}) { + super(parent, name, { + ...props, + cpu: props.cpu || '256', + memoryMiB: props.memoryMiB || '512', + compatibility: Compatibility.Fargate, + networkMode: NetworkMode.AwsVpc, + }); + } +} diff --git a/packages/@aws-cdk/aws-ecs/lib/images/adopt-repository/handler.js b/packages/@aws-cdk/aws-ecs/lib/images/adopt-repository/handler.js new file mode 100644 index 0000000000000..640b5d4e538f3 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/images/adopt-repository/handler.js @@ -0,0 +1,102 @@ +const AWS = require('aws-sdk'); +const ecr = new AWS.ECR(); + +exports.handler = async function(event, context, _callback, respond) { + respond = respond || respondCFN; + try { + console.log(JSON.stringify(event)); + + const markerStatement = { + Sid: event.StackId, + Effect: "Deny", + Action: "OwnedBy:CDKStack", + Principal: "*" + }; + + function repoName(props) { + return props.RepositoryArn.split('/').slice(1).join('/'); + } + + // The repository must already exist + async function getAdopter(name) { + try { + const policyResponse = await ecr.getRepositoryPolicy({ repositoryName: name }).promise(); + const policy = JSON.parse(policyResponse.policyText); + // Search the policy for an adopter marker + return (policy.Statement || []).find((x) => x.Action === markerStatement.Action) || {}; + } catch (e) { + if (e.code !== 'RepositoryPolicyNotFoundException') { throw e; } + return {}; + } + } + + const repo = repoName(event.ResourceProperties); + const adopter = await getAdopter(repo); + if (event.RequestType === 'Delete') { + if (adopter.Sid !== markerStatement.Sid) { + throw new Error(`This repository is already owned by another stack: ${adopter.Sid}`); + } + try { + console.log('Deleting', repo); + const ids = (await ecr.listImages({ repositoryName: repo }).promise()).imageIds; + await ecr.batchDeleteImage({ repositoryName: repo, imageIds: ids }).promise(); + await ecr.deleteRepository({ repositoryName: repo }).promise(); + } catch(e) { + if (e.code !== 'RepositoryNotFoundException') { throw e; } + } + } + + if (event.RequestType === 'Create' || event.RequestType === 'Update') { + if (adopter.Sid !== undefined && adopter.Sid !== markerStatement.Sid) { + throw new Error(`This repository is already owned by another stack: ${adopter.Sid}`); + } + console.log('Adopting', repo); + await ecr.setRepositoryPolicy({ repositoryName: repo, policyText: JSON.stringify({ + Version: '2008-10-17', + Statement: [markerStatement] + }) }).promise(); + } + + const arn = event.ResourceProperties.RepositoryArn.split(':'); + await respond("SUCCESS", "OK", repo, { + RepositoryUri: `${arn[4]}.dkr.ecr.${arn[3]}.amazonaws.com/${repoName(event.ResourceProperties)}` + }); + } catch (e) { + console.log(e); + await respond("FAILED", e.message, context.logStreamName, {}); + } + + function respondCFN(responseStatus, reason, physId, data) { + const responseBody = JSON.stringify({ + Status: responseStatus, + Reason: reason, + PhysicalResourceId: physId, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + NoEcho: false, + Data: data + }); + + console.log('Responding', JSON.stringify(responseBody)); + + const parsedUrl = require('url').parse(event.ResponseURL); + const requestOptions = { + hostname: parsedUrl.hostname, + path: parsedUrl.path, + method: "PUT", + headers: { "content-type": "", "content-length": responseBody.length } + }; + + return new Promise((resolve, reject) => { + try { + const request = require('https').request(requestOptions, resolve); + request.on("error", reject); + request.write(responseBody); + request.end(); + } catch (e) { + reject(e); + } + }); + } +} diff --git a/packages/@aws-cdk/aws-ecs/lib/images/asset-image.ts b/packages/@aws-cdk/aws-ecs/lib/images/asset-image.ts new file mode 100644 index 0000000000000..ac0c0c63cbe7b --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/images/asset-image.ts @@ -0,0 +1,129 @@ +import cfn = require('@aws-cdk/aws-cloudformation'); +import ecr = require('@aws-cdk/aws-ecr'); +import iam = require('@aws-cdk/aws-iam'); +import lambda = require('@aws-cdk/aws-lambda'); +import cdk = require('@aws-cdk/cdk'); +import cxapi = require('@aws-cdk/cx-api'); +import fs = require('fs'); +import path = require('path'); +import { ContainerDefinition } from '../container-definition'; +import { IContainerImage } from '../container-image'; + +export interface AssetImageProps { + /** + * The directory where the Dockerfile is stored + */ + directory: string; +} + +/** + * An image that will be built at synthesis time + */ +export class AssetImage extends cdk.Construct implements IContainerImage { + /** + * Full name of this image + */ + public readonly imageName: string; + + /** + * Directory where the source files are stored + */ + private readonly directory: string; + + /** + * Repository where the image is stored + */ + private repository: ecr.RepositoryRef; + + constructor(parent: cdk.Construct, id: string, props: AssetImageProps) { + super(parent, id); + + // resolve full path + this.directory = path.resolve(props.directory); + if (!fs.existsSync(this.directory)) { + throw new Error(`Cannot find image directory at ${this.directory}`); + } + if (!fs.existsSync(path.join(this.directory, 'Dockerfile'))) { + throw new Error(`No 'Dockerfile' found in ${this.directory}`); + } + + const repositoryParameter = new cdk.Parameter(this, 'Repository', { + type: 'String', + description: `Repository ARN for asset "${this.path}"`, + }); + + const tagParameter = new cdk.Parameter(this, 'Tag', { + type: 'String', + description: `Tag for asset "${this.path}"`, + }); + + const asset: cxapi.ContainerImageAssetMetadataEntry = { + packaging: 'container-image', + path: this.directory, + id: this.uniqueId, + repositoryParameter: repositoryParameter.logicalId, + tagParameter: tagParameter.logicalId + }; + + this.addMetadata(cxapi.ASSET_METADATA, asset); + + this.repository = ecr.Repository.import(this, 'RepositoryObject', { + repositoryArn: repositoryParameter.value.toString(), + }); + + // Require that repository adoption happens first, so we route the + // input ARN into the Custom Resource and then get the URI which we use to + // refer to the image FROM the Custom Resource. + // + // If adoption fails (because the repository might be twice-adopted), we + // haven't already started using the image. + const adopted = new AdoptRepository(this, 'AdoptRepository', { repositoryArn: this.repository.repositoryArn }); + this.imageName = `${adopted.repositoryUri}:${tagParameter.value}`; + } + + public bind(containerDefinition: ContainerDefinition): void { + this.repository.grantUseImage(containerDefinition.taskDefinition.obtainExecutionRole()); + } +} + +interface AdoptRepositoryProps { + repositoryArn: string; +} + +/** + * Custom Resource which will adopt the repository used for the locally built image into the stack. + * + * Since the repository is not created by the stack (but by the CDK toolkit), + * adopting will make the repository "owned" by the stack. It will be cleaned + * up when the stack gets deleted, to avoid leaving orphaned repositories on stack + * cleanup. + */ +class AdoptRepository extends cdk.Construct { + public readonly repositoryUri: string; + + constructor(parent: cdk.Construct, id: string, props: AdoptRepositoryProps) { + super(parent, id); + + const fn = new lambda.SingletonFunction(this, 'Function', { + runtime: lambda.Runtime.NodeJS810, + lambdaPurpose: 'AdoptEcrRepository', + handler: 'handler.handler', + code: lambda.Code.asset(path.join(__dirname, 'adopt-repository')), + uuid: 'dbc60def-c595-44bc-aa5c-28c95d68f62c', + timeout: 300 + }); + + fn.addToRolePolicy(new iam.PolicyStatement() + .addActions('ecr:GetRepositoryPolicy', 'ecr:SetRepositoryPolicy', 'ecr:DeleteRepository', 'ecr:ListImages', 'ecr:BatchDeleteImage') + .addResource(props.repositoryArn)); + + const resource = new cfn.CustomResource(this, 'Resource', { + lambdaProvider: fn, + properties: { + RepositoryArn: props.repositoryArn, + } + }); + + this.repositoryUri = resource.getAtt('RepositoryUri').toString(); + } +} diff --git a/packages/@aws-cdk/aws-ecs/lib/images/dockerhub.ts b/packages/@aws-cdk/aws-ecs/lib/images/dockerhub.ts new file mode 100644 index 0000000000000..d0bb48bd357a1 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/images/dockerhub.ts @@ -0,0 +1,26 @@ +import { ContainerDefinition } from "../container-definition"; +import { IContainerImage } from "../container-image"; + +/** + * Factory for DockerHub images + */ +export class DockerHub { + /** + * Reference an image on DockerHub + */ + public static image(name: string): IContainerImage { + return new DockerHubImage(name); + } +} + +/** + * A DockerHub image + */ +export class DockerHubImage implements IContainerImage { + constructor(public readonly imageName: string) { + } + + public bind(_containerDefinition: ContainerDefinition): void { + // Nothing to do + } +} diff --git a/packages/@aws-cdk/aws-ecs/lib/images/ecr.ts b/packages/@aws-cdk/aws-ecs/lib/images/ecr.ts new file mode 100644 index 0000000000000..f0c7803789120 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/images/ecr.ts @@ -0,0 +1,20 @@ +import ecr = require('@aws-cdk/aws-ecr'); +import { ContainerDefinition } from '../container-definition'; +import { IContainerImage } from '../container-image'; + +/** + * An image from an ECR repository + */ +export class EcrImage implements IContainerImage { + public readonly imageName: string; + private readonly repository: ecr.RepositoryRef; + + constructor(repository: ecr.RepositoryRef, tag: string) { + this.imageName = `${repository.repositoryUri}:${tag}`; + this.repository = repository; + } + + public bind(containerDefinition: ContainerDefinition): void { + this.repository.grantUseImage(containerDefinition.taskDefinition.obtainExecutionRole()); + } +} diff --git a/packages/@aws-cdk/aws-ecs/lib/index.ts b/packages/@aws-cdk/aws-ecs/lib/index.ts index 8cccdf3fb43d7..902efcdc04529 100644 --- a/packages/@aws-cdk/aws-ecs/lib/index.ts +++ b/packages/@aws-cdk/aws-ecs/lib/index.ts @@ -1,2 +1,30 @@ +export * from './base/base-service'; +export * from './base/scalable-task-count'; +export * from './base/task-definition'; + +export * from './container-definition'; +export * from './container-image'; +export * from './cluster'; + +export * from './ec2/ec2-service'; +export * from './ec2/ec2-task-definition'; + +export * from './fargate/fargate-service'; +export * from './fargate/fargate-task-definition'; + +export * from './linux-parameters'; +export * from './load-balanced-ecs-service'; +export * from './load-balanced-fargate-service'; +export * from './load-balanced-ecs-service'; +export * from './load-balanced-fargate-service-applet'; + +export * from './images/asset-image'; +export * from './images/dockerhub'; +export * from './images/ecr'; + +export * from './log-drivers/aws-log-driver'; +export * from './log-drivers/log-driver'; + // AWS::ECS CloudFormation Resources: +// export * from './ecs.generated'; diff --git a/packages/@aws-cdk/aws-ecs/lib/linux-parameters.ts b/packages/@aws-cdk/aws-ecs/lib/linux-parameters.ts new file mode 100644 index 0000000000000..e3cdb39ba74f2 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/linux-parameters.ts @@ -0,0 +1,253 @@ +import { cloudformation } from './ecs.generated'; + +/** + * Linux parameter setup in a container + */ +export class LinuxParameters { + /** + * Whether the init process is enabled + */ + public initProcessEnabled?: boolean; + + /** + * The shared memory size + */ + public sharedMemorySize?: number; + + /** + * Capabilities to be added + */ + private readonly capAdd: Capability[] = []; + + /** + * Capabilities to be dropped + */ + private readonly capDrop: Capability[] = []; + + /** + * Device mounts + */ + private readonly devices: Device[] = []; + + /** + * TMPFS mounts + */ + private readonly tmpfs: Tmpfs[] = []; + + /** + * Add one or more capabilities + * + * Only works with EC2 launch type. + */ + public addCapabilities(...cap: Capability[]) { + this.capAdd.push(...cap); + } + + /** + * Drop one or more capabilities + * + * Only works with EC2 launch type. + */ + public dropCapabilities(...cap: Capability[]) { + this.capDrop.push(...cap); + } + + /** + * Add one or more devices + */ + public addDevices(...device: Device[]) { + this.devices.push(...device); + } + + /** + * Add one or more tmpfs mounts + */ + public addTmpfs(...tmpfs: Tmpfs[]) { + this.tmpfs.push(...tmpfs); + } + + /** + * Render the Linux parameters to a CloudFormation object + */ + public renderLinuxParameters(): cloudformation.TaskDefinitionResource.LinuxParametersProperty { + return { + initProcessEnabled: this.initProcessEnabled, + sharedMemorySize: this.sharedMemorySize, + capabilities: { + add: this.capAdd, + drop: this.capDrop, + }, + devices: this.devices.map(renderDevice), + tmpfs: this.tmpfs.map(renderTmpfs) + }; + } +} + +/** + * A host device + */ +export interface Device { + /** + * Path in the container + * + * @default Same path as the host + */ + containerPath?: string, + + /** + * Path on the host + */ + hostPath: string, + + /** + * Permissions + * + * @default Readonly + */ + permissions?: DevicePermission[] +} + +function renderDevice(device: Device): cloudformation.TaskDefinitionResource.DeviceProperty { + return { + containerPath: device.containerPath, + hostPath: device.hostPath, + permissions: device.permissions + }; +} + +/** + * A tmpfs mount + */ +export interface Tmpfs { + /** + * Path in the container to mount + */ + containerPath: string, + + /** + * Size of the volume + */ + size: number, + + /** + * Mount options + */ + mountOptions?: TmpfsMountOption[], +} + +function renderTmpfs(tmpfs: Tmpfs): cloudformation.TaskDefinitionResource.TmpfsProperty { + return { + containerPath: tmpfs.containerPath, + size: tmpfs.size, + mountOptions: tmpfs.mountOptions + }; +} + +/** + * A Linux capability + */ +export enum Capability { + All = "ALL", + AuditControl = "AUDIT_CONTROL", + AuditWrite = "AUDIT_WRITE", + BlockSuspend = "BLOCK_SUSPEND", + Chown = "CHOWN", + DacOverride = "DAC_OVERRIDE", + DacReadSearch = "DAC_READ_SEARCH", + Fowner = "FOWNER", + Fsetid = "FSETID", + IpcLock = "IPC_LOCK", + IpcOwner = "IPC_OWNER", + Kill = "KILL", + Lease = "LEASE", + LinuxImmutable = "LINUX_IMMUTABLE", + MacAdmin = "MAC_ADMIN", + MacOverride = "MAC_OVERRIDE", + Mknod = "MKNOD", + NetAdmin = "NET_ADMIN", + NetBindService = "NET_BIND_SERVICE", + NetBroadcast = "NET_BROADCAST", + NetRaw = "NET_RAW", + Setfcap = "SETFCAP", + Setgid = "SETGID", + Setpcap = "SETPCAP", + Setuid = "SETUID", + SysAdmin = "SYS_ADMIN", + SysBoot = "SYS_BOOT", + SysChroot = "SYS_CHROOT", + SysModule = "SYS_MODULE", + SysNice = "SYS_NICE", + SysPacct = "SYS_PACCT", + SysPtrace = "SYS_PTRACE", + SysRawio = "SYS_RAWIO", + SysResource = "SYS_RESOURCE", + SysTime = "SYS_TIME", + SysTtyConfig = "SYS_TTY_CONFIG", + Syslog = "SYSLOG", + WakeAlarm = "WAKE_ALARM" +} + +/** + * Permissions for device access + */ +export enum DevicePermission { + /** + * Read + */ + Read = "read", + + /** + * Write + */ + Write = "write", + + /** + * Make a node + */ + Mknod = "mknod", +} + +/** + * Options for a tmpfs mount + */ +export enum TmpfsMountOption { + Defaults = "defaults", + Ro = "ro", + Rw = "rw", + Suid = "suid", + Nosuid = "nosuid", + Dev = "dev", + Nodev = "nodev", + Exec = "exec", + Noexec = "noexec", + Sync = "sync", + Async = "async", + Dirsync = "dirsync", + Remount = "remount", + Mand = "mand", + Nomand = "nomand", + Atime = "atime", + Noatime = "noatime", + Diratime = "diratime", + Nodiratime = "nodiratime", + Bind = "bind", + Rbind = "rbind", + Unbindable = "unbindable", + Runbindable = "runbindable", + Private = "private", + Rprivate = "rprivate", + Shared = "shared", + Rshared = "rshared", + Slave = "slave", + Rslave = "rslave", + Relatime = "relatime", + Norelatime = "norelatime", + Strictatime = "strictatime", + Nostrictatime = "nostrictatime", + Mode = "mode", + Uid = "uid", + Gid = "gid", + NrInodes = "nr_inodes", + NrBlocks = "nr_blocks", + Mpol = "mpol" +} diff --git a/packages/@aws-cdk/aws-ecs/lib/load-balanced-ecs-service.ts b/packages/@aws-cdk/aws-ecs/lib/load-balanced-ecs-service.ts new file mode 100644 index 0000000000000..6979ecacb8bfd --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/load-balanced-ecs-service.ts @@ -0,0 +1,103 @@ +import elbv2 = require('@aws-cdk/aws-elasticloadbalancingv2'); +import cdk = require('@aws-cdk/cdk'); +import { ICluster } from './cluster'; +import { IContainerImage } from './container-image'; +import { Ec2Service } from './ec2/ec2-service'; +import { Ec2TaskDefinition } from './ec2/ec2-task-definition'; + +/** + * Properties for a LoadBalancedEc2Service + */ +export interface LoadBalancedEc2ServiceProps { + /** + * The cluster where your Fargate service will be deployed + */ + cluster: ICluster; + + /** + * The image to start. + */ + image: IContainerImage; + + /** + * The hard limit (in MiB) of memory to present to the container. + * + * If your container attempts to exceed the allocated memory, the container + * is terminated. + * + * At least one of memoryLimitMiB and memoryReservationMiB is required. + */ + memoryLimitMiB?: number; + + /** + * The soft limit (in MiB) of memory to reserve for the container. + * + * When system memory is under contention, Docker attempts to keep the + * container memory within the limit. If the container requires more memory, + * it can consume up to the value specified by the Memory property or all of + * the available memory on the container instance—whichever comes first. + * + * At least one of memoryLimitMiB and memoryReservationMiB is required. + */ + memoryReservationMiB?: number; + + /** + * The container port of the application load balancer attached to your Fargate service. Corresponds to container port mapping. + * + * @default 80 + */ + containerPort?: number; + + /** + * Determines whether the Application Load Balancer will be internet-facing + * + * @default true + */ + publicLoadBalancer?: boolean; +} + +/** + * A single task running on an ECS cluster fronted by a load balancer + */ +export class LoadBalancedEc2Service extends cdk.Construct { + /** + * The load balancer that is fronting the ECS service + */ + public readonly loadBalancer: elbv2.ApplicationLoadBalancer; + + constructor(parent: cdk.Construct, id: string, props: LoadBalancedEc2ServiceProps) { + super(parent, id); + + const taskDefinition = new Ec2TaskDefinition(this, 'TaskDef', {}); + + const container = taskDefinition.addContainer('web', { + image: props.image, + memoryLimitMiB: props.memoryLimitMiB, + }); + + container.addPortMappings({ + containerPort: props.containerPort || 80, + }); + + const service = new Ec2Service(this, "Service", { + cluster: props.cluster, + taskDefinition, + }); + + const internetFacing = props.publicLoadBalancer !== undefined ? props.publicLoadBalancer : true; + const lb = new elbv2.ApplicationLoadBalancer(this, 'LB', { + vpc: props.cluster.vpc, + internetFacing + }); + + this.loadBalancer = lb; + + const listener = lb.addListener('PublicListener', { port: 80, open: true }); + listener.addTargets('ECS', { + port: 80, + targets: [service] + }); + + new cdk.Output(this, 'LoadBalancerDNS', { value: lb.dnsName }); + } +} diff --git a/packages/@aws-cdk/aws-ecs/lib/load-balanced-fargate-service-applet.ts b/packages/@aws-cdk/aws-ecs/lib/load-balanced-fargate-service-applet.ts new file mode 100644 index 0000000000000..1e3d00bb983de --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/load-balanced-fargate-service-applet.ts @@ -0,0 +1,96 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import cdk = require('@aws-cdk/cdk'); +import { Cluster } from './cluster'; +import { DockerHub } from './images/dockerhub'; +import { LoadBalancedFargateService } from './load-balanced-fargate-service'; + +/** + * Properties for a LoadBalancedEcsServiceApplet + */ +export interface LoadBalancedFargateServiceAppletProps extends cdk.StackProps { + /** + * The image to start (from DockerHub) + */ + image: string; + + /** + * The number of cpu units used by the task. + * Valid values, which determines your range of valid values for the memory parameter: + * 256 (.25 vCPU) - Available memory values: 0.5GB, 1GB, 2GB + * 512 (.5 vCPU) - Available memory values: 1GB, 2GB, 3GB, 4GB + * 1024 (1 vCPU) - Available memory values: 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB + * 2048 (2 vCPU) - Available memory values: Between 4GB and 16GB in 1GB increments + * 4096 (4 vCPU) - Available memory values: Between 8GB and 30GB in 1GB increments + * + * This default is set in the underlying FargateTaskDefinition construct. + * + * @default 256 + */ + cpu?: string; + + /** + * The amount (in MiB) of memory used by the task. + * + * This field is required and you must use one of the following values, which determines your range of valid values + * for the cpu parameter: + * + * 0.5GB, 1GB, 2GB - Available cpu values: 256 (.25 vCPU) + * + * 1GB, 2GB, 3GB, 4GB - Available cpu values: 512 (.5 vCPU) + * + * 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB - Available cpu values: 1024 (1 vCPU) + * + * Between 4GB and 16GB in 1GB increments - Available cpu values: 2048 (2 vCPU) + * + * Between 8GB and 30GB in 1GB increments - Available cpu values: 4096 (4 vCPU) + * + * This default is set in the underlying FargateTaskDefinition construct. + * + * @default 512 + */ + memoryMiB?: string; + + /** + * The container port of the application load balancer attached to your Fargate service. Corresponds to container port mapping. + * + * @default 80 + */ + containerPort?: number; + + /** + * Determines whether the Application Load Balancer will be internet-facing + * + * @default true + */ + publicLoadBalancer?: boolean; + + /** + * Determines whether your Fargate Service will be assigned a public IP address. + * + * @default false + */ + publicTasks?: boolean; +} + +/** + * An applet for a LoadBalancedFargateService + */ +export class LoadBalancedFargateServiceApplet extends cdk.Stack { + constructor(parent: cdk.App, id: string, props: LoadBalancedFargateServiceAppletProps) { + super(parent, id, props); + + const vpc = new ec2.VpcNetwork(this, 'MyVpc', { maxAZs: 2 }); + const cluster = new Cluster(this, 'Cluster', { vpc }); + + // Instantiate Fargate Service with just cluster and image + new LoadBalancedFargateService(this, "FargateService", { + cluster, + cpu: props.cpu, + containerPort: props.containerPort, + memoryMiB: props.memoryMiB, + publicLoadBalancer: props.publicLoadBalancer, + publicTasks: props.publicTasks, + image: DockerHub.image(props.image), + }); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/lib/load-balanced-fargate-service.ts b/packages/@aws-cdk/aws-ecs/lib/load-balanced-fargate-service.ts new file mode 100644 index 0000000000000..2287296468ea4 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/load-balanced-fargate-service.ts @@ -0,0 +1,126 @@ +import elbv2 = require('@aws-cdk/aws-elasticloadbalancingv2'); +import cdk = require('@aws-cdk/cdk'); +import { ICluster } from './cluster'; +import { IContainerImage } from './container-image'; +import { FargateService } from './fargate/fargate-service'; +import { FargateTaskDefinition } from './fargate/fargate-task-definition'; + +/** + * Properties for a LoadBalancedEcsService + */ +export interface LoadBalancedFargateServiceProps { + /** + * The cluster where your Fargate service will be deployed + */ + cluster: ICluster; + + /** + * The image to start + */ + image: IContainerImage; + + /** + * The number of cpu units used by the task. + * Valid values, which determines your range of valid values for the memory parameter: + * 256 (.25 vCPU) - Available memory values: 0.5GB, 1GB, 2GB + * 512 (.5 vCPU) - Available memory values: 1GB, 2GB, 3GB, 4GB + * 1024 (1 vCPU) - Available memory values: 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB + * 2048 (2 vCPU) - Available memory values: Between 4GB and 16GB in 1GB increments + * 4096 (4 vCPU) - Available memory values: Between 8GB and 30GB in 1GB increments + * + * This default is set in the underlying FargateTaskDefinition construct. + * + * @default 256 + */ + cpu?: string; + + /** + * The amount (in MiB) of memory used by the task. + * + * This field is required and you must use one of the following values, which determines your range of valid values + * for the cpu parameter: + * + * 0.5GB, 1GB, 2GB - Available cpu values: 256 (.25 vCPU) + * + * 1GB, 2GB, 3GB, 4GB - Available cpu values: 512 (.5 vCPU) + * + * 2GB, 3GB, 4GB, 5GB, 6GB, 7GB, 8GB - Available cpu values: 1024 (1 vCPU) + * + * Between 4GB and 16GB in 1GB increments - Available cpu values: 2048 (2 vCPU) + * + * Between 8GB and 30GB in 1GB increments - Available cpu values: 4096 (4 vCPU) + * + * This default is set in the underlying FargateTaskDefinition construct. + * + * @default 512 + */ + memoryMiB?: string; + + /** + * The container port of the application load balancer attached to your Fargate service. Corresponds to container port mapping. + * + * @default 80 + */ + containerPort?: number; + + /** + * Determines whether the Application Load Balancer will be internet-facing + * + * @default true + */ + publicLoadBalancer?: boolean; + + /** + * Determines whether your Fargate Service will be assigned a public IP address. + * + * @default false + */ + publicTasks?: boolean; +} + +/** + * A single task running on an ECS cluster fronted by a load balancer + */ +export class LoadBalancedFargateService extends cdk.Construct { + public readonly loadBalancer: elbv2.ApplicationLoadBalancer; + + constructor(parent: cdk.Construct, id: string, props: LoadBalancedFargateServiceProps) { + super(parent, id); + + const taskDefinition = new FargateTaskDefinition(this, 'TaskDef', { + memoryMiB: props.memoryMiB, + cpu: props.cpu + }); + + const container = taskDefinition.addContainer('web', { + image: props.image, + }); + + container.addPortMappings({ + containerPort: props.containerPort || 80, + }); + + const assignPublicIp = props.publicTasks !== undefined ? props.publicTasks : false; + const service = new FargateService(this, "Service", { + cluster: props.cluster, + taskDefinition, + assignPublicIp + }); + + const internetFacing = props.publicLoadBalancer !== undefined ? props.publicLoadBalancer : true; + const lb = new elbv2.ApplicationLoadBalancer(this, 'LB', { + vpc: props.cluster.vpc, + internetFacing + }); + + this.loadBalancer = lb; + + const listener = lb.addListener('PublicListener', { port: 80, open: true }); + listener.addTargets('ECS', { + port: 80, + targets: [service] + }); + + new cdk.Output(this, 'LoadBalancerDNS', { value: lb.dnsName }); + } +} diff --git a/packages/@aws-cdk/aws-ecs/lib/log-drivers/aws-log-driver.ts b/packages/@aws-cdk/aws-ecs/lib/log-drivers/aws-log-driver.ts new file mode 100644 index 0000000000000..8e8caa8f5e8e1 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/log-drivers/aws-log-driver.ts @@ -0,0 +1,91 @@ +import logs = require('@aws-cdk/aws-logs'); +import cdk = require('@aws-cdk/cdk'); +import { cloudformation } from '../ecs.generated'; +import { LogDriver } from "./log-driver"; + +/** + * Properties for defining a new AWS Log Driver + */ +export interface AwsLogDriverProps { + /** + * Prefix for the log streams + * + * The awslogs-stream-prefix option allows you to associate a log stream + * with the specified prefix, the container name, and the ID of the Amazon + * ECS task to which the container belongs. If you specify a prefix with + * this option, then the log stream takes the following format: + * + * prefix-name/container-name/ecs-task-id + */ + streamPrefix: string; + + /** + * The log group to log to + * + * @default A log group is automatically created + */ + logGroup?: logs.LogGroupRef; + + /** + * This option defines a multiline start pattern in Python strftime format. + * + * A log message consists of a line that matches the pattern and any + * following lines that don’t match the pattern. Thus the matched line is + * the delimiter between log messages. + */ + datetimeFormat?: string; + + /** + * This option defines a multiline start pattern using a regular expression. + * + * A log message consists of a line that matches the pattern and any + * following lines that don’t match the pattern. Thus the matched line is + * the delimiter between log messages. + */ + multilinePattern?: string; +} + +/** + * A log driver that will log to an AWS Log Group + */ +export class AwsLogDriver extends LogDriver { + /** + * The log group that the logs will be sent to + */ + public readonly logGroup: logs.LogGroupRef; + + constructor(parent: cdk.Construct, id: string, private readonly props: AwsLogDriverProps) { + super(parent, id); + this.logGroup = props.logGroup || new logs.LogGroup(this, 'LogGroup', { + retentionDays: 365, + }); + } + + /** + * Return the log driver CloudFormation JSON + */ + public renderLogDriver(): cloudformation.TaskDefinitionResource.LogConfigurationProperty { + return { + logDriver: 'awslogs', + options: removeEmpty({ + 'awslogs-group': this.logGroup.logGroupName, + 'awslogs-stream-prefix': this.props.streamPrefix, + 'awslogs-region': `${new cdk.AwsRegion()}`, + 'awslogs-datetime-format': this.props.datetimeFormat, + 'awslogs-multiline-pattern': this.props.multilinePattern, + }), + }; + } +} + +/** + * Remove undefined values from a dictionary + */ +function removeEmpty(x: {[key: string]: (T | undefined)}): {[key: string]: T} { + for (const key of Object.keys(x)) { + if (!x[key]) { + delete x[key]; + } + } + return x as any; +} diff --git a/packages/@aws-cdk/aws-ecs/lib/log-drivers/log-driver.ts b/packages/@aws-cdk/aws-ecs/lib/log-drivers/log-driver.ts new file mode 100644 index 0000000000000..eb7c1344b5dda --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/log-drivers/log-driver.ts @@ -0,0 +1,12 @@ +import cdk = require('@aws-cdk/cdk'); +import { cloudformation } from '../ecs.generated'; + +/** + * Base class for log drivers + */ +export abstract class LogDriver extends cdk.Construct { + /** + * Return the log driver CloudFormation JSON + */ + public abstract renderLogDriver(): cloudformation.TaskDefinitionResource.LogConfigurationProperty; +} diff --git a/packages/@aws-cdk/aws-ecs/lib/util.ts b/packages/@aws-cdk/aws-ecs/lib/util.ts new file mode 100644 index 0000000000000..e12251822e338 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/lib/util.ts @@ -0,0 +1,9 @@ +import { Compatibility } from "./base/task-definition"; + +export function isEc2Compatible(comp: Compatibility) { + return comp === Compatibility.Ec2 || comp === Compatibility.Ec2AndFargate; +} + +export function isFargateCompatible(comp: Compatibility) { + return comp === Compatibility.Fargate || comp === Compatibility.Ec2AndFargate; +} diff --git a/packages/@aws-cdk/aws-ecs/package.json b/packages/@aws-cdk/aws-ecs/package.json index c30b9d774fecb..a8a0f8e5210f0 100644 --- a/packages/@aws-cdk/aws-ecs/package.json +++ b/packages/@aws-cdk/aws-ecs/package.json @@ -54,11 +54,26 @@ "devDependencies": { "@aws-cdk/assert": "^0.14.1", "cdk-build-tools": "^0.14.1", + "cdk-integ-tools": "^0.14.1", "cfn2ts": "^0.14.1", - "pkglint": "^0.14.1" + "pkglint": "^0.14.1", + "proxyquire": "^2.1.0", + "@types/proxyquire": "^1.3.28" }, "dependencies": { - "@aws-cdk/cdk": "^0.14.1" + "@aws-cdk/aws-applicationautoscaling": "^0.14.1", + "@aws-cdk/aws-autoscaling": "^0.14.1", + "@aws-cdk/aws-cloudformation": "^0.14.1", + "@aws-cdk/aws-cloudwatch": "^0.14.1", + "@aws-cdk/aws-ec2": "^0.14.1", + "@aws-cdk/aws-ecr": "^0.14.1", + "@aws-cdk/aws-elasticloadbalancing": "^0.14.1", + "@aws-cdk/aws-elasticloadbalancingv2": "^0.14.1", + "@aws-cdk/aws-iam": "^0.14.1", + "@aws-cdk/aws-lambda": "^0.14.1", + "@aws-cdk/aws-logs": "^0.14.1", + "@aws-cdk/cdk": "^0.14.1", + "@aws-cdk/cx-api": "^0.14.1" }, "homepage": "https://github.com/awslabs/aws-cdk", "peerDependencies": { diff --git a/packages/@aws-cdk/aws-ecs/test/demo-image/Dockerfile b/packages/@aws-cdk/aws-ecs/test/demo-image/Dockerfile new file mode 100644 index 0000000000000..123b5670febc8 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/demo-image/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.6 +EXPOSE 8000 +WORKDIR /src +ADD . /src +CMD python3 index.py diff --git a/packages/@aws-cdk/aws-ecs/test/demo-image/index.py b/packages/@aws-cdk/aws-ecs/test/demo-image/index.py new file mode 100644 index 0000000000000..2ccedfce3ab76 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/demo-image/index.py @@ -0,0 +1,33 @@ +#!/usr/bin/python +import sys +import textwrap +import http.server +import socketserver + +PORT = 8000 + + +class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.end_headers() + self.wfile.write(textwrap.dedent('''\ + + It works + +

Hello from the integ test container

+

This container got built and started as part of the integ test.

+ + + ''').encode('utf-8')) + + +def main(): + httpd = http.server.HTTPServer(("", PORT), Handler) + print("serving at port", PORT) + httpd.serve_forever() + + +if __name__ == '__main__': + main() diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.expected.json b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.expected.json new file mode 100644 index 0000000000000..c655174233d6e --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.expected.json @@ -0,0 +1,725 @@ +{ + "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" + } + ] + } + }, + "VpcPublicSubnet1Subnet5C2D37C4": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTable6C95E38E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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" + } + } + }, + "VpcPublicSubnet1EIPD7E02669": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VpcPublicSubnet1NATGateway4D7517AA": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet1EIPD7E02669", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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": "Name", + "Value": "aws-ecs-integ/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet2RouteTable94F7E489": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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" + } + } + }, + "VpcPublicSubnet2EIP3C605A87": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VpcPublicSubnet2NATGateway9182C01D": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet2EIP3C605A87", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPrivateSubnet1Subnet536B997A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableB2C5B500": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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": "Name", + "Value": "aws-ecs-integ/Vpc/PrivateSubnet2" + } + ] + } + }, + "VpcPrivateSubnet2RouteTableA678073B": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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": "VpcPublicSubnet2NATGateway9182C01D" + } + } + }, + "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" + } + } + }, + "EcsCluster97242B84": { + "Type": "AWS::ECS::Cluster" + }, + "EcsClusterDefaultAutoScalingGroupInstanceSecurityGroup912E1231": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ/EcsCluster/DefaultAutoScalingGroup/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/EcsCluster/DefaultAutoScalingGroup" + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "EcsClusterDefaultAutoScalingGroupInstanceRole3C026863": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "EcsClusterDefaultAutoScalingGroupInstanceRoleDefaultPolicy04DC6C80": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecs:CreateCluster", + "ecs:DeregisterContainerInstance", + "ecs:DiscoverPollEndpoint", + "ecs:Poll", + "ecs:RegisterContainerInstance", + "ecs:StartTelemetrySession", + "ecs:Submit*", + "ecr:GetAuthorizationToken", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterDefaultAutoScalingGroupInstanceRoleDefaultPolicy04DC6C80", + "Roles": [ + { + "Ref": "EcsClusterDefaultAutoScalingGroupInstanceRole3C026863" + } + ] + } + }, + "EcsClusterDefaultAutoScalingGroupInstanceProfile2CE606B3": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "EcsClusterDefaultAutoScalingGroupInstanceRole3C026863" + } + ] + } + }, + "EcsClusterDefaultAutoScalingGroupLaunchConfigB7E376C1": { + "Type": "AWS::AutoScaling::LaunchConfiguration", + "Properties": { + "ImageId": "ami-1234", + "InstanceType": "t2.micro", + "IamInstanceProfile": { + "Ref": "EcsClusterDefaultAutoScalingGroupInstanceProfile2CE606B3" + }, + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "EcsClusterDefaultAutoScalingGroupInstanceSecurityGroup912E1231", + "GroupId" + ] + } + ], + "UserData": { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\necho ECS_CLUSTER=", + { + "Ref": "EcsCluster97242B84" + }, + " >> /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": [ + "EcsClusterDefaultAutoScalingGroupInstanceRole3C026863", + "EcsClusterDefaultAutoScalingGroupInstanceRoleDefaultPolicy04DC6C80" + ] + }, + "EcsClusterDefaultAutoScalingGroupASGC1A785DB": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "MaxSize": "1", + "MinSize": "0", + "DesiredCapacity": "1", + "LaunchConfigurationName": { + "Ref": "EcsClusterDefaultAutoScalingGroupLaunchConfigB7E376C1" + }, + "Tags": [ + { + "Key": "Name", + "PropagateAtLaunch": true, + "Value": "aws-ecs-integ/EcsCluster/DefaultAutoScalingGroup" + } + ], + "VPCZoneIdentifier": [ + { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + }, + { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + ] + }, + "UpdatePolicy": { + "AutoScalingReplacingUpdate": { + "WillReplace": true + }, + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } + } + }, + "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": "amazon/amazon-ecs-sample", + "Links": [], + "LinuxParameters": { + "Capabilities": { + "Add": [], + "Drop": [] + }, + "Devices": [], + "Tmpfs": [] + }, + "Memory": 256, + "MountPoints": [], + "Name": "web", + "PortMappings": [ + { + "ContainerPort": 80, + "Protocol": "tcp" + } + ], + "Ulimits": [], + "VolumesFrom": [] + } + ], + "Family": "awsecsintegTaskDef6FDFB69A", + "NetworkMode": "awsvpc", + "PlacementConstraints": [], + "RequiresCompatibilities": [ + "EC2" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "TaskDefTaskRole1EDB4A67", + "Arn" + ] + }, + "Volumes": [] + } + }, + "ServiceD69D759B": { + "Type": "AWS::ECS::Service", + "Properties": { + "TaskDefinition": { + "Ref": "TaskDef54694570" + }, + "Cluster": { + "Ref": "EcsCluster97242B84" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 50 + }, + "DesiredCount": 1, + "LaunchType": "EC2", + "LoadBalancers": [ + { + "ContainerName": "web", + "ContainerPort": 80, + "TargetGroupArn": { + "Ref": "LBPublicListenerECSGroupD6A32205" + } + } + ], + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "DISABLED", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "ServiceSecurityGroupC96ED6A7", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + }, + { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + ] + } + }, + "PlacementConstraints": [], + "PlacementStrategies": [], + "SchedulingStrategy": "REPLICA" + }, + "DependsOn": [ + "LBPublicListener6E1F3D94" + ] + }, + "ServiceSecurityGroupC96ED6A7": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ/Service/SecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "ServiceSecurityGroupfromawsecsintegLBSecurityGroupC30F5EB480CD1B9463": { + "Type": "AWS::EC2::SecurityGroupIngress", + "Properties": { + "IpProtocol": "tcp", + "Description": "Load balancer to target", + "FromPort": 80, + "GroupId": { + "Fn::GetAtt": [ + "ServiceSecurityGroupC96ED6A7", + "GroupId" + ] + }, + "SourceSecurityGroupId": { + "Fn::GetAtt": [ + "LBSecurityGroup8A41EA2B", + "GroupId" + ] + }, + "ToPort": 80 + } + }, + "LB8A12904C": { + "Type": "AWS::ElasticLoadBalancingV2::LoadBalancer", + "Properties": { + "LoadBalancerAttributes": [], + "Scheme": "internet-facing", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "LBSecurityGroup8A41EA2B", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + } + ], + "Type": "application" + } + }, + "LBSecurityGroup8A41EA2B": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "Automatically created Security Group for ELB awsecsintegLBC73915FE", + "SecurityGroupEgress": [], + "SecurityGroupIngress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow from anyone on port 80", + "FromPort": 80, + "IpProtocol": "tcp", + "ToPort": 80 + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "LBSecurityGrouptoawsecsintegServiceSecurityGroup48EE4368807B287D7F": { + "Type": "AWS::EC2::SecurityGroupEgress", + "Properties": { + "GroupId": { + "Fn::GetAtt": [ + "LBSecurityGroup8A41EA2B", + "GroupId" + ] + }, + "IpProtocol": "tcp", + "Description": "Load balancer to target", + "DestinationSecurityGroupId": { + "Fn::GetAtt": [ + "ServiceSecurityGroupC96ED6A7", + "GroupId" + ] + }, + "FromPort": 80, + "ToPort": 80 + } + }, + "LBPublicListener6E1F3D94": { + "Type": "AWS::ElasticLoadBalancingV2::Listener", + "Properties": { + "DefaultActions": [ + { + "TargetGroupArn": { + "Ref": "LBPublicListenerECSGroupD6A32205" + }, + "Type": "forward" + } + ], + "LoadBalancerArn": { + "Ref": "LB8A12904C" + }, + "Port": 80, + "Protocol": "HTTP", + "Certificates": [] + } + }, + "LBPublicListenerECSGroupD6A32205": { + "Type": "AWS::ElasticLoadBalancingV2::TargetGroup", + "Properties": { + "Port": 80, + "Protocol": "HTTP", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "TargetGroupAttributes": [], + "Targets": [], + "TargetType": "ip" + } + } + }, + "Outputs": { + "LoadBalancerDNS": { + "Value": { + "Fn::GetAtt": [ + "LB8A12904C", + "DNSName" + ] + }, + "Export": { + "Name": "aws-ecs-integ:LoadBalancerDNS" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.ts b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.ts new file mode 100644 index 0000000000000..b40be9b57e319 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-awsvpc-nw.ts @@ -0,0 +1,45 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import elbv2 = require('@aws-cdk/aws-elasticloadbalancingv2'); +import cdk = require('@aws-cdk/cdk'); +import ecs = require('../../lib'); +import { NetworkMode } from '../../lib'; + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-ecs-integ'); + +const vpc = new ec2.VpcNetwork(stack, 'Vpc', { maxAZs: 2 }); + +const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); +cluster.addDefaultAutoScalingGroupCapacity({ + instanceType: new ec2.InstanceType('t2.micro') +}); + +const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: NetworkMode.AwsVpc +}); + +const container = taskDefinition.addContainer('web', { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 256, +}); + +container.addPortMappings({ + containerPort: 80, + protocol: ecs.Protocol.Tcp +}); + +const service = new ecs.Ec2Service(stack, "Service", { + cluster, + taskDefinition, +}); + +const lb = new elbv2.ApplicationLoadBalancer(stack, 'LB', { vpc, internetFacing: true }); +const listener = lb.addListener('PublicListener', { port: 80, open: true }); +listener.addTargets('ECS', { + port: 80, + targets: [service] +}); + +new cdk.Output(stack, 'LoadBalancerDNS', { value: lb.dnsName, }); + +app.run(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.expected.json b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.expected.json new file mode 100644 index 0000000000000..7e26702951204 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.expected.json @@ -0,0 +1,688 @@ +{ + "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-ecs/Vpc" + } + ] + } + }, + "VpcPublicSubnet1Subnet5C2D37C4": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTable6C95E38E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/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" + } + } + }, + "VpcPublicSubnet1EIPD7E02669": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VpcPublicSubnet1NATGateway4D7517AA": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet1EIPD7E02669", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/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": "Name", + "Value": "aws-ecs-integ-ecs/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet2RouteTable94F7E489": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/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" + } + } + }, + "VpcPublicSubnet2EIP3C605A87": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VpcPublicSubnet2NATGateway9182C01D": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet2EIP3C605A87", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPrivateSubnet1Subnet536B997A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableB2C5B500": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/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": "Name", + "Value": "aws-ecs-integ-ecs/Vpc/PrivateSubnet2" + } + ] + } + }, + "VpcPrivateSubnet2RouteTableA678073B": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/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": "VpcPublicSubnet2NATGateway9182C01D" + } + } + }, + "VpcIGWD7BA715C": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/Vpc" + } + ] + } + }, + "VpcVPCGWBF912B6E": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "InternetGatewayId": { + "Ref": "VpcIGWD7BA715C" + } + } + }, + "EcsCluster97242B84": { + "Type": "AWS::ECS::Cluster" + }, + "EcsClusterDefaultAutoScalingGroupInstanceSecurityGroup912E1231": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ-ecs/EcsCluster/DefaultAutoScalingGroup/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [], + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ-ecs/EcsCluster/DefaultAutoScalingGroup" + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "EcsClusterDefaultAutoScalingGroupInstanceSecurityGroupfromawsecsintegecsLBSecurityGroup7DA9012980800B834EB8": { + "Type": "AWS::EC2::SecurityGroupIngress", + "Properties": { + "IpProtocol": "tcp", + "Description": "Load balancer to target", + "FromPort": 8080, + "GroupId": { + "Fn::GetAtt": [ + "EcsClusterDefaultAutoScalingGroupInstanceSecurityGroup912E1231", + "GroupId" + ] + }, + "SourceSecurityGroupId": { + "Fn::GetAtt": [ + "LBSecurityGroup8A41EA2B", + "GroupId" + ] + }, + "ToPort": 8080 + } + }, + "EcsClusterDefaultAutoScalingGroupInstanceRole3C026863": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "EcsClusterDefaultAutoScalingGroupInstanceRoleDefaultPolicy04DC6C80": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecs:CreateCluster", + "ecs:DeregisterContainerInstance", + "ecs:DiscoverPollEndpoint", + "ecs:Poll", + "ecs:RegisterContainerInstance", + "ecs:StartTelemetrySession", + "ecs:Submit*", + "ecr:GetAuthorizationToken", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "EcsClusterDefaultAutoScalingGroupInstanceRoleDefaultPolicy04DC6C80", + "Roles": [ + { + "Ref": "EcsClusterDefaultAutoScalingGroupInstanceRole3C026863" + } + ] + } + }, + "EcsClusterDefaultAutoScalingGroupInstanceProfile2CE606B3": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "EcsClusterDefaultAutoScalingGroupInstanceRole3C026863" + } + ] + } + }, + "EcsClusterDefaultAutoScalingGroupLaunchConfigB7E376C1": { + "Type": "AWS::AutoScaling::LaunchConfiguration", + "Properties": { + "ImageId": "ami-1234", + "InstanceType": "t2.micro", + "IamInstanceProfile": { + "Ref": "EcsClusterDefaultAutoScalingGroupInstanceProfile2CE606B3" + }, + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "EcsClusterDefaultAutoScalingGroupInstanceSecurityGroup912E1231", + "GroupId" + ] + } + ], + "UserData": { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\necho ECS_CLUSTER=", + { + "Ref": "EcsCluster97242B84" + }, + " >> /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": [ + "EcsClusterDefaultAutoScalingGroupInstanceRole3C026863", + "EcsClusterDefaultAutoScalingGroupInstanceRoleDefaultPolicy04DC6C80" + ] + }, + "EcsClusterDefaultAutoScalingGroupASGC1A785DB": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "MaxSize": "1", + "MinSize": "0", + "DesiredCapacity": "1", + "LaunchConfigurationName": { + "Ref": "EcsClusterDefaultAutoScalingGroupLaunchConfigB7E376C1" + }, + "Tags": [ + { + "Key": "Name", + "PropagateAtLaunch": true, + "Value": "aws-ecs-integ-ecs/EcsCluster/DefaultAutoScalingGroup" + } + ], + "VPCZoneIdentifier": [ + { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + }, + { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + ] + }, + "UpdatePolicy": { + "AutoScalingReplacingUpdate": { + "WillReplace": true + }, + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } + } + }, + "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": "amazon/amazon-ecs-sample", + "Links": [], + "LinuxParameters": { + "Capabilities": { + "Add": [], + "Drop": [] + }, + "Devices": [], + "Tmpfs": [] + }, + "Memory": 256, + "MountPoints": [], + "Name": "web", + "PortMappings": [ + { + "ContainerPort": 80, + "HostPort": 8080, + "Protocol": "tcp" + } + ], + "Ulimits": [], + "VolumesFrom": [] + } + ], + "Family": "awsecsintegecsTaskDef8DD0C801", + "NetworkMode": "bridge", + "PlacementConstraints": [], + "RequiresCompatibilities": [ + "EC2" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "TaskDefTaskRole1EDB4A67", + "Arn" + ] + }, + "Volumes": [] + } + }, + "ServiceD69D759B": { + "Type": "AWS::ECS::Service", + "Properties": { + "TaskDefinition": { + "Ref": "TaskDef54694570" + }, + "Cluster": { + "Ref": "EcsCluster97242B84" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 50 + }, + "DesiredCount": 1, + "LaunchType": "EC2", + "LoadBalancers": [ + { + "ContainerName": "web", + "ContainerPort": 80, + "TargetGroupArn": { + "Ref": "LBPublicListenerECSGroupD6A32205" + } + } + ], + "PlacementConstraints": [], + "PlacementStrategies": [], + "SchedulingStrategy": "REPLICA" + }, + "DependsOn": [ + "LBPublicListener6E1F3D94" + ] + }, + "LB8A12904C": { + "Type": "AWS::ElasticLoadBalancingV2::LoadBalancer", + "Properties": { + "LoadBalancerAttributes": [], + "Scheme": "internet-facing", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "LBSecurityGroup8A41EA2B", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + } + ], + "Type": "application" + } + }, + "LBSecurityGroup8A41EA2B": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "Automatically created Security Group for ELB awsecsintegecsLB84BFA683", + "SecurityGroupEgress": [], + "SecurityGroupIngress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow from anyone on port 80", + "FromPort": 80, + "IpProtocol": "tcp", + "ToPort": 80 + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "LBSecurityGrouptoawsecsintegecsEcsClusterDefaultAutoScalingGroupInstanceSecurityGroupE3116410808033398DFA": { + "Type": "AWS::EC2::SecurityGroupEgress", + "Properties": { + "GroupId": { + "Fn::GetAtt": [ + "LBSecurityGroup8A41EA2B", + "GroupId" + ] + }, + "IpProtocol": "tcp", + "Description": "Load balancer to target", + "DestinationSecurityGroupId": { + "Fn::GetAtt": [ + "EcsClusterDefaultAutoScalingGroupInstanceSecurityGroup912E1231", + "GroupId" + ] + }, + "FromPort": 8080, + "ToPort": 8080 + } + }, + "LBPublicListener6E1F3D94": { + "Type": "AWS::ElasticLoadBalancingV2::Listener", + "Properties": { + "DefaultActions": [ + { + "TargetGroupArn": { + "Ref": "LBPublicListenerECSGroupD6A32205" + }, + "Type": "forward" + } + ], + "LoadBalancerArn": { + "Ref": "LB8A12904C" + }, + "Port": 80, + "Protocol": "HTTP", + "Certificates": [] + } + }, + "LBPublicListenerECSGroupD6A32205": { + "Type": "AWS::ElasticLoadBalancingV2::TargetGroup", + "Properties": { + "Port": 80, + "Protocol": "HTTP", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "TargetGroupAttributes": [], + "Targets": [], + "TargetType": "instance" + } + } + }, + "Outputs": { + "LoadBalancerDNS": { + "Value": { + "Fn::GetAtt": [ + "LB8A12904C", + "DNSName" + ] + }, + "Export": { + "Name": "aws-ecs-integ-ecs:LoadBalancerDNS" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.ts b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.ts new file mode 100644 index 0000000000000..2d8cf306f3886 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/integ.lb-bridge-nw.ts @@ -0,0 +1,47 @@ + +import ec2 = require('@aws-cdk/aws-ec2'); +import elbv2 = require('@aws-cdk/aws-elasticloadbalancingv2'); +import cdk = require('@aws-cdk/cdk'); +import ecs = require('../../lib'); + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-ecs-integ-ecs'); + +const vpc = new ec2.VpcNetwork(stack, 'Vpc', { maxAZs: 2 }); + +const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); +cluster.addDefaultAutoScalingGroupCapacity({ + instanceType: new ec2.InstanceType('t2.micro') +}); + +const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + // networkMode defaults to "bridge" + // memoryMiB: '1GB', + // cpu: '512' +}); + +const container = taskDefinition.addContainer('web', { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 256, +}); +container.addPortMappings({ + containerPort: 80, + hostPort: 8080, + protocol: ecs.Protocol.Tcp +}); + +const service = new ecs.Ec2Service(stack, "Service", { + cluster, + taskDefinition, +}); + +const lb = new elbv2.ApplicationLoadBalancer(stack, 'LB', { vpc, internetFacing: true }); +const listener = lb.addListener('PublicListener', { port: 80, open: true }); +listener.addTargets('ECS', { + port: 80, + targets: [service] +}); + +new cdk.Output(stack, 'LoadBalancerDNS', { value: lb.dnsName, }); + +app.run(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts new file mode 100644 index 0000000000000..93a0fa4dc4b7f --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts @@ -0,0 +1,497 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import ec2 = require('@aws-cdk/aws-ec2'); +import elb = require('@aws-cdk/aws-elasticloadbalancing'); +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import ecs = require('../../lib'); +import { BinPackResource, BuiltInAttributes, NetworkMode } from '../../lib'; + +export = { + "When creating an ECS Service": { + "with only required properties set, it correctly sets default properties"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + TaskDefinition: { + Ref: "Ec2TaskDef0226F28C" + }, + Cluster: { + Ref: "EcsCluster97242B84" + }, + DeploymentConfiguration: { + MaximumPercent: 200, + MinimumHealthyPercent: 50 + }, + DesiredCount: 1, + LaunchType: "EC2", + LoadBalancers: [], + PlacementConstraints: [], + PlacementStrategies: [], + SchedulingStrategy: "REPLICA" + })); + + test.done(); + }, + + "errors if daemon and desiredCount both specified"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + // THEN + test.throws(() => { + new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + daemon: true, + desiredCount: 2 + }); + }); + + test.done(); + }, + + "errors if no container definitions"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + // THEN + test.throws(() => { + new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + }); + }); + + test.done(); + }, + + "sets daemon scheduling strategy"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + daemon: true + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + SchedulingStrategy: "DAEMON" + })); + + test.done(); + }, + + "with a TaskDefinition with Bridge network mode": { + "it errors if vpcPlacement is specified"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef', { + networkMode: NetworkMode.Bridge + }); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + // THEN + test.throws(() => { + new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + vpcPlacement: { + subnetsToUse: ec2.SubnetType.Public + } + }); + }); + + // THEN + test.done(); + }, + }, + + "with a TaskDefinition with AwsVpc network mode": { + "it creates a security group for the service"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef', { + networkMode: NetworkMode.AwsVpc + }); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + NetworkConfiguration: { + AwsvpcConfiguration: { + AssignPublicIp: "DISABLED", + SecurityGroups: [ + { + "Fn::GetAtt": [ + "Ec2ServiceSecurityGroupAEC30825", + "GroupId" + ] + } + ], + Subnets: [ + { + Ref: "MyVpcPrivateSubnet1Subnet5057CF7E" + }, + { + Ref: "MyVpcPrivateSubnet2Subnet0040C983" + }, + { + Ref: "MyVpcPrivateSubnet3Subnet772D6AD7" + } + ] + } + } + })); + + test.done(); + }, + + "it allows vpcPlacement"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef', { + networkMode: NetworkMode.AwsVpc + }); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + vpcPlacement: { + subnetsToUse: ec2.SubnetType.Public + } + }); + + // THEN + test.done(); + }, + }, + + "with distinctInstance placement constraint"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + placeOnDistinctInstances: true + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + PlacementConstraints: [{ + Type: "distinctInstance" + }] + })); + + test.done(); + }, + + "with memberOf placement constraints"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + const service = new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition + }); + + service.placeOnMemberOf("attribute:ecs.instance-type =~ t2.*"); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + PlacementConstraints: [{ + Expression: "attribute:ecs.instance-type =~ t2.*", + Type: "memberOf" + }] + })); + + test.done(); + }, + + "with placeSpreadAcross placement strategy"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + const service = new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition + }); + + service.placeSpreadAcross(BuiltInAttributes.AvailabilityZone); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + PlacementStrategies: [{ + Field: "attribute:ecs.availability-zone", + Type: "spread" + }] + })); + + test.done(); + }, + + "errors with placeSpreadAcross placement strategy if daemon specified"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + const service = new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + daemon: true + }); + + // THEN + test.throws(() => { + service.placeSpreadAcross(BuiltInAttributes.AvailabilityZone); + }); + + test.done(); + }, + + "with placeRandomly placement strategy"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc'); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + const service = new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition + }); + + service.placeRandomly(); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + PlacementStrategies: [{ + Type: "random" + }] + })); + + test.done(); + }, + + "errors with placeRandomly placement strategy if daemon specified"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc'); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + const service = new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + daemon: true + }); + + // THEN + test.throws(() => { + service.placeRandomly(); + }); + + test.done(); + }, + + "with placePackedBy placement strategy"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + const service = new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition + }); + + service.placePackedBy(BinPackResource.Memory); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + PlacementStrategies: [{ + Field: "memory", + Type: "binpack" + }] + })); + + test.done(); + }, + + "errors with placePackedBy placement strategy if daemon specified"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + const service = new ecs.Ec2Service(stack, "Ec2Service", { + cluster, + taskDefinition, + daemon: true + }); + + // THEN + test.throws(() => { + service.placePackedBy(BinPackResource.Memory); + }); + + test.done(); + } + }, + + 'classic ELB': { + 'can attach to classic ELB'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'VPC'); + const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TD', { networkMode: ecs.NetworkMode.Host }); + const container = taskDefinition.addContainer('web', { + image: ecs.DockerHub.image('test'), + memoryLimitMiB: 1024, + }); + container.addPortMappings({ containerPort: 808 }); + const service = new ecs.Ec2Service(stack, 'Service', { cluster, taskDefinition }); + + // WHEN + const lb = new elb.LoadBalancer(stack, 'LB', { vpc }); + lb.addTarget(service); + + // THEN + expect(stack).to(haveResource('AWS::ECS::Service', { + LoadBalancers: [ + { + ContainerName: "web", + ContainerPort: 808, + LoadBalancerName: { Ref: "LB8A12904C" } + } + ], + })); + + test.done(); + }, + } +}; diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-task-definition.ts b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-task-definition.ts new file mode 100644 index 0000000000000..5b57fc043e16d --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-task-definition.ts @@ -0,0 +1,260 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import { Protocol } from '@aws-cdk/aws-ec2'; +// import iam = require('@aws-cdk/aws-iam'); // importing this is throwing a really weird error in line 11? +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import ecs = require('../../lib'); + +export = { + "When creating an ECS TaskDefinition": { + "with only required properties set, it correctly sets default properties"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + // THEN + expect(stack).to(haveResource("AWS::ECS::TaskDefinition", { + Family: "Ec2TaskDef", + ContainerDefinitions: [], + PlacementConstraints: [], + Volumes: [], + NetworkMode: ecs.NetworkMode.Bridge, + RequiresCompatibilities: ["EC2"] + })); + + // test error if no container defs? + test.done(); + }, + + "correctly sets network mode"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef', { + networkMode: ecs.NetworkMode.AwsVpc + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::TaskDefinition", { + NetworkMode: ecs.NetworkMode.AwsVpc, + })); + + test.done(); + }, + + "correctly sets containers"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + const container = taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 // add validation? + }); + + // TODO test other containerDefinition methods + container.addPortMappings({ + containerPort: 3000 + }); + + container.addUlimits({ + hardLimit: 128, + name: ecs.UlimitName.Rss, + softLimit: 128 + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::TaskDefinition", { + Family: "Ec2TaskDef", + ContainerDefinitions: [{ + Essential: true, + Memory: 512, + Image: "amazon/amazon-ecs-sample", + Links: [], + LinuxParameters: { + Capabilities: { + Add: [], + Drop: [] + }, + Devices: [], + Tmpfs: [] + }, + MountPoints: [], + Name: "web", + PortMappings: [{ + ContainerPort: 3000, + HostPort: 0, + Protocol: Protocol.Tcp + }], + Ulimits: [{ + HardLimit: 128, + Name: "rss", + SoftLimit: 128 + }], + VolumesFrom: [] + }], + })); + + test.done(); + }, + + "correctly sets scratch space"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + const container = taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + container.addScratch({ + containerPath: "./cache", + readOnly: true, + sourcePath: "/tmp/cache", + name: "scratch" + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::TaskDefinition", { + Family: "Ec2TaskDef", + ContainerDefinitions: [{ + MountPoints: [ + { + ContainerPath: "./cache", + ReadOnly: true, + SourceVolume: "scratch" + } + ] + }], + Volumes: [{ + Host: { + SourcePath: "/tmp/cache" + }, + Name: "scratch" + }] + })); + + test.done(); + }, + + "correctly sets volumes"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const volume = { + host: { + sourcePath: "/tmp/cache", + }, + name: "scratch" + }; + + // Adding volumes via props is a bit clunky + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef', { + volumes: [volume] + }); + + const container = taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + memoryLimitMiB: 512 + }); + + // this needs to be a better API -- should auto-add volumes + container.addMountPoints({ + containerPath: "./cache", + readOnly: true, + sourceVolume: "scratch", + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::TaskDefinition", { + Family: "Ec2TaskDef", + ContainerDefinitions: [{ + MountPoints: [ + { + ContainerPath: "./cache", + ReadOnly: true, + SourceVolume: "scratch" + } + ] + }], + Volumes: [{ + Host: { + SourcePath: "/tmp/cache" + }, + Name: "scratch" + }] + })); + + test.done(); + }, + + "correctly sets placement constraints"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef', { + placementConstraints: [{ + expression: "attribute:ecs.instance-type =~ t2.*", + type: ecs.PlacementConstraintType.MemberOf + }] + }); + + taskDefinition.addContainer("web", { + memoryLimitMiB: 1024, + image: ecs.DockerHub.image("amazon/amazon-ecs-sample") + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::TaskDefinition", { + PlacementConstraints: [ + { + Expression: "attribute:ecs.instance-type =~ t2.*", + Type: "memberOf" + } + ] + })); + + test.done(); + }, + + // "correctly sets taskRole"(test: Test) { + // // GIVEN + // const stack = new cdk.Stack(); + // const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef', { + // taskRole: new iam.Role(this, 'TaskRole', { + // assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'), + // }) + // }); + + // taskDefinition.addContainer("web", { + // image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + // memoryLimitMiB: 512 + // }); + + // // THEN + // expect(stack).to(haveResource("AWS::ECS::TaskDefinition", { + // TaskRole: "roleArn" + // })); + + // test.done(); + // }, + + // "correctly sets taskExecutionRole if containerDef uses ECR"(test: Test) { + // // GIVEN + // const stack = new cdk.Stack(); + // const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef', {}); + // const container = taskDefinition.addContainer("web", { + // image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + // memoryLimitMiB: 512 // add validation? + // }); + + // container.useEcrImage(); + + // // THEN + // expect(stack).to(haveResource("AWS::ECS::TaskDefinition", { + // TaskExecutionRole: "roleArn" + // })); + + // test.done(); + // }, + } +}; diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/integ.asset-image.expected.json b/packages/@aws-cdk/aws-ecs/test/fargate/integ.asset-image.expected.json new file mode 100644 index 0000000000000..083b5b9ba3d22 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/fargate/integ.asset-image.expected.json @@ -0,0 +1,804 @@ +{ + "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" + } + ] + } + }, + "VpcPublicSubnet1Subnet5C2D37C4": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTable6C95E38E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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" + } + } + }, + "VpcPublicSubnet1EIPD7E02669": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VpcPublicSubnet1NATGateway4D7517AA": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet1EIPD7E02669", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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": "Name", + "Value": "aws-ecs-integ/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet2RouteTable94F7E489": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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" + } + } + }, + "VpcPublicSubnet2EIP3C605A87": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VpcPublicSubnet2NATGateway9182C01D": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet2EIP3C605A87", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPrivateSubnet1Subnet536B997A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableB2C5B500": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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": "Name", + "Value": "aws-ecs-integ/Vpc/PrivateSubnet2" + } + ] + } + }, + "VpcPrivateSubnet2RouteTableA678073B": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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": "VpcPublicSubnet2NATGateway9182C01D" + } + } + }, + "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" + } + } + }, + "ClusterEB0386A7": { + "Type": "AWS::ECS::Cluster" + }, + "ImageAdoptRepositoryE1E84E35": { + "Type": "AWS::CloudFormation::CustomResource", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62c52BE89E9", + "Arn" + ] + }, + "RepositoryArn": { + "Ref": "ImageRepositoryC2BE7AD4" + } + } + }, + "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cServiceRoleD788AA17": { + "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" + ] + ] + } + ] + } + }, + "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cServiceRoleDefaultPolicy6BC8737C": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecr:GetRepositoryPolicy", + "ecr:SetRepositoryPolicy", + "ecr:DeleteRepository", + "ecr:ListImages", + "ecr:BatchDeleteImage" + ], + "Effect": "Allow", + "Resource": { + "Ref": "ImageRepositoryC2BE7AD4" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cServiceRoleDefaultPolicy6BC8737C", + "Roles": [ + { + "Ref": "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cServiceRoleD788AA17" + } + ] + } + }, + "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62c52BE89E9": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cCodeS3Bucket92AB06B6" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cCodeS3VersionKey393B7276" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cCodeS3VersionKey393B7276" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "handler.handler", + "Role": { + "Fn::GetAtt": [ + "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cServiceRoleD788AA17", + "Arn" + ] + }, + "Runtime": "nodejs8.10", + "Timeout": 300 + }, + "DependsOn": [ + "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cServiceRoleD788AA17", + "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cServiceRoleDefaultPolicy6BC8737C" + ] + }, + "FargateServiceTaskDefTaskRole8CDCF85E": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "FargateServiceTaskDef940E3A80": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "Essential": true, + "Image": { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "ImageAdoptRepositoryE1E84E35", + "RepositoryUri" + ] + }, + ":", + { + "Ref": "ImageTagE17D8A6B" + } + ] + ] + }, + "Links": [], + "LinuxParameters": { + "Capabilities": { + "Add": [], + "Drop": [] + }, + "Devices": [], + "Tmpfs": [] + }, + "MountPoints": [], + "Name": "web", + "PortMappings": [ + { + "ContainerPort": 8000, + "Protocol": "tcp" + } + ], + "Ulimits": [], + "VolumesFrom": [] + } + ], + "Cpu": "256", + "ExecutionRoleArn": { + "Fn::GetAtt": [ + "FargateServiceTaskDefExecutionRole9194820E", + "Arn" + ] + }, + "Family": "awsecsintegFargateServiceTaskDefE1C73F14", + "Memory": "512", + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "FARGATE" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "FargateServiceTaskDefTaskRole8CDCF85E", + "Arn" + ] + }, + "Volumes": [] + } + }, + "FargateServiceTaskDefExecutionRole9194820E": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "FargateServiceTaskDefExecutionRoleDefaultPolicy827E7CA2": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + "Effect": "Allow", + "Resource": { + "Ref": "ImageRepositoryC2BE7AD4" + } + }, + { + "Action": [ + "ecr:GetAuthorizationToken", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "FargateServiceTaskDefExecutionRoleDefaultPolicy827E7CA2", + "Roles": [ + { + "Ref": "FargateServiceTaskDefExecutionRole9194820E" + } + ] + } + }, + "FargateServiceECC8084D": { + "Type": "AWS::ECS::Service", + "Properties": { + "TaskDefinition": { + "Ref": "FargateServiceTaskDef940E3A80" + }, + "Cluster": { + "Ref": "ClusterEB0386A7" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 50 + }, + "DesiredCount": 1, + "LaunchType": "FARGATE", + "LoadBalancers": [ + { + "ContainerName": "web", + "ContainerPort": 8000, + "TargetGroupArn": { + "Ref": "FargateServiceLBPublicListenerECSGroupBE57E081" + } + } + ], + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "DISABLED", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "FargateServiceSecurityGroup262B61DD", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + }, + { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + ] + } + } + }, + "DependsOn": [ + "FargateServiceLBPublicListener4B4929CA" + ] + }, + "FargateServiceSecurityGroup262B61DD": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ/FargateService/Service/SecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "FargateServiceSecurityGroupfromawsecsintegFargateServiceLBSecurityGroup129467A18000AD32AE25": { + "Type": "AWS::EC2::SecurityGroupIngress", + "Properties": { + "IpProtocol": "tcp", + "Description": "Load balancer to target", + "FromPort": 8000, + "GroupId": { + "Fn::GetAtt": [ + "FargateServiceSecurityGroup262B61DD", + "GroupId" + ] + }, + "SourceSecurityGroupId": { + "Fn::GetAtt": [ + "FargateServiceLBSecurityGroup5F444C78", + "GroupId" + ] + }, + "ToPort": 8000 + } + }, + "FargateServiceLBB353E155": { + "Type": "AWS::ElasticLoadBalancingV2::LoadBalancer", + "Properties": { + "LoadBalancerAttributes": [], + "Scheme": "internet-facing", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "FargateServiceLBSecurityGroup5F444C78", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + } + ], + "Type": "application" + } + }, + "FargateServiceLBSecurityGroup5F444C78": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "Automatically created Security Group for ELB awsecsintegFargateServiceLB5FE4725D", + "SecurityGroupEgress": [], + "SecurityGroupIngress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow from anyone on port 80", + "FromPort": 80, + "IpProtocol": "tcp", + "ToPort": 80 + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "FargateServiceLBSecurityGrouptoawsecsintegFargateServiceSecurityGroup8930AEB880001FF8BADE": { + "Type": "AWS::EC2::SecurityGroupEgress", + "Properties": { + "GroupId": { + "Fn::GetAtt": [ + "FargateServiceLBSecurityGroup5F444C78", + "GroupId" + ] + }, + "IpProtocol": "tcp", + "Description": "Load balancer to target", + "DestinationSecurityGroupId": { + "Fn::GetAtt": [ + "FargateServiceSecurityGroup262B61DD", + "GroupId" + ] + }, + "FromPort": 8000, + "ToPort": 8000 + } + }, + "FargateServiceLBPublicListener4B4929CA": { + "Type": "AWS::ElasticLoadBalancingV2::Listener", + "Properties": { + "DefaultActions": [ + { + "TargetGroupArn": { + "Ref": "FargateServiceLBPublicListenerECSGroupBE57E081" + }, + "Type": "forward" + } + ], + "LoadBalancerArn": { + "Ref": "FargateServiceLBB353E155" + }, + "Port": 80, + "Protocol": "HTTP", + "Certificates": [] + } + }, + "FargateServiceLBPublicListenerECSGroupBE57E081": { + "Type": "AWS::ElasticLoadBalancingV2::TargetGroup", + "Properties": { + "Port": 80, + "Protocol": "HTTP", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "TargetGroupAttributes": [], + "Targets": [], + "TargetType": "ip" + } + } + }, + "Parameters": { + "ImageRepositoryC2BE7AD4": { + "Type": "String", + "Description": "Repository ARN for asset \"aws-ecs-integ/Image\"" + }, + "ImageTagE17D8A6B": { + "Type": "String", + "Description": "Tag for asset \"aws-ecs-integ/Image\"" + }, + "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cCodeS3Bucket92AB06B6": { + "Type": "String", + "Description": "S3 bucket for asset \"aws-ecs-integ/AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62c/Code\"" + }, + "AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62cCodeS3VersionKey393B7276": { + "Type": "String", + "Description": "S3 key for asset version \"aws-ecs-integ/AdoptEcrRepositorydbc60defc59544bcaa5c28c95d68f62c/Code\"" + } + }, + "Outputs": { + "FargateServiceLoadBalancerDNS9433D5F6": { + "Value": { + "Fn::GetAtt": [ + "FargateServiceLBB353E155", + "DNSName" + ] + }, + "Export": { + "Name": "aws-ecs-integ:FargateServiceLoadBalancerDNS9433D5F6" + } + }, + "LoadBalancerDNS": { + "Value": { + "Fn::GetAtt": [ + "FargateServiceLBB353E155", + "DNSName" + ] + }, + "Export": { + "Name": "aws-ecs-integ:LoadBalancerDNS" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/integ.asset-image.ts b/packages/@aws-cdk/aws-ecs/test/fargate/integ.asset-image.ts new file mode 100644 index 0000000000000..c80af87c3776a --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/fargate/integ.asset-image.ts @@ -0,0 +1,27 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import cdk = require('@aws-cdk/cdk'); +import path = require('path'); +import ecs = require('../../lib'); + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-ecs-integ'); +const vpc = new ec2.VpcNetwork(stack, 'Vpc', { maxAZs: 2 }); + +const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + +Array.isArray(cluster); +Array.isArray(path); + +// Instantiate Fargate Service with just cluster and image +const fargateService = new ecs.LoadBalancedFargateService(stack, "FargateService", { + cluster, + containerPort: 8000, + image: new ecs.AssetImage(stack, 'Image', { + directory: path.join(__dirname, '..', 'demo-image') + }) +}); + +// Output the DNS where you can access your service +new cdk.Output(stack, 'LoadBalancerDNS', { value: fargateService.loadBalancer.dnsName }); + +app.run(); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/integ.lb-awsvpc-nw.expected.json b/packages/@aws-cdk/aws-ecs/test/fargate/integ.lb-awsvpc-nw.expected.json new file mode 100644 index 0000000000000..d4f7ba85f8c29 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/fargate/integ.lb-awsvpc-nw.expected.json @@ -0,0 +1,634 @@ +{ + "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" + } + ] + } + }, + "VpcPublicSubnet1Subnet5C2D37C4": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTable6C95E38E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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" + } + } + }, + "VpcPublicSubnet1EIPD7E02669": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VpcPublicSubnet1NATGateway4D7517AA": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet1EIPD7E02669", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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": "Name", + "Value": "aws-ecs-integ/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet2RouteTable94F7E489": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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" + } + } + }, + "VpcPublicSubnet2EIP3C605A87": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VpcPublicSubnet2NATGateway9182C01D": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet2EIP3C605A87", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPrivateSubnet1Subnet536B997A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/18", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableB2C5B500": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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": "Name", + "Value": "aws-ecs-integ/Vpc/PrivateSubnet2" + } + ] + } + }, + "VpcPrivateSubnet2RouteTableA678073B": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/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": "VpcPublicSubnet2NATGateway9182C01D" + } + } + }, + "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" + }, + "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": "amazon/amazon-ecs-sample", + "Links": [], + "LinuxParameters": { + "Capabilities": { + "Add": [], + "Drop": [] + }, + "Devices": [], + "Tmpfs": [] + }, + "MountPoints": [], + "Name": "web", + "PortMappings": [ + { + "ContainerPort": 80, + "Protocol": "tcp" + } + ], + "Ulimits": [], + "VolumesFrom": [] + } + ], + "Cpu": "512", + "Family": "awsecsintegTaskDef6FDFB69A", + "Memory": "1GB", + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "FARGATE" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "TaskDefTaskRole1EDB4A67", + "Arn" + ] + }, + "Volumes": [] + } + }, + "ServiceD69D759B": { + "Type": "AWS::ECS::Service", + "Properties": { + "TaskDefinition": { + "Ref": "TaskDef54694570" + }, + "Cluster": { + "Ref": "FargateCluster7CCD5F93" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 50 + }, + "DesiredCount": 1, + "LaunchType": "FARGATE", + "LoadBalancers": [ + { + "ContainerName": "web", + "ContainerPort": 80, + "TargetGroupArn": { + "Ref": "LBPublicListenerFargateGroup5EE2FBAF" + } + } + ], + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "DISABLED", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "ServiceSecurityGroupC96ED6A7", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + }, + { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + ] + } + } + }, + "DependsOn": [ + "LBPublicListener6E1F3D94" + ] + }, + "ServiceSecurityGroupC96ED6A7": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ/Service/SecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "ServiceSecurityGroupfromawsecsintegLBSecurityGroupC30F5EB480CD1B9463": { + "Type": "AWS::EC2::SecurityGroupIngress", + "Properties": { + "IpProtocol": "tcp", + "Description": "Load balancer to target", + "FromPort": 80, + "GroupId": { + "Fn::GetAtt": [ + "ServiceSecurityGroupC96ED6A7", + "GroupId" + ] + }, + "SourceSecurityGroupId": { + "Fn::GetAtt": [ + "LBSecurityGroup8A41EA2B", + "GroupId" + ] + }, + "ToPort": 80 + } + }, + "ServiceTaskCountTarget23E25614": { + "Type": "AWS::ApplicationAutoScaling::ScalableTarget", + "Properties": { + "MaxCapacity": 10, + "MinCapacity": 1, + "ResourceId": { + "Fn::Join": [ + "", + [ + "service/", + { + "Ref": "FargateCluster7CCD5F93" + }, + "/", + { + "Fn::GetAtt": [ + "ServiceD69D759B", + "Name" + ] + } + ] + ] + }, + "RoleARN": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService" + ] + ] + }, + "ScalableDimension": "ecs:service:DesiredCount", + "ServiceNamespace": "ecs", + "ScheduledActions": [] + } + }, + "ServiceTaskCountTargetReasonableCpu4174EFCE": { + "Type": "AWS::ApplicationAutoScaling::ScalingPolicy", + "Properties": { + "PolicyName": "awsecsintegServiceTaskCountTargetReasonableCpuDB6AEA73", + "PolicyType": "TargetTrackingScaling", + "ScalingTargetId": { + "Ref": "ServiceTaskCountTarget23E25614" + }, + "TargetTrackingScalingPolicyConfiguration": { + "PredefinedMetricSpecification": { + "PredefinedMetricType": "ECSServiceAverageCPUUtilization" + }, + "TargetValue": 10 + } + } + }, + "LB8A12904C": { + "Type": "AWS::ElasticLoadBalancingV2::LoadBalancer", + "Properties": { + "LoadBalancerAttributes": [], + "Scheme": "internet-facing", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "LBSecurityGroup8A41EA2B", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + } + ], + "Type": "application" + } + }, + "LBSecurityGroup8A41EA2B": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "Automatically created Security Group for ELB awsecsintegLBC73915FE", + "SecurityGroupEgress": [], + "SecurityGroupIngress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow from anyone on port 80", + "FromPort": 80, + "IpProtocol": "tcp", + "ToPort": 80 + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "LBSecurityGrouptoawsecsintegServiceSecurityGroup48EE4368807B287D7F": { + "Type": "AWS::EC2::SecurityGroupEgress", + "Properties": { + "GroupId": { + "Fn::GetAtt": [ + "LBSecurityGroup8A41EA2B", + "GroupId" + ] + }, + "IpProtocol": "tcp", + "Description": "Load balancer to target", + "DestinationSecurityGroupId": { + "Fn::GetAtt": [ + "ServiceSecurityGroupC96ED6A7", + "GroupId" + ] + }, + "FromPort": 80, + "ToPort": 80 + } + }, + "LBPublicListener6E1F3D94": { + "Type": "AWS::ElasticLoadBalancingV2::Listener", + "Properties": { + "DefaultActions": [ + { + "TargetGroupArn": { + "Ref": "LBPublicListenerFargateGroup5EE2FBAF" + }, + "Type": "forward" + } + ], + "LoadBalancerArn": { + "Ref": "LB8A12904C" + }, + "Port": 80, + "Protocol": "HTTP", + "Certificates": [] + } + }, + "LBPublicListenerFargateGroup5EE2FBAF": { + "Type": "AWS::ElasticLoadBalancingV2::TargetGroup", + "Properties": { + "Port": 80, + "Protocol": "HTTP", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "TargetGroupAttributes": [], + "Targets": [], + "TargetType": "ip" + } + } + }, + "Outputs": { + "LoadBalancerDNS": { + "Value": { + "Fn::GetAtt": [ + "LB8A12904C", + "DNSName" + ] + }, + "Export": { + "Name": "aws-ecs-integ:LoadBalancerDNS" + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/integ.lb-awsvpc-nw.ts b/packages/@aws-cdk/aws-ecs/test/fargate/integ.lb-awsvpc-nw.ts new file mode 100644 index 0000000000000..dd0d9343455b7 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/fargate/integ.lb-awsvpc-nw.ts @@ -0,0 +1,45 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import elbv2 = require('@aws-cdk/aws-elasticloadbalancingv2'); +import cdk = require('@aws-cdk/cdk'); +import ecs = require('../../lib'); + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-ecs-integ'); + +const vpc = new ec2.VpcNetwork(stack, 'Vpc', { maxAZs: 2 }); + +const cluster = new ecs.Cluster(stack, 'FargateCluster', { vpc }); + +const taskDefinition = new ecs.FargateTaskDefinition(stack, 'TaskDef', { + memoryMiB: '1GB', + cpu: '512' +}); + +const container = taskDefinition.addContainer('web', { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), +}); + +container.addPortMappings({ + containerPort: 80, + protocol: ecs.Protocol.Tcp +}); + +const service = new ecs.FargateService(stack, "Service", { + cluster, + taskDefinition, +}); + +const scaling = service.autoScaleTaskCount({ maxCapacity: 10 }); +// Quite low to try and force it to scale +scaling.scaleOnCpuUtilization('ReasonableCpu', { targetUtilizationPercent: 10 }); + +const lb = new elbv2.ApplicationLoadBalancer(stack, 'LB', { vpc, internetFacing: true }); +const listener = lb.addListener('PublicListener', { port: 80, open: true }); +listener.addTargets('Fargate', { + port: 80, + targets: [service] +}); + +new cdk.Output(stack, 'LoadBalancerDNS', { value: lb.dnsName, }); + +app.run(); diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts b/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts new file mode 100644 index 0000000000000..42c2a83ac7482 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts @@ -0,0 +1,131 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import ec2 = require('@aws-cdk/aws-ec2'); +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import ecs = require('../../lib'); + +export = { + "When creating a Fargate Service": { + "with only required properties set, it correctly sets default properties"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + }); + + new ecs.FargateService(stack, "FargateService", { + cluster, + taskDefinition + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + TaskDefinition: { + Ref: "FargateTaskDefC6FB60B4" + }, + Cluster: { + Ref: "EcsCluster97242B84" + }, + DeploymentConfiguration: { + MaximumPercent: 200, + MinimumHealthyPercent: 50 + }, + DesiredCount: 1, + LaunchType: "FARGATE", + LoadBalancers: [], + NetworkConfiguration: { + AwsvpcConfiguration: { + AssignPublicIp: "DISABLED", + SecurityGroups: [ + { + "Fn::GetAtt": [ + "FargateServiceSecurityGroup0A0E79CB", + "GroupId" + ] + } + ], + Subnets: [ + { + Ref: "MyVpcPrivateSubnet1Subnet5057CF7E" + }, + { + Ref: "MyVpcPrivateSubnet2Subnet0040C983" + }, + { + Ref: "MyVpcPrivateSubnet3Subnet772D6AD7" + } + ] + } + } + })); + + expect(stack).to(haveResource("AWS::EC2::SecurityGroup", { + GroupDescription: "FargateService/SecurityGroup", + SecurityGroupEgress: [ + { + CidrIp: "0.0.0.0/0", + Description: "Allow all outbound traffic by default", + IpProtocol: "-1" + } + ], + SecurityGroupIngress: [], + VpcId: { + Ref: "MyVpcF9F0CA6F" + } + })); + + test.done(); + }, + + "errors when no container specified on task definition"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + + // THEN + test.throws(() => { + new ecs.FargateService(stack, "FargateService", { + cluster, + taskDefinition, + }); + }); + + test.done(); + }, + + "allows specifying assignPublicIP as enabled"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + + taskDefinition.addContainer("web", { + image: ecs.DockerHub.image("amazon/amazon-ecs-sample"), + }); + + new ecs.FargateService(stack, "FargateService", { + cluster, + taskDefinition, + assignPublicIp: true + }); + + // THEN + expect(stack).to(haveResource("AWS::ECS::Service", { + NetworkConfiguration: { + AwsvpcConfiguration: { + AssignPublicIp: "ENABLED", + } + } + })); + + test.done(); + }, + } +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-task-definition.ts b/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-task-definition.ts new file mode 100644 index 0000000000000..3c89f52d27c5d --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-task-definition.ts @@ -0,0 +1,28 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import ecs = require('../../lib'); + +export = { + "When creating an Fargate TaskDefinition": { + "with only required properties set, it correctly sets default properties"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + + // THEN + expect(stack).to(haveResource("AWS::ECS::TaskDefinition", { + Family: "FargateTaskDef", + ContainerDefinitions: [], + Volumes: [], + NetworkMode: ecs.NetworkMode.AwsVpc, + RequiresCompatibilities: ["FARGATE"], + Cpu: "256", + Memory: "512", + })); + + // test error if no container defs? + test.done(); + }, + } +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/test.asset-image.ts b/packages/@aws-cdk/aws-ecs/test/test.asset-image.ts new file mode 100644 index 0000000000000..7e386ddcae252 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/test.asset-image.ts @@ -0,0 +1,148 @@ +import { expect, MatchStyle } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import path = require('path'); +import proxyquire = require('proxyquire'); +import ecs = require('../lib'); + +export = { + 'test instantiating Asset Image'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new ecs.AssetImage(stack, 'Image', { + directory: path.join(__dirname, 'demo-image'), + }); + + // THEN + expect(stack).toMatch({ + ImageRepositoryC2BE7AD4: { + Type: "String", + Description: "Repository ARN for asset \"Image\"" + }, + ImageTagE17D8A6B: { + Type: "String", + Description: "Tag for asset \"Image\"" + }, + }, MatchStyle.SUPERSET); + + test.done(); + }, + + async 'exercise handler create'(test: Test) { + const handler = proxyquire(path.resolve(__dirname, '..', 'lib', 'images', 'adopt-repository', 'handler'), { + 'aws-sdk': { + '@noCallThru': true, + "ECR": ECRWithEmptyPolicy, + } + }); + + let output; + async function response(responseStatus: string, reason: string, physId: string, data: any) { + output = { responseStatus, reason, physId, data }; + } + + await handler.handler({ + StackId: 'StackId', + ResourceProperties: { + RepositoryArn: 'RepositoryArn', + }, + RequestType: 'Create', + ResponseURL: 'https://localhost/test' + }, { + logStreamName: 'xyz', + }, undefined, response); + + test.deepEqual(output, { + responseStatus: 'SUCCESS', + reason: 'OK', + physId: '', + data: { RepositoryUri: 'undefined.dkr.ecr.undefined.amazonaws.com/' } + }); + + test.done(); + }, + + async 'exercise handler delete'(test: Test) { + const handler = proxyquire(path.resolve(__dirname, '..', 'lib', 'images', 'adopt-repository', 'handler'), { + 'aws-sdk': { '@noCallThru': true, "ECR": ECRWithOwningPolicy } + }); + + let output; + async function response(responseStatus: string, reason: string, physId: string, data: any) { + output = { responseStatus, reason, physId, data }; + } + + await handler.handler({ + StackId: 'StackId', + ResourceProperties: { + RepositoryArn: 'RepositoryArn', + }, + RequestType: 'Delete', + ResponseURL: 'https://localhost/test' + }, { + logStreamName: 'xyz', + }, undefined, response); + + test.deepEqual(output, { + responseStatus: 'SUCCESS', + reason: 'OK', + physId: '', + data: { RepositoryUri: 'undefined.dkr.ecr.undefined.amazonaws.com/' } + }); + + test.done(); + }, +}; + +function ECRWithEmptyPolicy() { + return new ECR({ asdf: 'asdf' }); +} + +function ECRWithOwningPolicy() { + return new ECR({ + Statement: [ + { + Sid: 'StackId', + Effect: "Deny", + Action: "OwnedBy:CDKStack", + Principal: "*" + } + ] + }); +} + +class ECR { + public constructor(private policy: any) { + } + + public getRepositoryPolicy() { + const self = this; + return { async promise() { return { + policyText: JSON.stringify(self.policy) + }; } }; + } + + public setRepositoryPolicy() { + return { async promise() { return; } }; + } + + public listImages() { + return { async promise() { + return { imageIds: [] }; + } }; + } + + public batchDeleteImage() { + return { async promise() { + return {}; + } }; + } + + public deleteRepository() { + return { async promise() { + return {}; + } }; + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/test.container-definition.ts b/packages/@aws-cdk/aws-ecs/test/test.container-definition.ts new file mode 100644 index 0000000000000..63f7197d4edc3 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/test.container-definition.ts @@ -0,0 +1,355 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import ecs = require('../lib'); + +export = { + "When creating a Task Definition": { + // Validating portMapping inputs + "With network mode AwsVpc": { + "Host port should be the same as container port"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: ecs.NetworkMode.AwsVpc, + }); + + const container = taskDefinition.addContainer("Container", { + image: ecs.DockerHub.image("/aws/aws-example-app"), + memoryLimitMiB: 2048, + }); + + // THEN + test.throws(() => { + container.addPortMappings({ + containerPort: 8080, + hostPort: 8081 + }); + }); + + test.done(); + }, + + "Host port can be empty "(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: ecs.NetworkMode.AwsVpc, + }); + + const container = taskDefinition.addContainer("Container", { + image: ecs.DockerHub.image("/aws/aws-example-app"), + memoryLimitMiB: 2048, + }); + + // WHEN + container.addPortMappings({ + containerPort: 8080, + }); + + // THEN no exception raised + test.done(); + }, + }, + + "With network mode Host ": { + "Host port should be the same as container port"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: ecs.NetworkMode.Host, + }); + + const container = taskDefinition.addContainer("Container", { + image: ecs.DockerHub.image("/aws/aws-example-app"), + memoryLimitMiB: 2048, + }); + + // THEN + test.throws(() => { + container.addPortMappings({ + containerPort: 8080, + hostPort: 8081 + }); + }); + + test.done(); + }, + + "Host port can be empty "(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: ecs.NetworkMode.Host, + }); + + const container = taskDefinition.addContainer("Container", { + image: ecs.DockerHub.image("/aws/aws-example-app"), + memoryLimitMiB: 2048, + }); + + // WHEN + container.addPortMappings({ + containerPort: 8080, + }); + + // THEN no exception raised + test.done(); + }, + + "errors when adding links"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: ecs.NetworkMode.Host, + }); + + const container = taskDefinition.addContainer("Container", { + image: ecs.DockerHub.image("/aws/aws-example-app"), + memoryLimitMiB: 2048, + }); + + const logger = taskDefinition.addContainer("LoggingContainer", { + image: ecs.DockerHub.image("myLogger"), + memoryLimitMiB: 1024, + }); + + // THEN + test.throws(() => { + container.addLink(logger); + }); + + test.done(); + }, + }, + + "With network mode Bridge": { + "allows adding links"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: ecs.NetworkMode.Bridge, + }); + + const container = taskDefinition.addContainer("Container", { + image: ecs.DockerHub.image("/aws/aws-example-app"), + memoryLimitMiB: 2048, + }); + + const logger = taskDefinition.addContainer("LoggingContainer", { + image: ecs.DockerHub.image("myLogger"), + memoryLimitMiB: 1024, + }); + + // THEN + container.addLink(logger); + + test.done(); + }, + } + + }, + "Ingress Port": { + "With network mode AwsVpc": { + "Ingress port should be the same as container port"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: ecs.NetworkMode.AwsVpc, + }); + + const container = taskDefinition.addContainer("Container", { + image: ecs.DockerHub.image("/aws/aws-example-app"), + memoryLimitMiB: 2048, + }); + + // WHEN + container.addPortMappings({ + containerPort: 8080, + }); + const actual = container.ingressPort; + + // THEN + const expected = 8080; + test.equal(actual, expected, "Ingress port should be the same as container port"); + test.done(); + }, + }, + "With network mode Host ": { + "Ingress port should be the same as container port"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: ecs.NetworkMode.Host, + }); + + const container = taskDefinition.addContainer("Container", { + image: ecs.DockerHub.image("/aws/aws-example-app"), + memoryLimitMiB: 2048, + }); + + // WHEN + container.addPortMappings({ + containerPort: 8080, + }); + const actual = container.ingressPort; + + // THEN + const expected = 8080; + test.equal(actual, expected); + test.done(); + }, + }, + + "With network mode Bridge": { + "Ingress port should be the same as host port if supplied"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: ecs.NetworkMode.Bridge, + }); + + const container = taskDefinition.addContainer("Container", { + image: ecs.DockerHub.image("/aws/aws-example-app"), + memoryLimitMiB: 2048, + }); + + // WHEN + container.addPortMappings({ + containerPort: 8080, + hostPort: 8081, + }); + const actual = container.ingressPort; + + // THEN + const expected = 8081; + test.equal(actual, expected); + test.done(); + }, + + "Ingress port should be 0 if not supplied"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef', { + networkMode: ecs.NetworkMode.Bridge, + }); + + const container = taskDefinition.addContainer("Container", { + image: ecs.DockerHub.image("/aws/aws-example-app"), + memoryLimitMiB: 2048, + }); + + // WHEN + container.addPortMappings({ + containerPort: 8081, + }); + const actual = container.ingressPort; + + // THEN + const expected = 0; + test.equal(actual, expected); + test.done(); + }, + }, + }, + + 'can add AWS logging to container definition'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef'); + + // WHEN + taskDefinition.addContainer('cont', { + image: ecs.DockerHub.image('test'), + memoryLimitMiB: 1024, + logging: new ecs.AwsLogDriver(stack, 'Logging', { streamPrefix: 'prefix' }) + }); + + // THEN + expect(stack).to(haveResource('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [ + { + LogConfiguration: { + LogDriver: "awslogs", + Options: { + "awslogs-group": { Ref: "LoggingLogGroupC6B8E20B" }, + "awslogs-stream-prefix": "prefix", + "awslogs-region": { Ref: "AWS::Region" } + } + }, + } + ] + })); + + test.done(); + }, + 'can set Health Check with defaults'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef'); + const hcCommand = "curl localhost:8000"; + + // WHEN + taskDefinition.addContainer('cont', { + image: ecs.DockerHub.image('test'), + memoryLimitMiB: 1024, + healthCheck: { + command: [hcCommand] + } + }); + + // THEN + expect(stack).to(haveResource('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [ + { + HealthCheck: { + Command: ["CMD-SHELL", hcCommand], + Interval: 30, + Retries: 3, + Timeout: 5 + }, + } + ] + })); + + test.done(); + }, + + 'can specify Health Check values'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef'); + const hcCommand = "curl localhost:8000"; + + // WHEN + taskDefinition.addContainer('cont', { + image: ecs.DockerHub.image('test'), + memoryLimitMiB: 1024, + healthCheck: { + command: [hcCommand], + intervalSeconds: 20, + retries: 5, + startPeriod: 10 + } + }); + + // THEN + expect(stack).to(haveResource('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [ + { + HealthCheck: { + Command: ["CMD-SHELL", hcCommand], + Interval: 20, + Retries: 5, + Timeout: 5, + StartPeriod: 10 + }, + } + ] + })); + + test.done(); + }, + + // render extra hosts test +}; \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts new file mode 100644 index 0000000000000..be055197775cc --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts @@ -0,0 +1,195 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import ec2 = require('@aws-cdk/aws-ec2'); +import { InstanceType } from '@aws-cdk/aws-ec2'; +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import ecs = require('../lib'); + +export = { + "When creating an ECS Cluster": { + "with only required properties set, it correctly sets default properties"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { + vpc, + }); + + cluster.addDefaultAutoScalingGroupCapacity({ + instanceType: new ec2.InstanceType('t2.micro') + }); + + expect(stack).to(haveResource("AWS::ECS::Cluster")); + + expect(stack).to(haveResource("AWS::EC2::VPC", { + CidrBlock: '10.0.0.0/16', + EnableDnsHostnames: true, + EnableDnsSupport: true, + InstanceTenancy: ec2.DefaultInstanceTenancy.Default, + Tags: [ + { + Key: "Name", + Value: "MyVpc" + } + ] + })); + + expect(stack).to(haveResource("AWS::AutoScaling::LaunchConfiguration", { + ImageId: "", // Should this not be the latest image ID? + InstanceType: "t2.micro", + IamInstanceProfile: { + Ref: "EcsClusterDefaultAutoScalingGroupInstanceProfile2CE606B3" + }, + SecurityGroups: [ + { + "Fn::GetAtt": [ + "EcsClusterDefaultAutoScalingGroupInstanceSecurityGroup912E1231", + "GroupId" + ] + } + ], + UserData: { + "Fn::Base64": { + "Fn::Join": [ + "", + [ + "#!/bin/bash\necho ECS_CLUSTER=", + { + Ref: "EcsCluster97242B84" + }, + // tslint:disable-next-line:max-line-length + " >> /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" + ] + ] + } + } + })); + + expect(stack).to(haveResource("AWS::AutoScaling::AutoScalingGroup", { + MaxSize: "1", + MinSize: "0", + DesiredCapacity: "1", + LaunchConfigurationName: { + Ref: "EcsClusterDefaultAutoScalingGroupLaunchConfigB7E376C1" + }, + Tags: [ + { + Key: "Name", + PropagateAtLaunch: true, + Value: "EcsCluster/DefaultAutoScalingGroup" + } + ], + VPCZoneIdentifier: [ + { + Ref: "MyVpcPrivateSubnet1Subnet5057CF7E" + }, + { + Ref: "MyVpcPrivateSubnet2Subnet0040C983" + }, + { + Ref: "MyVpcPrivateSubnet3Subnet772D6AD7" + } + ] + })); + + expect(stack).to(haveResource("AWS::EC2::SecurityGroup", { + GroupDescription: "EcsCluster/DefaultAutoScalingGroup/InstanceSecurityGroup", + SecurityGroupEgress: [ + { + CidrIp: "0.0.0.0/0", + Description: "Allow all outbound traffic by default", + IpProtocol: "-1" + } + ], + SecurityGroupIngress: [], + Tags: [ + { + Key: "Name", + Value: "EcsCluster/DefaultAutoScalingGroup" + } + ], + VpcId: { + Ref: "MyVpcF9F0CA6F" + } + })); + + expect(stack).to(haveResource("AWS::IAM::Role", { + AssumeRolePolicyDocument: { + Statement: [ + { + Action: "sts:AssumeRole", + Effect: "Allow", + Principal: { + Service: "ec2.amazonaws.com" + } + } + ], + Version: "2012-10-17" + } + })); + + expect(stack).to(haveResource("AWS::IAM::Policy", { + PolicyDocument: { + Statement: [ + { + Action: [ + "ecs:CreateCluster", + "ecs:DeregisterContainerInstance", + "ecs:DiscoverPollEndpoint", + "ecs:Poll", + "ecs:RegisterContainerInstance", + "ecs:StartTelemetrySession", + "ecs:Submit*", + "ecr:GetAuthorizationToken", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + Effect: "Allow", + Resource: "*" + } + ], + Version: "2012-10-17" + } + })); + + test.done(); + }, + }, + + "allows specifying instance type"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ + instanceType: new InstanceType("m3.large") + }); + + // THEN + expect(stack).to(haveResource("AWS::AutoScaling::LaunchConfiguration", { + InstanceType: "m3.large" + })); + + test.done(); + }, + + "allows specifying cluster size"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'MyVpc', {}); + + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ + instanceType: new ec2.InstanceType('t2.micro'), + instanceCount: 3 + }); + + // THEN + expect(stack).to(haveResource("AWS::AutoScaling::AutoScalingGroup", { + MaxSize: "3" + })); + + test.done(); + }, +}; diff --git a/packages/@aws-cdk/aws-ecs/test/test.ecs.ts b/packages/@aws-cdk/aws-ecs/test/test.ecs.ts deleted file mode 100644 index 820f6b467f38f..0000000000000 --- a/packages/@aws-cdk/aws-ecs/test/test.ecs.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Test, testCase } from 'nodeunit'; - -export = testCase({ - notTested(test: Test) { - test.ok(true, 'No tests are specified for this package.'); - test.done(); - } -}); diff --git a/packages/@aws-cdk/aws-ecs/test/test.l3s.ts b/packages/@aws-cdk/aws-ecs/test/test.l3s.ts new file mode 100644 index 0000000000000..900116a34fdb9 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/test.l3s.ts @@ -0,0 +1,45 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import ec2 = require('@aws-cdk/aws-ec2'); +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import ecs = require('../lib'); + +export = { + 'test ECS loadbalanced construct'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'VPC'); + const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + cluster.addDefaultAutoScalingGroupCapacity({ instanceType: new ec2.InstanceType('t2.micro') }); + + // WHEN + new ecs.LoadBalancedEc2Service(stack, 'Service', { + cluster, + memoryLimitMiB: 1024, + image: ecs.DockerHub.image('test') + }); + + // THEN - stack containers a load balancer + expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::LoadBalancer')); + + test.done(); + }, + + 'test Fargateloadbalanced construct'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'VPC'); + const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + + // WHEN + new ecs.LoadBalancedFargateService(stack, 'Service', { + cluster, + image: ecs.DockerHub.image('test') + }); + + // THEN - stack containers a load balancer + expect(stack).to(haveResource('AWS::ElasticLoadBalancingV2::LoadBalancer')); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-ecs/test/test.task-definition.ts b/packages/@aws-cdk/aws-ecs/test/test.task-definition.ts new file mode 100644 index 0000000000000..8bf044dd09ae4 --- /dev/null +++ b/packages/@aws-cdk/aws-ecs/test/test.task-definition.ts @@ -0,0 +1,25 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import ecs = require('../lib'); + +export = { + "A task definition with both compatibilities defaults to networkmode AwsVpc"(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new ecs.TaskDefinition(stack, 'TD', { + cpu: '512', + memoryMiB: '512', + compatibility: ecs.Compatibility.Ec2AndFargate, + }); + + // THEN + expect(stack).to(haveResource('AWS::ECS::TaskDefinition', { + NetworkMode: "awsvpc", + })); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-elasticloadbalancing/lib/load-balancer.ts b/packages/@aws-cdk/aws-elasticloadbalancing/lib/load-balancer.ts index 7df533982bab5..3fc0f35b46c70 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancing/lib/load-balancer.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancing/lib/load-balancer.ts @@ -208,7 +208,7 @@ export class LoadBalancer extends cdk.Construct implements IConnectable, codedep super(parent, name); this.securityGroup = new SecurityGroup(this, 'SecurityGroup', { vpc: props.vpc, allowAllOutbound: false }); - this.connections = new Connections({ securityGroup: this.securityGroup }); + this.connections = new Connections({ securityGroups: [this.securityGroup] }); // Depending on whether the ELB has public or internal IPs, pick the right backend subnets const subnets: VpcSubnetRef[] = props.internetFacing ? props.vpc.publicSubnets : props.vpc.privateSubnets; @@ -342,7 +342,7 @@ export class ListenerPort implements IConnectable { public readonly connections: Connections; constructor(securityGroup: SecurityGroupRef, defaultPortRange: IPortRange) { - this.connections = new Connections({ securityGroup, defaultPortRange }); + this.connections = new Connections({ securityGroups: [securityGroup] , defaultPortRange }); } } diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts index 217db667db327..114f499de8ef7 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-listener.ts @@ -128,7 +128,7 @@ export class ApplicationListener extends BaseListener implements IApplicationLis // This listener edits the securitygroup of the load balancer, // but adds its own default port. this.connections = new ec2.Connections({ - securityGroup: props.loadBalancer.connections.securityGroup, + securityGroups: props.loadBalancer.connections.securityGroups, defaultPortRange: new ec2.TcpPort(port), }); @@ -241,7 +241,7 @@ export class ApplicationListener extends BaseListener implements IApplicationLis public export(): ApplicationListenerRefProps { return { listenerArn: new cdk.Output(this, 'ListenerArn', { value: this.listenerArn }).makeImportValue().toString(), - securityGroupId: this.connections.securityGroup!.export().securityGroupId, + securityGroupId: this.connections.securityGroups[0]!.export().securityGroupId, defaultPort: new cdk.Output(this, 'Port', { value: this.defaultPort }).makeImportValue().toString(), }; } @@ -335,7 +335,7 @@ class ImportedApplicationListener extends cdk.Construct implements IApplicationL const defaultPortRange = props.defaultPort !== undefined ? new ec2.TcpPortFromAttribute(props.defaultPort) : undefined; this.connections = new ec2.Connections({ - securityGroup: ec2.SecurityGroupRef.import(this, 'SecurityGroup', { securityGroupId: props.securityGroupId }), + securityGroups: [ec2.SecurityGroupRef.import(this, 'SecurityGroup', { securityGroupId: props.securityGroupId })], defaultPortRange, }); } diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts index bb4e15ef8ce22..357551789ff7d 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-load-balancer.ts @@ -67,7 +67,7 @@ export class ApplicationLoadBalancer extends BaseLoadBalancer implements IApplic description: `Automatically created Security Group for ELB ${this.uniqueId}`, allowAllOutbound: false }); - this.connections = new ec2.Connections({ securityGroup: this.securityGroup }); + this.connections = new ec2.Connections({ securityGroups: [this.securityGroup] }); if (props.http2Enabled === false) { this.setAttribute('routing.http2.enabled', 'false'); } if (props.idleTimeoutSecs !== undefined) { this.setAttribute('idle_timeout.timeout_seconds', props.idleTimeoutSecs.toString()); } @@ -201,7 +201,7 @@ class ImportedApplicationLoadBalancer extends cdk.Construct implements IApplicat this.loadBalancerArn = props.loadBalancerArn; this.connections = new ec2.Connections({ - securityGroup: ec2.SecurityGroupRef.import(this, 'SecurityGroup', { securityGroupId: props.securityGroupId }) + securityGroups: [ec2.SecurityGroupRef.import(this, 'SecurityGroup', { securityGroupId: props.securityGroupId })] }); } diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/helpers.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/helpers.ts index 77fec08c04824..2b129f97c67e7 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/helpers.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/helpers.ts @@ -11,7 +11,7 @@ export class FakeSelfRegisteringTarget extends cdk.Construct implements elbv2.IA super(parent, id); this.securityGroup = new ec2.SecurityGroup(this, 'SG', { vpc }); this.connections = new ec2.Connections({ - securityGroup: this.securityGroup + securityGroups: [this.securityGroup] }); } diff --git a/packages/@aws-cdk/aws-lambda/lib/lambda-ref.ts b/packages/@aws-cdk/aws-lambda/lib/lambda-ref.ts index 8b81a01d9cc32..f915ad9922c81 100644 --- a/packages/@aws-cdk/aws-lambda/lib/lambda-ref.ts +++ b/packages/@aws-cdk/aws-lambda/lib/lambda-ref.ts @@ -334,8 +334,8 @@ export abstract class FunctionRef extends cdk.Construct public export(): FunctionRefProps { return { functionArn: new cdk.Output(this, 'FunctionArn', { value: this.functionArn }).makeImportValue().toString(), - securityGroupId: this._connections && this._connections.securityGroup - ? new cdk.Output(this, 'SecurityGroupId', { value: this._connections.securityGroup.securityGroupId }).makeImportValue().toString() + securityGroupId: this._connections && this._connections.securityGroups[0] + ? new cdk.Output(this, 'SecurityGroupId', { value: this._connections.securityGroups[0].securityGroupId }).makeImportValue().toString() : undefined }; } @@ -427,9 +427,9 @@ class LambdaRefImport extends FunctionRef { if (props.securityGroupId) { this._connections = new ec2.Connections({ - securityGroup: ec2.SecurityGroupRef.import(this, 'SecurityGroup', { + securityGroups: [ec2.SecurityGroupRef.import(this, 'SecurityGroup', { securityGroupId: props.securityGroupId - }) + })] }); } } @@ -449,6 +449,5 @@ class LambdaRefImport extends FunctionRef { */ private extractNameFromArn(arn: string) { return new cdk.FnSelect(6, new cdk.FnSplit(':', arn)).toString(); - } } diff --git a/packages/@aws-cdk/aws-lambda/lib/lambda.ts b/packages/@aws-cdk/aws-lambda/lib/lambda.ts index ada7082d963bd..e7760707f5369 100644 --- a/packages/@aws-cdk/aws-lambda/lib/lambda.ts +++ b/packages/@aws-cdk/aws-lambda/lib/lambda.ts @@ -338,7 +338,7 @@ export class Function extends FunctionRef { allowAllOutbound: props.allowAllOutbound }); - this._connections = new ec2.Connections({ securityGroup }); + this._connections = new ec2.Connections({ securityGroups: [securityGroup] }); // Pick subnets, make sure they're not Public. Routing through an IGW // won't work because the ENIs don't get a Public IP. diff --git a/packages/@aws-cdk/aws-lambda/test/test.vpc-lambda.ts b/packages/@aws-cdk/aws-lambda/test/test.vpc-lambda.ts index 2af214ae84f0d..cebff1fcf4272 100644 --- a/packages/@aws-cdk/aws-lambda/test/test.vpc-lambda.ts +++ b/packages/@aws-cdk/aws-lambda/test/test.vpc-lambda.ts @@ -46,7 +46,7 @@ export = { public 'participates in Connections objects'(test: Test) { // GIVEN const securityGroup = new ec2.SecurityGroup(this.stack, 'SomeSecurityGroup', { vpc: this.vpc }); - const somethingConnectable = new SomethingConnectable(new ec2.Connections({ securityGroup })); + const somethingConnectable = new SomethingConnectable(new ec2.Connections({ securityGroups: [securityGroup] })); // WHEN this.lambda.connections.allowTo(somethingConnectable, new ec2.TcpAllPorts(), 'Lambda can call connectable'); @@ -78,7 +78,7 @@ export = { // GIVEN const stack2 = new cdk.Stack(); const securityGroup = new ec2.SecurityGroup(stack2, 'SomeSecurityGroup', { vpc: this.vpc }); - const somethingConnectable = new SomethingConnectable(new ec2.Connections({ securityGroup })); + const somethingConnectable = new SomethingConnectable(new ec2.Connections({ securityGroups: [securityGroup] })); // WHEN const importedLambda = lambda.FunctionRef.import(stack2, 'Lambda', this.lambda.export()); diff --git a/packages/@aws-cdk/aws-quickstarts/lib/database.ts b/packages/@aws-cdk/aws-quickstarts/lib/database.ts index 3426a68952b3f..69d582b48afda 100644 --- a/packages/@aws-cdk/aws-quickstarts/lib/database.ts +++ b/packages/@aws-cdk/aws-quickstarts/lib/database.ts @@ -49,6 +49,6 @@ export class SqlServer extends cdk.Construct implements ec2.IConnectable { }); const defaultPortRange = new ec2.TcpPort(SqlServer.PORT); - this.connections = new ec2.Connections({ securityGroup, defaultPortRange }); + this.connections = new ec2.Connections({ securityGroups: [securityGroup], defaultPortRange }); } } diff --git a/packages/@aws-cdk/aws-quickstarts/lib/rdgw.ts b/packages/@aws-cdk/aws-quickstarts/lib/rdgw.ts index 156ddb645dc17..54b9bc688e9d2 100644 --- a/packages/@aws-cdk/aws-quickstarts/lib/rdgw.ts +++ b/packages/@aws-cdk/aws-quickstarts/lib/rdgw.ts @@ -52,6 +52,6 @@ export class RemoteDesktopGateway extends cdk.Construct implements ec2.IConnecta }); const defaultPortRange = new ec2.TcpPort(RemoteDesktopGateway.PORT); - this.connections = new ec2.Connections({ securityGroup, defaultPortRange }); + this.connections = new ec2.Connections({ securityGroups: [securityGroup], defaultPortRange }); } } diff --git a/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts b/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts index 427591f54d38e..5f7d6409a87ee 100644 --- a/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts +++ b/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts @@ -156,7 +156,7 @@ class ImportedDatabaseCluster extends DatabaseClusterRef { this.securityGroupId = props.securityGroupId; this.defaultPortRange = new ec2.TcpPortFromAttribute(props.port); this.connections = new ec2.Connections({ - securityGroup: ec2.SecurityGroupRef.import(this, 'SecurityGroup', props), + securityGroups: [ec2.SecurityGroupRef.import(this, 'SecurityGroup', props)], defaultPortRange: this.defaultPortRange }); this.clusterIdentifier = props.clusterIdentifier; diff --git a/packages/@aws-cdk/aws-rds/lib/cluster.ts b/packages/@aws-cdk/aws-rds/lib/cluster.ts index 888900a903302..7b34d104c4a20 100644 --- a/packages/@aws-cdk/aws-rds/lib/cluster.ts +++ b/packages/@aws-cdk/aws-rds/lib/cluster.ts @@ -213,7 +213,7 @@ export class DatabaseCluster extends DatabaseClusterRef { } const defaultPortRange = new ec2.TcpPortFromAttribute(this.clusterEndpoint.port); - this.connections = new ec2.Connections({ securityGroup, defaultPortRange }); + this.connections = new ec2.Connections({ securityGroups: [securityGroup], defaultPortRange }); } } diff --git a/packages/@aws-cdk/cdk/lib/context.ts b/packages/@aws-cdk/cdk/lib/context.ts index 1492113bc1180..49fef72abbd72 100644 --- a/packages/@aws-cdk/cdk/lib/context.ts +++ b/packages/@aws-cdk/cdk/lib/context.ts @@ -167,8 +167,8 @@ export class SSMParameterProvider { /** * Return the SSM parameter string with the indicated key */ - public parameterValue(): any { - return this.provider.getStringValue('dummy'); + public parameterValue(defaultValue = 'dummy'): any { + return this.provider.getStringValue(defaultValue); } } diff --git a/packages/@aws-cdk/cx-api/lib/cxapi.ts b/packages/@aws-cdk/cx-api/lib/cxapi.ts index f0ef41a59256b..20da4145f9172 100644 --- a/packages/@aws-cdk/cx-api/lib/cxapi.ts +++ b/packages/@aws-cdk/cx-api/lib/cxapi.ts @@ -104,7 +104,13 @@ export const DEFAULT_ACCOUNT_CONTEXT_KEY = 'aws:cdk:toolkit:default-account'; export const DEFAULT_REGION_CONTEXT_KEY = 'aws:cdk:toolkit:default-region'; export const ASSET_METADATA = 'aws:cdk:asset'; -export interface AssetMetadataEntry { + +export interface FileAssetMetadataEntry { + /** + * Requested packaging style + */ + packaging: 'zip' | 'file'; + /** * Path on disk to the asset */ @@ -115,11 +121,6 @@ export interface AssetMetadataEntry { */ id: string; - /** - * Requested packaging style - */ - packaging: 'zip' | 'file'; - /** * Name of parameter where S3 bucket should be passed in */ @@ -131,6 +132,35 @@ export interface AssetMetadataEntry { s3KeyParameter: string; } +export interface ContainerImageAssetMetadataEntry { + /** + * Type of asset + */ + packaging: 'container-image'; + + /** + * Path on disk to the asset + */ + path: string; + + /** + * Logical identifier for the asset + */ + id: string; + + /** + * Name of the parameter that takes the repository name + */ + repositoryParameter: string; + + /** + * Name of the parameter that takes the tag + */ + tagParameter: string; +} + +export type AssetMetadataEntry = FileAssetMetadataEntry | ContainerImageAssetMetadataEntry; + /** * Metadata key used to print INFO-level messages by the toolkit when an app is syntheized. */ diff --git a/packages/aws-cdk/lib/api/toolkit-info.ts b/packages/aws-cdk/lib/api/toolkit-info.ts index 130268989dcfe..762fd1b1ff07a 100644 --- a/packages/aws-cdk/lib/api/toolkit-info.ts +++ b/packages/aws-cdk/lib/api/toolkit-info.ts @@ -21,6 +21,8 @@ export interface Uploaded { } export class ToolkitInfo { + public readonly sdk: SDK; + /** * A cache of previous uploads done in this session */ @@ -31,7 +33,9 @@ export class ToolkitInfo { bucketName: string, bucketEndpoint: string, environment: cxapi.Environment - }) { } + }) { + this.sdk = props.sdk; + } public get bucketUrl() { return `https://${this.props.bucketEndpoint}`; @@ -92,6 +96,85 @@ export class ToolkitInfo { return uploaded; } + /** + * Prepare an ECR repository for uploading to using Docker + */ + public async prepareEcrRepository(id: string, imageTag: string): Promise { + const ecr = await this.props.sdk.ecr(this.props.environment, Mode.ForWriting); + + // Create the repository if it doesn't exist yet + const repositoryName = 'cdk/' + id.replace(/[:/]/g, '-').toLowerCase(); + + let repository; + try { + debug(`${repositoryName}: checking for repository.`); + const describeResponse = await ecr.describeRepositories({ repositoryNames: [repositoryName] }).promise(); + repository = describeResponse.repositories![0]; + } catch (e) { + if (e.code !== 'RepositoryNotFoundException') { throw e; } + } + + if (repository) { + try { + debug(`${repositoryName}: checking for image ${imageTag}`); + await ecr.describeImages({ repositoryName, imageIds: [{ imageTag }] }).promise(); + + // If we got here, the image already exists. Nothing else needs to be done. + return { + alreadyExists: true, + repositoryUri: repository.repositoryUri!, + repositoryArn: repository.repositoryArn!, + }; + } catch (e) { + if (e.code !== 'ImageNotFoundException') { throw e; } + } + } else { + debug(`${repositoryName}: creating`); + const response = await ecr.createRepository({ repositoryName }).promise(); + repository = response.repository!; + + // Better put a lifecycle policy on this so as to not cost too much money + await ecr.putLifecyclePolicy({ + repositoryName, + lifecyclePolicyText: JSON.stringify(DEFAULT_REPO_LIFECYCLE) + }).promise(); + } + + // The repo exists, image just needs to be uploaded. Get auth to do so. + debug(`Fetching ECR authorization token`); + const authData = (await ecr.getAuthorizationToken({ }).promise()).authorizationData || []; + if (authData.length === 0) { + throw new Error('No authorization data received from ECR'); + } + const token = Buffer.from(authData[0].authorizationToken!, 'base64').toString('ascii'); + const [username, password] = token.split(':'); + + return { + alreadyExists: false, + repositoryUri: repository.repositoryUri!, + repositoryArn: repository.repositoryArn!, + username, + password, + endpoint: authData[0].proxyEndpoint!, + }; + } +} + +export type EcrRepositoryInfo = CompleteEcrRepositoryInfo | UploadableEcrRepositoryInfo; + +export interface CompleteEcrRepositoryInfo { + repositoryUri: string; + repositoryArn: string; + alreadyExists: true; +} + +export interface UploadableEcrRepositoryInfo { + repositoryUri: string; + repositoryArn: string; + alreadyExists: false; + username: string; + password: string; + endpoint: string; } async function objectExists(s3: aws.S3, bucket: string, key: string) { @@ -133,3 +216,18 @@ function getOutputValue(stack: aws.CloudFormation.Stack, output: string): string } return result; } + +const DEFAULT_REPO_LIFECYCLE = { + rules: [ + { + rulePriority: 100, + description: 'Retain only 5 images', + selection: { + tagStatus: 'any', + countType: 'imageCountMoreThan', + countNumber: 5, + }, + action: { type: 'expire' } + } + ] +}; diff --git a/packages/aws-cdk/lib/api/util/sdk.ts b/packages/aws-cdk/lib/api/util/sdk.ts index ee91083528a01..dc4af454df42d 100644 --- a/packages/aws-cdk/lib/api/util/sdk.ts +++ b/packages/aws-cdk/lib/api/util/sdk.ts @@ -103,12 +103,21 @@ export class SDK { credentials: await this.credentialsCache.get(environment.account, mode) }); } + public async route53(awsAccountId: string | undefined, region: string | undefined, mode: Mode): Promise { return new AWS.Route53({ region, credentials: await this.credentialsCache.get(awsAccountId, mode), }); } + + public async ecr(environment: Environment, mode: Mode): Promise { + return new AWS.ECR({ + region: environment.region, + credentials: await this.credentialsCache.get(environment.account, mode) + }); + } + public async defaultRegion(): Promise { return await getCLICompatibleDefaultRegion(this.profile); } diff --git a/packages/aws-cdk/lib/assets.ts b/packages/aws-cdk/lib/assets.ts index 1610421a7ab36..6bc5bc5eebfd1 100644 --- a/packages/aws-cdk/lib/assets.ts +++ b/packages/aws-cdk/lib/assets.ts @@ -1,4 +1,5 @@ -import { ASSET_METADATA, ASSET_PREFIX_SEPARATOR, AssetMetadataEntry, StackMetadata, SynthesizedStack } from '@aws-cdk/cx-api'; +// tslint:disable-next-line:max-line-length +import { ASSET_METADATA, ASSET_PREFIX_SEPARATOR, AssetMetadataEntry, FileAssetMetadataEntry, StackMetadata, SynthesizedStack } from '@aws-cdk/cx-api'; import { CloudFormation } from 'aws-sdk'; import colors = require('colors'); import fs = require('fs-extra'); @@ -6,6 +7,7 @@ import os = require('os'); import path = require('path'); import { ToolkitInfo } from './api/toolkit-info'; import { zipDirectory } from './archive'; +import { prepareContainerAsset } from './docker'; import { debug, success } from './logging'; export async function prepareAssets(stack: SynthesizedStack, toolkitInfo?: ToolkitInfo): Promise { @@ -36,12 +38,15 @@ async function prepareAsset(asset: AssetMetadataEntry, toolkitInfo: ToolkitInfo) return await prepareZipAsset(asset, toolkitInfo); case 'file': return await prepareFileAsset(asset, toolkitInfo); + case 'container-image': + return await prepareContainerAsset(asset, toolkitInfo); default: - throw new Error(`Unsupported packaging type: ${asset.packaging}`); + // tslint:disable-next-line:max-line-length + throw new Error(`Unsupported packaging type: ${(asset as any).packaging}. You might need to upgrade your aws-cdk toolkit to support this asset type.`); } } -async function prepareZipAsset(asset: AssetMetadataEntry, toolkitInfo: ToolkitInfo): Promise { +async function prepareZipAsset(asset: FileAssetMetadataEntry, toolkitInfo: ToolkitInfo): Promise { debug('Preparing zip asset from directory:', asset.path); const staging = await fs.mkdtemp(path.join(os.tmpdir(), 'cdk-assets')); try { @@ -61,7 +66,7 @@ async function prepareZipAsset(asset: AssetMetadataEntry, toolkitInfo: ToolkitIn * @param contentType Content-type to use when uploading to S3 (none will be specified by default) */ async function prepareFileAsset( - asset: AssetMetadataEntry, + asset: FileAssetMetadataEntry, toolkitInfo: ToolkitInfo, filePath?: string, contentType?: string): Promise { diff --git a/packages/aws-cdk/lib/docker.ts b/packages/aws-cdk/lib/docker.ts new file mode 100644 index 0000000000000..e916ff0480c29 --- /dev/null +++ b/packages/aws-cdk/lib/docker.ts @@ -0,0 +1,220 @@ +import { ContainerImageAssetMetadataEntry } from '@aws-cdk/cx-api'; +import { CloudFormation } from 'aws-sdk'; +import crypto = require('crypto'); +import { ToolkitInfo } from './api/toolkit-info'; +import { debug, print } from './logging'; +import { shell } from './os'; +import { PleaseHold } from './util/please-hold'; + +/** + * Build and upload a Docker image + * + * Permanently identifying images is a bit of a bust. Newer Docker version use + * a digest (sha256:xxxx) as an image identifier, which is pretty good to avoid + * spurious rebuilds. However, this digest is calculated over a manifest that + * includes metadata that is liable to change. For example, as soon as we + * push the Docker image to a repository, the digest changes. This makes the + * digest worthless to determe whether we already pushed an image, for example. + * + * As a workaround, we calculate our own digest over parts of the manifest that + * are unlikely to change, and tag based on that. + */ +export async function prepareContainerAsset(asset: ContainerImageAssetMetadataEntry, toolkitInfo: ToolkitInfo): Promise { + debug(' 👑 Preparing Docker image asset:', asset.path); + + const buildHold = new PleaseHold(` ⌛ Building Docker image for ${asset.path}; this may take a while.`); + try { + buildHold.start(); + + const command = ['docker', + 'build', + '--quiet', + asset.path]; + const imageId = (await shell(command, { quiet: true })).trim(); + buildHold.stop(); + + const tag = await calculateImageFingerprint(imageId); + + debug(` ⌛ Image has tag ${tag}, preparing ECR repository`); + const ecr = await toolkitInfo.prepareEcrRepository(asset.id, tag); + + if (ecr.alreadyExists) { + debug(' 👑 Image already uploaded.'); + } else { + // Login and push + debug(` ⌛ Image needs to be uploaded first.`); + + await shell(['docker', 'login', + '--username', ecr.username, + '--password', ecr.password, + ecr.endpoint]); + + const qualifiedImageName = `${ecr.repositoryUri}:${tag}`; + await shell(['docker', 'tag', imageId, qualifiedImageName]); + + // There's no way to make this quiet, so we can't use a PleaseHold. Print a header message. + print(` ⌛ Pusing Docker image for ${asset.path}; this may take a while.`); + await shell(['docker', 'push', qualifiedImageName]); + debug(` 👑 Docker image for ${asset.path} pushed.`); + } + + return [ + { ParameterKey: asset.repositoryParameter, ParameterValue: ecr.repositoryArn }, + { ParameterKey: asset.tagParameter, ParameterValue: tag }, + ]; + } catch (e) { + if (e.code === 'ENOENT') { + // tslint:disable-next-line:max-line-length + throw new Error('Error building Docker image asset; you need to have Docker installed in order to be able to build image assets. Please install Docker and try again.'); + } + throw e; + } finally { + buildHold.stop(); + } +} + +/** + * Calculate image fingerprint. + * + * The fingerprint has a high likelihood to be the same across repositories. + * (As opposed to Docker's built-in image digest, which changes as soon + * as the image is uploaded since it includes the tags that an image has). + * + * The fingerprint will be used as a tag to identify a particular image. + */ +async function calculateImageFingerprint(imageId: string) { + const manifestString = await shell(['docker', 'inspect', imageId], { quiet: true }); + const manifest = JSON.parse(manifestString)[0]; + + // Id can change + delete manifest.Id; + + // Repository-based identifiers are out + delete manifest.RepoTags; + delete manifest.RepoDigests; + + // Metadata that has no bearing on the image contents + delete manifest.Created; + + // We're interested in the image itself, not any running instaces of it + delete manifest.Container; + delete manifest.ContainerConfig; + + // We're not interested in the Docker version used to create this image + delete manifest.DockerVersion; + + return crypto.createHash('sha256').update(JSON.stringify(manifest)).digest('hex'); +} + +/** + * Example of a Docker manifest + * + * [ + * { + * "Id": "sha256:3a90542991d03007fd1d8f3b3a6ab04ebb02386785430fe48a867768a048d828", + * "RepoTags": [ + * "993655754359.dkr.ecr.us-east-1.amazonaws.com/cdk/awsecsintegimage7c15b8c6:latest" + * ], + * "RepoDigests": [ + * "993655754359.dkr.ecr.us-east-1.amazo....5e50c0cfc3f2355191934b05df68cd3339a044959111ffec2e14765" + * ], + * "Parent": "sha256:465720f8f43c9c0aff5dcc731d4e368a3927cae4e885442d4ba0bf8a867b7561", + * "Comment": "", + * "Created": "2018-10-17T10:16:40.775888476Z", + * "Container": "20f145d2e7fbf126ca9f4422497b932bc96b5faa038dc032de1e246f64e03a66", + * "ContainerConfig": { + * "Hostname": "9b48b580a312", + * "Domainname": "", + * "User": "", + * "AttachStdin": false, + * "AttachStdout": false, + * "AttachStderr": false, + * "ExposedPorts": { + * "8000/tcp": {} + * }, + * "Tty": false, + * "OpenStdin": false, + * "StdinOnce": false, + * "Env": [ + * "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + * "LANG=C.UTF-8", + * "GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D", + * "PYTHON_VERSION=3.6.6", + * "PYTHON_PIP_VERSION=18.1" + * ], + * "Cmd": [ + * "/bin/sh", + * "-c", + * "#(nop) ", + * "CMD [\"/bin/sh\" \"-c\" \"python3 index.py\"]" + * ], + * "ArgsEscaped": true, + * "Image": "sha256:465720f8f43c9c0aff5dcc731d4e368a3927cae4e885442d4ba0bf8a867b7561", + * "Volumes": null, + * "WorkingDir": "/code", + * "Entrypoint": null, + * "OnBuild": [], + * "Labels": {} + * }, + * "DockerVersion": "17.03.2-ce", + * "Author": "", + * "Config": { + * "Hostname": "9b48b580a312", + * "Domainname": "", + * "User": "", + * "AttachStdin": false, + * "AttachStdout": false, + * "AttachStderr": false, + * "ExposedPorts": { + * "8000/tcp": {} + * }, + * "Tty": false, + * "OpenStdin": false, + * "StdinOnce": false, + * "Env": [ + * "PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + * "LANG=C.UTF-8", + * "GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421D", + * "PYTHON_VERSION=3.6.6", + * "PYTHON_PIP_VERSION=18.1" + * ], + * "Cmd": [ + * "/bin/sh", + * "-c", + * "python3 index.py" + * ], + * "ArgsEscaped": true, + * "Image": "sha256:465720f8f43c9c0aff5dcc731d4e368a3927cae4e885442d4ba0bf8a867b7561", + * "Volumes": null, + * "WorkingDir": "/code", + * "Entrypoint": null, + * "OnBuild": [], + * "Labels": {} + * }, + * "Architecture": "amd64", + * "Os": "linux", + * "Size": 917730468, + * "VirtualSize": 917730468, + * "GraphDriver": { + * "Name": "aufs", + * "Data": null + * }, + * "RootFS": { + * "Type": "layers", + * "Layers": [ + * "sha256:f715ed19c28b66943ac8bc12dbfb828e8394de2530bbaf1ecce906e748e4fdff", + * "sha256:8bb25f9cdc41e7d085033af15a522973b44086d6eedd24c11cc61c9232324f77", + * "sha256:08a01612ffca33483a1847c909836610610ce523fb7e1aca880140ee84df23e9", + * "sha256:1191b3f5862aa9231858809b7ac8b91c0b727ce85c9b3279932f0baacc92967d", + * "sha256:9978d084fd771e0b3d1acd7f3525d1b25288ababe9ad8ed259b36101e4e3addd", + * "sha256:2f4f74d3821ecbdd60b5d932452ea9e30cecf902334165c4a19837f6ee636377", + * "sha256:003bb6178bc3218242d73e51d5e9ab2f991dc607780194719c6bd4c8c412fe8c", + * "sha256:15b32d849da2239b1af583f9381c7a75d7aceba12f5ddfffa7a059116cf05ab9", + * "sha256:6e5c5f6bf043bc634378b1e4b61af09be74741f2ac80204d7a373713b1fd5a40", + * "sha256:3260e00e353bfb765b25597d13868c2ef64cb3d509875abcfb58c4e9bf7f4ee2", + * "sha256:f3274b75856311e92e14a1270c78737c86456d6353fe4a83bd2e81bcd2a996ea" + * ] + * } + * } + * ] + */ diff --git a/packages/aws-cdk/lib/logging.ts b/packages/aws-cdk/lib/logging.ts index 03405da3fdb1b..d9b6ef87a2900 100644 --- a/packages/aws-cdk/lib/logging.ts +++ b/packages/aws-cdk/lib/logging.ts @@ -3,7 +3,7 @@ import util = require('util'); // tslint:disable:no-console the whole point of those methods is precisely to output to the console... -let isVerbose = false; +export let isVerbose = false; export function setVerbose(enabled = true) { isVerbose = enabled; diff --git a/packages/aws-cdk/lib/os.ts b/packages/aws-cdk/lib/os.ts new file mode 100644 index 0000000000000..07353ee659ddd --- /dev/null +++ b/packages/aws-cdk/lib/os.ts @@ -0,0 +1,98 @@ +import child_process = require("child_process"); +import colors = require('colors/safe'); +import { debug } from "./logging"; + +export interface ShellOptions extends child_process.SpawnOptions { + quiet?: boolean; +} + +/** + * OS helpers + * + * Shell function which both prints to stdout and collects the output into a + * string. + */ +export async function shell(command: string[], options: ShellOptions = {}): Promise { + debug(`Executing ${colors.blue(renderCommandLine(command))}`); + const child = child_process.spawn(command[0], command.slice(1), { + ...options, + stdio: [ 'ignore', 'pipe', 'inherit' ] + }); + + return new Promise((resolve, reject) => { + const stdout = new Array(); + + // Both write to stdout and collect + child.stdout.on('data', chunk => { + if (!options.quiet) { + process.stdout.write(chunk); + } + stdout.push(chunk); + }); + + child.once('error', reject); + + child.once('exit', code => { + if (code === 0) { + resolve(Buffer.concat(stdout).toString('utf-8')); + } else { + reject(new Error(`${renderCommandLine(command)} exited with error code ${code}`)); + } + }); + }); +} + +/** + * Render the given command line as a string + * + * Probably missing some cases but giving it a good effort. + */ +function renderCommandLine(cmd: string[]) { + if (process.platform !== 'win32') { + return doRender(cmd, hasAnyChars(' ', '\\', '!', '"', "'", '&', '$'), posixEscape); + } else { + return doRender(cmd, hasAnyChars(' ', '"', '&', '^', '%'), windowsEscape); + } +} + +/** + * Render a UNIX command line + */ +function doRender(cmd: string[], needsEscaping: (x: string) => boolean, doEscape: (x: string) => string): string { + return cmd.map(x => needsEscaping(x) ? doEscape(x) : x).join(' '); +} + +/** + * Return a predicate that checks if a string has any of the indicated chars in it + */ +function hasAnyChars(...chars: string[]): (x: string) => boolean { + return (str: string) => { + return chars.some(c => str.indexOf(c) !== -1); + }; +} + +/** + * Escape a shell argument for POSIX shells + * + * Wrapping in single quotes and escaping single quotes inside will do it for us. + */ +function posixEscape(x: string) { + // Turn ' -> '"'"' + x = x.replace("'", "'\"'\"'"); + return `'${x}'`; +} + +/** + * Escape a shell argument for cmd.exe + * + * This is how to do it right, but I'm not following everything: + * + * https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ + */ +function windowsEscape(x: string): string { + // First surround by double quotes, ignore the part about backslashes + x = `"${x}"`; + // Now escape all special characters + const shellMeta = new Set(['"', '&', '^', '%']); + return x.split('').map(c => shellMeta.has(x) ? '^' + c : c).join(''); +} diff --git a/packages/aws-cdk/lib/util/please-hold.ts b/packages/aws-cdk/lib/util/please-hold.ts new file mode 100644 index 0000000000000..cb6eff963296b --- /dev/null +++ b/packages/aws-cdk/lib/util/please-hold.ts @@ -0,0 +1,26 @@ +import colors = require('colors/safe'); +import { print } from "../logging"; + +/** + * Print a message to the logger in case the operation takes a long time + */ +export class PleaseHold { + private handle?: NodeJS.Timer; + + constructor(private readonly message: string, private readonly timeoutSec = 10) { + } + + public start() { + this.handle = setTimeout(this.printMessage.bind(this), this.timeoutSec * 1000); + } + + public stop() { + if (this.handle) { + clearTimeout(this.handle); + } + } + + private printMessage() { + print(colors.yellow(this.message)); + } +} diff --git a/scripts/build-typescript.sh b/scripts/build-typescript.sh new file mode 100755 index 0000000000000..98bc1dbd77538 --- /dev/null +++ b/scripts/build-typescript.sh @@ -0,0 +1,42 @@ +#!/bin/bash +cat < tsconfig.json +{ + "compilerOptions": { + "alwaysStrict": true, + "charset": "utf8", + "declaration": false, + "experimentalDecorators": true, + "inlineSourceMap": true, + "inlineSources": true, + "lib": [ + "es2016", + "es2017.object", + "es2017.string" + ], + "module": "CommonJS", + "noEmitOnError": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "strict": true, + "strictNullChecks": true, + "target": "ES2018" + }, + "include": [ + "packages/**/*.ts", + "tools/**/*.ts" + ], + "exclude": [ + "node_modules", + "packages/@aws-cdk/aws-sns/examples", + "tools/cfn2ts/test/enrichments", + "packages/aws-cdk/lib/init-templates" + ], + "_generated_by_jsii_": "Generated by jsii - safe to delete, and ideally should be in .gitignore" +} +EOF +node_modules/.bin/tsc -p . "$@" diff --git a/scripts/dependencies.py b/scripts/dependencies.py new file mode 100644 index 0000000000000..459c8829c2170 --- /dev/null +++ b/scripts/dependencies.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import json +import sys +import collections +import os +from os import path + + +def full_dependency_graph(): + """Return a map of { package -> [package] }.""" + graph = collections.defaultdict(set) + for filename in package_jsons(): + with open(filename) as f: + package_json = json.load(f) + + for key in ['devDependencies', 'dependencies']: + if key in package_json: + graph[package_json['name']].update(package_json[key].keys()) + + return graph + + +def local_dependency_graph(): + """Retain only the dependencies that are also in the repo.""" + graph = full_dependency_graph() + for deps in graph.values(): + deps.intersection_update(graph.keys()) + return graph + + +def package_jsons(): + """Return a list of all package.json files in this project.""" + rootdir = path.dirname(path.dirname(path.realpath(__file__))) + for root, dirs, files in os.walk(rootdir): + if 'node_modules' in dirs: + dirs.remove('node_modules') + + if 'package.json' in files: + yield path.join(root, 'package.json') + + +def find(xs, x): + for i, value in enumerate(xs): + if x == value: + return i + return None + + +def print_graph(graph): + for package, deps in graph.items(): + for dep in deps: + print('%s -> %s' % (package, dep)) + + checked = set() + + # Do a check for cycles for each package. This is slow but it works, + # and it has the advantage that it can give good diagnostics. + def check_for_cycles(package, path): + i = find(path, package) + if i is not None: + cycle = path[i:] + [package] + print('Cycle: %s' % ' => '.join(cycle)) + return + + if package in checked: + return + + checked.add(package) + + deps = graph.get(package, []) + for dep in deps: + check_for_cycles(dep, path + [package]) + + for package in graph.keys(): + check_for_cycles(package, []) + +def main(): + print_graph(local_dependency_graph()) + + +if __name__ == '__main__': + main() diff --git a/scripts/regen-l1.sh b/scripts/regen-l1.sh new file mode 100755 index 0000000000000..bc6dd410b9e7b --- /dev/null +++ b/scripts/regen-l1.sh @@ -0,0 +1,6 @@ +#!/bin/bash +rm -f packages/@aws-cdk/*/lib/*.generated.* +node_modules/.bin/lerna --scope @aws-cdk/cfnspec run build +node_modules/.bin/lerna --scope cfn2ts run build +cfn2ts=$(pwd)/tools/cfn2ts/bin/cfn2ts +node_modules/.bin/lerna --concurrency=1 --no-bail exec -- bash -c "pwd && $cfn2ts --scope \$(node -p 'require(\"./package.json\")[\"cdk-build\"].cloudformation')" diff --git a/tools/cdk-integ-tools/lib/integ-helpers.ts b/tools/cdk-integ-tools/lib/integ-helpers.ts index f7686401c3d7f..60936c394a5c6 100644 --- a/tools/cdk-integ-tools/lib/integ-helpers.ts +++ b/tools/cdk-integ-tools/lib/integ-helpers.ts @@ -5,6 +5,9 @@ import fs = require('fs'); import path = require('path'); import util = require('util'); +const stat = util.promisify(fs.stat); +const readdir = util.promisify(fs.readdir); + export class IntegrationTests { constructor(private readonly directory: string) { } @@ -18,14 +21,33 @@ export class IntegrationTests { } public async discover(): Promise { - const files = await util.promisify(fs.readdir)(this.directory); - const integs = files.filter(fileName => fileName.startsWith('integ.') && fileName.endsWith('.js')); + const files = await this.readTree(); + const integs = files.filter(fileName => path.basename(fileName).startsWith('integ.') && path.basename(fileName).endsWith('.js')); return await this.request(integs); } public async request(files: string[]): Promise { return files.map(fileName => new IntegrationTest(this.directory, fileName)); } + + private async readTree(): Promise { + const ret = new Array(); + + const rootDir = this.directory; + + async function recurse(dir: string) { + const files = await readdir(dir); + for (const file of files) { + const fullPath = path.join(dir, file); + const statf = await stat(fullPath); + if (statf.isFile()) { ret.push(fullPath.substr(rootDir.length + 1)); } + if (statf.isDirectory()) { await recurse(path.join(fullPath)); } + } + } + + await recurse(this.directory); + return ret; + } } export class IntegrationTest { @@ -34,7 +56,8 @@ export class IntegrationTest { private readonly cdkConfigPath: string; constructor(private readonly directory: string, public readonly name: string) { - this.expectedFileName = path.basename(this.name, '.js') + '.expected.json'; + const baseName = this.name.endsWith('.js') ? this.name.substr(0, this.name.length - 3) : this.name; + this.expectedFileName = baseName + '.expected.json'; this.expectedFilePath = path.join(this.directory, this.expectedFileName); this.cdkConfigPath = path.join(this.directory, 'cdk.json'); } @@ -91,6 +114,7 @@ export const STATIC_TEST_CONTEXT = { "availability-zones:account=12345678:region=test-region": [ "test-region-1a", "test-region-1b", "test-region-1c" ], "ssm:account=12345678:parameterName=/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2:region=test-region": "ami-1234", "ssm:account=12345678:parameterName=/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2:region=test-region": "ami-1234", + "ssm:account=12345678:parameterName=/aws/service/ecs/optimized-ami/amazon-linux/recommended:region=test-region": "{\"image_id\": \"ami-1234\"}", }; /**