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\"}", }; /**