diff --git a/.github/workflows/issue-label-assign.yml b/.github/workflows/issue-label-assign.yml index 22f20538795c7..01afcd841138d 100644 --- a/.github/workflows/issue-label-assign.yml +++ b/.github/workflows/issue-label-assign.yml @@ -54,8 +54,8 @@ jobs: {"keywords":["(@aws-cdk/aws-cloudfront)","(aws-cloudfront)","(cloudfront)","(cloud front)"],"labels":["@aws-cdk/aws-cloudfront"],"assignees":["njlynch"]}, {"keywords":["(@aws-cdk/aws-cloudfront-origins)","(aws-cloudfront-origins)","(cloudfront-origins)","(cloudfront origins)"],"labels":["@aws-cdk/aws-cloudfront-origins"],"assignees":["njlynch"]}, {"keywords":["(@aws-cdk/aws-cloudtrail)","(aws-cloudtrail)","(cloudtrail)","(cloud trail)"],"labels":["@aws-cdk/aws-cloudtrail"],"assignees":["NetaNir"]}, - {"keywords":["(@aws-cdk/aws-cloudwatch)","(aws-cloudwatch)","(cloudwatch)","(cloud watch)"],"labels":["@aws-cdk/aws-cloudwatch"],"assignees":["rix0rrr"]}, - {"keywords":["(@aws-cdk/aws-cloudwatch-actions)","(aws-cloudwatch-actions)","(cloudwatch-actions)","(cloudwatch actions)"],"labels":["@aws-cdk/aws-cloudwatch-actions"],"assignees":["rix0rrr"]}, + {"keywords":["(@aws-cdk/aws-cloudwatch)","(aws-cloudwatch)","(cloudwatch)","(cloud watch)"],"labels":["@aws-cdk/aws-cloudwatch"],"assignees":["NetaNir"]}, + {"keywords":["(@aws-cdk/aws-cloudwatch-actions)","(aws-cloudwatch-actions)","(cloudwatch-actions)","(cloudwatch actions)"],"labels":["@aws-cdk/aws-cloudwatch-actions"],"assignees":["NetaNir"]}, {"keywords":["(@aws-cdk/aws-codeartifact)","(aws-codeartifact)","(codeartifact)","(code artifact)","(code-artifact)"],"labels":["@aws-cdk/aws-codeartifact"],"assignees":["njlynch"]}, {"keywords":["(@aws-cdk/aws-codebuild)","(aws-codebuild)","(codebuild)","(code build)","(code-build)"],"labels":["@aws-cdk/aws-codebuild"],"assignees":["skinny85"]}, {"keywords":["(@aws-cdk/aws-codecommit)","(aws-codecommit)","(codecommit)","(code commit)", "(code-commit)"],"labels":["@aws-cdk/aws-codecommit"],"assignees":["skinny85"]}, @@ -76,8 +76,8 @@ jobs: {"keywords":["(@aws-cdk/aws-dlm)","(aws-dlm)","(dlm)"],"labels":["@aws-cdk/aws-dlm"],"assignees":["njlynch"]}, {"keywords":["(@aws-cdk/aws-dms)","(aws-dms)","(dms)"],"labels":["@aws-cdk/aws-dms"],"assignees":["njlynch"]}, {"keywords":["(@aws-cdk/aws-docdb)","(aws-docdb)","(docdb)","(doc db)","(doc-db)"],"labels":["@aws-cdk/aws-docdb"],"assignees":["iliapolo"]}, - {"keywords":["(@aws-cdk/aws-dynamodb)","(aws-dynamodb)","(dynamodb)","(dynamo db)","(dynamo-db)"],"labels":["@aws-cdk/aws-dynamodb"],"assignees":["skinny85"]}, - {"keywords":["(@aws-cdk/aws-dynamodb-global)","(aws-dynamodb-global)","(dynamodb-global)","(dynamodb global)"],"labels":["@aws-cdk/aws-dynamodb-global"],"assignees":["skinny85"]}, + {"keywords":["(@aws-cdk/aws-dynamodb)","(aws-dynamodb)","(dynamodb)","(dynamo db)","(dynamo-db)"],"labels":["@aws-cdk/aws-dynamodb"],"assignees":["RomainMuller"]}, + {"keywords":["(@aws-cdk/aws-dynamodb-global)","(aws-dynamodb-global)","(dynamodb-global)","(dynamodb global)"],"labels":["@aws-cdk/aws-dynamodb-global"],"assignees":["RomainMuller"]}, {"keywords":["(@aws-cdk/aws-ec2)","(aws-ec2)","(ec2)","(vpc)"],"labels":["@aws-cdk/aws-ec2"],"assignees":["rix0rrr"]}, {"keywords":["(@aws-cdk/aws-ecr)","(aws-ecr)","(ecr)"],"labels":["@aws-cdk/aws-ecr"],"assignees":["MrArnoldPalmer"]}, {"keywords":["(@aws-cdk/aws-ecr-assets)","(aws-ecr-assets)","(ecr-assets)","(ecr assets)","(ecrassets)"],"labels":["@aws-cdk/aws-ecr-assets"],"assignees":["eladb"]}, diff --git a/CHANGELOG.md b/CHANGELOG.md index 41d7a76abc330..7dd8c2c1991b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,7 +101,7 @@ All notable changes to this project will be documented in this file. See [standa ### ⚠ BREAKING CHANGES TO EXPERIMENTAL FEATURES -* **appmesh:** the properties virtualRouter and virtualNode of VirtualServiceProps have been replaced with the union-like class VirtualServiceProvider +* **appmesh:** the properties virtualRouter and virtualNode of VirtualServiceProps have been replaced with the union-like class VirtualServiceProvider * **appmesh**: the method `addVirtualService` has been removed from `IMesh` * **cloudfront:** experimental EdgeFunction stack names have changed from 'edge-lambda-stack-${region}' to 'edge-lambda-stack-${stackid}' to support multiple independent CloudFront distributions with EdgeFunctions. diff --git a/DESIGN_GUIDELINES.md b/DESIGN_GUIDELINES.md index e6cebe9f434c6..20fd5be016870 100644 --- a/DESIGN_GUIDELINES.md +++ b/DESIGN_GUIDELINES.md @@ -14,7 +14,7 @@ APIs and verifies that the APIs adhere to the guidelines. When a guideline is backed by a linter rule, the rule name will be referenced like this: _[awslint:resource-class-is-construct]_. -For the purpose of this document we will use "Foo" to denote the official name +For the purpose of this document, we will use "Foo" to denote the official name of the resource as defined in the AWS CloudFormation resource specification (i.e. "Bucket", "Queue", "Topic", etc). This notation allows deriving names from the official name. For example, `FooProps` would be `BucketProps`, `TopicProps`, @@ -98,8 +98,8 @@ or abstractions. However, you will notice that some sections explicitly call out guidelines that apply only to AWS resources (and in many cases enforced/implemented by the **Resource** base class). -AWS services are modeled around the concept of *resources*. Service normally -expose through their APIs one or more resources, which can be provisioned +AWS services are modeled around the concept of *resources*. Services normally +expose one or more resources through their APIs, which can be provisioned through the APIs control plane or through AWS CloudFormation. Every resource available in the AWS platform will have a corresponding resource @@ -397,12 +397,12 @@ For example, prefer “readCapacity” versus “readCapacityUnits”. We prefer the terminology used by the official AWS service documentation over new terminology, even if you think it's not ideal. It helps users diagnose issues and map the mental model of the construct to the service APIs, -documentation and examples. For example don't be tempted to change SQS's +documentation and examples. For example, don't be tempted to change SQS's **dataKeyReusePeriod** with **keyRotation** because it will be hard for people to diagnose problems. They won't be able to just search for “sqs dataKeyReuse” and find topics on it. -> We can relax this guidelines when this is about generic terms (like +> We can relax this guideline when this is about generic terms (like `httpStatus` instead of `statusCode`). The important semantics to preserve are for *service features*: I wouldn't want to rename "lambda layers" to "lambda dependencies" just because it makes more sense because then users won't be @@ -697,8 +697,8 @@ _[awslint:from-signature]_: #### “from” Methods Resource constructs should export static “from” methods for importing unowned -resources given one more of its physical attributes such as ARN, name, etc. All -constructs should have at least one "fromXxx" method _[awslint:from-method]_: +resources given one or more of its physical attributes such as ARN, name, etc. All +constructs should have at least one `fromXxx` method _[awslint:from-method]_: ```ts static fromFooArn(scope: Construct, id: string, bucketArn: string): IFoo; @@ -870,7 +870,7 @@ vpcSubnetSelection?: ec2.SubnetSelection; ### Grants -Grants are one of the most powerful concept in the AWS Construct Library. They +Grants are one of the most powerful concepts in the AWS Construct Library. They offer a higher level, intent-based, API for managing IAM permissions for AWS resources. @@ -974,7 +974,7 @@ class Function extends Resource implements IFunction { ### Events -Many AWS resource emit events to the CloudWatch event bus. Such resources should +Many AWS resources emit events to the CloudWatch event bus. Such resources should have a set of “onXxx” methods available on their construct interface _[awslint:events-in-interface]_. diff --git a/allowed-breaking-changes.txt b/allowed-breaking-changes.txt index 964bb4d5c7712..2ca2ca5b6067f 100644 --- a/allowed-breaking-changes.txt +++ b/allowed-breaking-changes.txt @@ -56,10 +56,3 @@ incompatible-argument:@aws-cdk/aws-ecs.TaskDefinition.addVolume # We made properties optional and it's really fine but our differ doesn't think so. weakened:@aws-cdk/cloud-assembly-schema.DockerImageSource weakened:@aws-cdk/cloud-assembly-schema.FileSource - -# https://github.com/aws/aws-cdk/pull/13145 -removed:@aws-cdk/core.AssetStaging.isArchive -removed:@aws-cdk/core.AssetStaging.packaging -removed:@aws-cdk/core.BundlingOutput -removed:@aws-cdk/core.BundlingOptions.outputType - diff --git a/packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.expected.json b/packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.expected.json index 23c4884db164f..e5db15d48809b 100644 --- a/packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.expected.json +++ b/packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.expected.json @@ -72,7 +72,8 @@ "Arn" ] }, - "Runtime": "nodejs12.x" + "Runtime": "nodejs12.x", + "Description": "veni vidi vici" }, "DependsOn": [ "CustomReflectCustomResourceProviderRoleB4B29AEC" diff --git a/packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.ts b/packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.ts index 1743444c1ad57..7f5e9d49a95db 100644 --- a/packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.ts +++ b/packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.ts @@ -24,6 +24,7 @@ class TestStack extends Stack { const serviceToken = CustomResourceProvider.getOrCreate(this, resourceType, { codeDirectory: `${__dirname}/core-custom-resource-provider-fixture`, runtime: CustomResourceProviderRuntime.NODEJS_12, + description: 'veni vidi vici', }); const cr = new CustomResource(this, 'MyResource', { diff --git a/packages/@aws-cdk/aws-cloudwatch/lib/private/rendering.ts b/packages/@aws-cdk/aws-cloudwatch/lib/private/rendering.ts index e8dcb15cabc84..8553d9ad5c486 100644 --- a/packages/@aws-cdk/aws-cloudwatch/lib/private/rendering.ts +++ b/packages/@aws-cdk/aws-cloudwatch/lib/private/rendering.ts @@ -55,6 +55,7 @@ function metricGraphJson(metric: IMetric, yAxis?: string, id?: string) { withExpression(expr) { options.expression = expr.expression; + if (expr.period && expr.period !== 300) { options.period = expr.period; } }, }); diff --git a/packages/@aws-cdk/aws-cloudwatch/test/integ.math-alarm-and-dashboard.expected.json b/packages/@aws-cdk/aws-cloudwatch/test/integ.math-alarm-and-dashboard.expected.json index 7de0e4290cd65..8e9b235bb2b65 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/integ.math-alarm-and-dashboard.expected.json +++ b/packages/@aws-cdk/aws-cloudwatch/test/integ.math-alarm-and-dashboard.expected.json @@ -88,7 +88,7 @@ { "Ref": "AWS::Region" }, - "\",\"metrics\":[[{\"label\":\"Total Messages\",\"expression\":\"m1+m2\"}],[\"AWS/SQS\",\"ApproximateNumberOfMessagesVisible\",\"QueueName\",\"", + "\",\"metrics\":[[{\"label\":\"Total Messages\",\"expression\":\"m1+m2\",\"period\":60}],[\"AWS/SQS\",\"ApproximateNumberOfMessagesVisible\",\"QueueName\",\"", { "Fn::GetAtt": [ "queue", @@ -120,7 +120,7 @@ { "Ref": "AWS::Region" }, - "\",\"metrics\":[[{\"label\":\"Total Messages\",\"expression\":\"m1+m2\"}],[\"AWS/SQS\",\"ApproximateNumberOfMessagesVisible\",\"QueueName\",\"", + "\",\"metrics\":[[{\"label\":\"Total Messages\",\"expression\":\"m1+m2\",\"period\":60}],[\"AWS/SQS\",\"ApproximateNumberOfMessagesVisible\",\"QueueName\",\"", { "Fn::GetAtt": [ "queue", diff --git a/packages/@aws-cdk/aws-cloudwatch/test/test.metric-math.ts b/packages/@aws-cdk/aws-cloudwatch/test/test.metric-math.ts index e8288ce092b27..b5eecac9ec52d 100644 --- a/packages/@aws-cdk/aws-cloudwatch/test/test.metric-math.ts +++ b/packages/@aws-cdk/aws-cloudwatch/test/test.metric-math.ts @@ -219,6 +219,28 @@ export = { test.done(); }, + 'top level period in a MathExpression is respected in its metrics'(test: Test) { + const graph = new GraphWidget({ + left: [ + a, + new MathExpression({ + expression: 'a + b', + usingMetrics: { a, b }, + period: Duration.minutes(1), + }), + ], + }); + + // THEN + graphMetricsAre(test, graph, [ + ['Test', 'ACount'], + [{ label: 'a + b', expression: 'a + b', period: 60 }], + ['Test', 'ACount', { visible: false, id: 'a', period: 60 }], + ['Test', 'BCount', { visible: false, id: 'b', period: 60 }], + ]); + test.done(); + }, + 'MathExpression controls period of metrics transitively used in it'(test: Test) { // Same as the previous test, but recursively diff --git a/packages/@aws-cdk/aws-ec2/README.md b/packages/@aws-cdk/aws-ec2/README.md index 3b902e5cb8c75..49fff4b5c4f63 100644 --- a/packages/@aws-cdk/aws-ec2/README.md +++ b/packages/@aws-cdk/aws-ec2/README.md @@ -275,7 +275,7 @@ DatabaseSubnet3 |`ISOLATED`|`10.0.6.32/28`|#3|Only routes within the VPC ### Accessing the Internet Gateway -If you need access to the internet gateway, you can get it's ID like so: +If you need access to the internet gateway, you can get its ID like so: ```ts const igwId = vpc.internetGatewayId; diff --git a/packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts b/packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts index a190c17d7aa20..646d6b2dcbfa4 100644 --- a/packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts +++ b/packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts @@ -327,6 +327,9 @@ export abstract class InitFile extends InitElement { * Use a literal string as the file content */ public static fromString(fileName: string, content: string, options: InitFileOptions = {}): InitFile { + if (!content) { + throw new Error(`InitFile ${fileName}: cannot create empty file. Please supply at least one character of content.`); + } return new class extends InitFile { protected _doBind(bindOptions: InitBindOptions) { return { diff --git a/packages/@aws-cdk/aws-ec2/lib/vpc-endpoint.ts b/packages/@aws-cdk/aws-ec2/lib/vpc-endpoint.ts index 197946716f969..2250bdd37c6c3 100644 --- a/packages/@aws-cdk/aws-ec2/lib/vpc-endpoint.ts +++ b/packages/@aws-cdk/aws-ec2/lib/vpc-endpoint.ts @@ -287,6 +287,7 @@ export class InterfaceVpcEndpointAwsService implements IInterfaceVpcEndpointServ public static readonly KMS = new InterfaceVpcEndpointAwsService('kms'); public static readonly CLOUDWATCH_LOGS = new InterfaceVpcEndpointAwsService('logs'); public static readonly CLOUDWATCH = new InterfaceVpcEndpointAwsService('monitoring'); + public static readonly RDS = new InterfaceVpcEndpointAwsService('rds'); public static readonly SAGEMAKER_API = new InterfaceVpcEndpointAwsService('sagemaker.api'); public static readonly SAGEMAKER_RUNTIME = new InterfaceVpcEndpointAwsService('sagemaker.runtime'); public static readonly SAGEMAKER_RUNTIME_FIPS = new InterfaceVpcEndpointAwsService('sagemaker.runtime-fips'); diff --git a/packages/@aws-cdk/aws-ec2/package.json b/packages/@aws-cdk/aws-ec2/package.json index 624577f547b62..801040d4c7385 100644 --- a/packages/@aws-cdk/aws-ec2/package.json +++ b/packages/@aws-cdk/aws-ec2/package.json @@ -233,6 +233,7 @@ "docs-public-apis:@aws-cdk/aws-ec2.InterfaceVpcEndpointAwsService.KINESIS_STREAMS", "docs-public-apis:@aws-cdk/aws-ec2.InterfaceVpcEndpointAwsService.KINESIS_FIREHOSE", "docs-public-apis:@aws-cdk/aws-ec2.InterfaceVpcEndpointAwsService.KMS", + "docs-public-apis:@aws-cdk/aws-ec2.InterfaceVpcEndpointAwsService.RDS", "docs-public-apis:@aws-cdk/aws-ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_API", "docs-public-apis:@aws-cdk/aws-ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_NOTEBOOK", "docs-public-apis:@aws-cdk/aws-ec2.InterfaceVpcEndpointAwsService.SAGEMAKER_RUNTIME", diff --git a/packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts b/packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts index e794021c46b8f..75896912f3661 100644 --- a/packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts +++ b/packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts @@ -206,6 +206,12 @@ describe('InitFile', () => { }); }); + test('empty content string throws error', () => { + expect(() => { + ec2.InitFile.fromString('/tmp/foo', ''); + }).toThrow('InitFile /tmp/foo: cannot create empty file. Please supply at least one character of content.'); + }); + test('symlink throws an error if mode is set incorrectly', () => { expect(() => { ec2.InitFile.symlink('/tmp/foo', '/tmp/bar', { diff --git a/packages/@aws-cdk/aws-ecr/lib/repository.ts b/packages/@aws-cdk/aws-ecr/lib/repository.ts index 83c05ba1ed308..3787903dedc3e 100644 --- a/packages/@aws-cdk/aws-ecr/lib/repository.ts +++ b/packages/@aws-cdk/aws-ecr/lib/repository.ts @@ -39,6 +39,15 @@ export interface IRepository extends IResource { */ repositoryUriForTag(tag?: string): string; + /** + * Returns the URI of the repository for a certain tag. Can be used in `docker push/pull`. + * + * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY[@DIGEST] + * + * @param digest Image digest to use (tools usually default to the image with the "latest" tag if omitted) + */ + repositoryUriForDigest(digest?: string): string; + /** * Add a policy statement to the repository's resource policy */ @@ -136,8 +145,29 @@ export abstract class RepositoryBase extends Resource implements IRepository { */ public repositoryUriForTag(tag?: string): string { const tagSuffix = tag ? `:${tag}` : ''; + return this.repositoryUriWithSuffix(tagSuffix); + } + + /** + * Returns the URL of the repository. Can be used in `docker push/pull`. + * + * ACCOUNT.dkr.ecr.REGION.amazonaws.com/REPOSITORY[@DIGEST] + * + * @param digest Optional image digest + */ + public repositoryUriForDigest(digest?: string): string { + const digestSuffix = digest ? `@${digest}` : ''; + return this.repositoryUriWithSuffix(digestSuffix); + } + + /** + * Returns the repository URI, with an appended suffix, if provided. + * @param suffix An image tag or an image digest. + * @private + */ + private repositoryUriWithSuffix(suffix?: string): string { const parts = this.stack.parseArn(this.repositoryArn); - return `${parts.account}.dkr.ecr.${parts.region}.${this.stack.urlSuffix}/${this.repositoryName}${tagSuffix}`; + return `${parts.account}.dkr.ecr.${parts.region}.${this.stack.urlSuffix}/${this.repositoryName}${suffix}`; } /** diff --git a/packages/@aws-cdk/aws-ecs-patterns/README.md b/packages/@aws-cdk/aws-ecs-patterns/README.md index 12f56a2b70102..a1072d47fe700 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/README.md +++ b/packages/@aws-cdk/aws-ecs-patterns/README.md @@ -466,3 +466,41 @@ const scheduledFargateTask = new ScheduledFargateTask(stack, 'ScheduledFargateTa platformVersion: ecs.FargatePlatformVersion.VERSION1_4, }); ``` + +### Use the REMOVE_DEFAULT_DESIRED_COUNT feature flag + +The REMOVE_DEFAULT_DESIRED_COUNT feature flag is used to override the default desiredCount that is autogenerated by the CDK. This will set the desiredCount of any service created by any of the following constructs to be undefined. + +* ApplicationLoadBalancedEc2Service +* ApplicationLoadBalancedFargateService +* NetworkLoadBalancedEc2Service +* NetworkLoadBalancedFargateService +* QueueProcessingEc2Service +* QueueProcessingFargateService + +If a desiredCount is not passed in as input to the above constructs, CloudFormation will either create a new service to start up with a desiredCount of 1, or update an existing service to start up with the same desiredCount as prior to the update. + +To enable the feature flag, ensure that the REMOVE_DEFAULT_DESIRED_COUNT flag within an application stack context is set to true, like so: + +```ts +stack.node.setContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT, true); +``` + +The following is an example of an application with the REMOVE_DEFAULT_DESIRED_COUNT feature flag enabled: + +```ts +const app = new App(); + +const stack = new Stack(app, 'aws-ecs-patterns-queue'); +stack.node.setContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT, true); + +const vpc = new ec2.Vpc(stack, 'VPC', { + maxAzs: 2, +}); + +new QueueProcessingFargateService(stack, 'QueueProcessingService', { + vpc, + memoryLimitMiB: 512, + image: new ecs.AssetImage(path.join(__dirname, '..', 'sqs-reader')), +}); +``` diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/base/application-load-balanced-service-base.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/base/application-load-balanced-service-base.ts index 74411bb217558..dde07a16e114c 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/base/application-load-balanced-service-base.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/base/application-load-balanced-service-base.ts @@ -78,7 +78,9 @@ export interface ApplicationLoadBalancedServiceBaseProps { * The desired number of instantiations of the task definition to keep running on the service. * The minimum value is 1 * - * @default 1 + * @default - If the feature flag, ECS_REMOVE_DEFAULT_DESIRED_COUNT is false, the default is 1; + * if true, the default is 1 for all new services and uses the existing services desired count + * when updating an existing service. */ readonly desiredCount?: number; @@ -311,12 +313,19 @@ export interface ApplicationLoadBalancedTaskImageOptions { * The base class for ApplicationLoadBalancedEc2Service and ApplicationLoadBalancedFargateService services. */ export abstract class ApplicationLoadBalancedServiceBase extends CoreConstruct { - /** * The desired number of instantiations of the task definition to keep running on the service. + * @deprecated - Use `internalDesiredCount` instead. */ public readonly desiredCount: number; + /** + * The desired number of instantiations of the task definition to keep running on the service. + * The default is 1 for all new services and uses the existing services desired count + * when updating an existing service if one is not provided. + */ + public readonly internalDesiredCount?: number; + /** * The Application Load Balancer for the service. */ @@ -368,7 +377,9 @@ export abstract class ApplicationLoadBalancedServiceBase extends CoreConstruct { if (props.desiredCount !== undefined && props.desiredCount < 1) { throw new Error('You must specify a desiredCount greater than 0'); } + this.desiredCount = props.desiredCount || 1; + this.internalDesiredCount = props.desiredCount; const internetFacing = props.publicLoadBalancer ?? true; diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/base/application-multiple-target-groups-service-base.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/base/application-multiple-target-groups-service-base.ts index f3eb132e934ae..ff3fc675fcd69 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/base/application-multiple-target-groups-service-base.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/base/application-multiple-target-groups-service-base.ts @@ -47,7 +47,9 @@ export interface ApplicationMultipleTargetGroupsServiceBaseProps { /** * The desired number of instantiations of the task definition to keep running on the service. * - * @default 1 + * @default - If the feature flag, ECS_REMOVE_DEFAULT_DESIRED_COUNT is false, the default is 1; + * if true, the default is 1 for all new services and uses the existing services desired count + * when updating an existing service. */ readonly desiredCount?: number; @@ -329,12 +331,19 @@ export interface ApplicationListenerProps { * The base class for ApplicationMultipleTargetGroupsEc2Service and ApplicationMultipleTargetGroupsFargateService classes. */ export abstract class ApplicationMultipleTargetGroupsServiceBase extends CoreConstruct { - /** * The desired number of instantiations of the task definition to keep running on the service. + * @deprecated - Use `internalDesiredCount` instead. */ public readonly desiredCount: number; + /** + * The desired number of instantiations of the task definition to keep running on the service. + * The default is 1 for all new services and uses the existing services desired count + * when updating an existing service, if one is not provided. + */ + public readonly internalDesiredCount?: number; + /** * The default Application Load Balancer for the service (first added load balancer). */ @@ -365,7 +374,10 @@ export abstract class ApplicationMultipleTargetGroupsServiceBase extends CoreCon this.validateInput(props); this.cluster = props.cluster || this.getDefaultCluster(this, props.vpc); + this.desiredCount = props.desiredCount || 1; + this.internalDesiredCount = props.desiredCount; + if (props.taskImageOptions) { this.logDriver = this.createLogDriver(props.taskImageOptions.enableLogging, props.taskImageOptions.logDriver); } diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/base/network-load-balanced-service-base.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/base/network-load-balanced-service-base.ts index 656cc19d19d43..949b052aebbad 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/base/network-load-balanced-service-base.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/base/network-load-balanced-service-base.ts @@ -67,7 +67,9 @@ export interface NetworkLoadBalancedServiceBaseProps { * The desired number of instantiations of the task definition to keep running on the service. * The minimum value is 1 * - * @default 1 + * @default - If the feature flag, ECS_REMOVE_DEFAULT_DESIRED_COUNT is false, the default is 1; + * if true, the default is 1 for all new services and uses the existing services desired count + * when updating an existing service. */ readonly desiredCount?: number; @@ -263,9 +265,17 @@ export interface NetworkLoadBalancedTaskImageOptions { export abstract class NetworkLoadBalancedServiceBase extends CoreConstruct { /** * The desired number of instantiations of the task definition to keep running on the service. + * @deprecated - Use `internalDesiredCount` instead. */ public readonly desiredCount: number; + /** + * The desired number of instantiations of the task definition to keep running on the service. + * The default is 1 for all new services and uses the existing services desired count + * when updating an existing service, if one is not provided. + */ + public readonly internalDesiredCount?: number; + /** * The Network Load Balancer for the service. */ @@ -306,7 +316,9 @@ export abstract class NetworkLoadBalancedServiceBase extends CoreConstruct { if (props.desiredCount !== undefined && props.desiredCount < 1) { throw new Error('You must specify a desiredCount greater than 0'); } + this.desiredCount = props.desiredCount || 1; + this.internalDesiredCount = props.desiredCount; const internetFacing = props.publicLoadBalancer ?? true; diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/base/network-multiple-target-groups-service-base.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/base/network-multiple-target-groups-service-base.ts index d34a6b548076d..60fd9904b8078 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/base/network-multiple-target-groups-service-base.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/base/network-multiple-target-groups-service-base.ts @@ -45,7 +45,9 @@ export interface NetworkMultipleTargetGroupsServiceBaseProps { * The desired number of instantiations of the task definition to keep running on the service. * The minimum value is 1 * - * @default 1 + * @default - If the feature flag, ECS_REMOVE_DEFAULT_DESIRED_COUNT is false, the default is 1; + * if true, the default is 1 for all new services and uses the existing services desired count + * when updating an existing service. */ readonly desiredCount?: number; @@ -264,9 +266,17 @@ export interface NetworkTargetProps { export abstract class NetworkMultipleTargetGroupsServiceBase extends CoreConstruct { /** * The desired number of instantiations of the task definition to keep running on the service. + * @deprecated - Use `internalDesiredCount` instead. */ public readonly desiredCount: number; + /** + * The desired number of instantiations of the task definition to keep running on the service. + * The default is 1 for all new services and uses the existing services desired count + * when updating an existing service, if one is not provided. + */ + public readonly internalDesiredCount?: number; + /** * The Network Load Balancer for the service. */ @@ -297,7 +307,10 @@ export abstract class NetworkMultipleTargetGroupsServiceBase extends CoreConstru this.validateInput(props); this.cluster = props.cluster || this.getDefaultCluster(this, props.vpc); + this.desiredCount = props.desiredCount || 1; + this.internalDesiredCount = props.desiredCount; + if (props.taskImageOptions) { this.logDriver = this.createLogDriver(props.taskImageOptions.enableLogging, props.taskImageOptions.logDriver); } diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/base/queue-processing-service-base.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/base/queue-processing-service-base.ts index 3248514931f4d..2f72c6345c469 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/base/queue-processing-service-base.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/base/queue-processing-service-base.ts @@ -3,6 +3,7 @@ import { IVpc } from '@aws-cdk/aws-ec2'; import { AwsLogDriver, BaseService, Cluster, ContainerImage, DeploymentController, ICluster, LogDriver, PropagatedTagSource, Secret } from '@aws-cdk/aws-ecs'; import { IQueue, Queue } from '@aws-cdk/aws-sqs'; import { CfnOutput, Duration, Stack } from '@aws-cdk/core'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; // v2 - keep this import as a separate section to reduce merge conflict when forward merging with the v2 branch. @@ -53,7 +54,10 @@ export interface QueueProcessingServiceBaseProps { /** * The desired number of instantiations of the task definition to keep running on the service. * - * @default 1 + * @default - If the feature flag, ECS_REMOVE_DEFAULT_DESIRED_COUNT is false, the default is 1; + * if true, the minScalingCapacity is 1 for all new services and uses the existing services desired count + * when updating an existing service. + * @deprecated - Use `minScalingCapacity` or a literal object instead. */ readonly desiredTaskCount?: number; @@ -109,10 +113,17 @@ export interface QueueProcessingServiceBaseProps { /** * Maximum capacity to scale to. * - * @default (desiredTaskCount * 2) + * @default - If the feature flag, ECS_REMOVE_DEFAULT_DESIRED_COUNT is false, the default is (desiredTaskCount * 2); if true, the default is 2. */ readonly maxScalingCapacity?: number + /** + * Minimum capacity to scale to. + * + * @default - If the feature flag, ECS_REMOVE_DEFAULT_DESIRED_COUNT is false, the default is the desiredTaskCount; if true, the default is 1. + */ + readonly minScalingCapacity?: number + /** * The intervals for scaling based on the SQS queue's ApproximateNumberOfMessagesVisible metric. * @@ -214,6 +225,7 @@ export abstract class QueueProcessingServiceBase extends CoreConstruct { /** * The minimum number of tasks to run. + * @deprecated - Use `minCapacity` instead. */ public readonly desiredCount: number; @@ -222,6 +234,11 @@ export abstract class QueueProcessingServiceBase extends CoreConstruct { */ public readonly maxCapacity: number; + /** + * The minimum number of instances for autoscaling to scale down to. + */ + public readonly minCapacity: number; + /** * The scaling interval for autoscaling based off an SQS Queue size. */ @@ -272,9 +289,21 @@ export abstract class QueueProcessingServiceBase extends CoreConstruct { this.environment = { ...(props.environment || {}), QUEUE_NAME: this.sqsQueue.queueName }; this.secrets = props.secrets; - // Determine the desired task count (minimum) and maximum scaling capacity this.desiredCount = props.desiredTaskCount ?? 1; - this.maxCapacity = props.maxScalingCapacity || (2 * this.desiredCount); + + // Determine the desired task count (minimum) and maximum scaling capacity + if (!this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT)) { + this.minCapacity = props.minScalingCapacity || this.desiredCount; + this.maxCapacity = props.maxScalingCapacity || (2 * this.desiredCount); + } else { + if (props.desiredTaskCount != null) { + this.minCapacity = props.minScalingCapacity || this.desiredCount; + this.maxCapacity = props.maxScalingCapacity || (2 * this.desiredCount); + } else { + this.minCapacity = props.minScalingCapacity || 1; + this.maxCapacity = props.maxScalingCapacity || 2; + } + } if (!this.desiredCount && !this.maxCapacity) { throw new Error('maxScalingCapacity must be set and greater than 0 if desiredCount is 0'); @@ -290,7 +319,7 @@ export abstract class QueueProcessingServiceBase extends CoreConstruct { * @param service the ECS/Fargate service for which to apply the autoscaling rules to */ protected configureAutoscalingForService(service: BaseService) { - const scalingTarget = service.autoScaleTaskCount({ maxCapacity: this.maxCapacity, minCapacity: this.desiredCount }); + const scalingTarget = service.autoScaleTaskCount({ maxCapacity: this.maxCapacity, minCapacity: this.minCapacity }); scalingTarget.scaleOnCpuUtilization('CpuScaling', { targetUtilizationPercent: 50, }); diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/application-load-balanced-ecs-service.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/application-load-balanced-ecs-service.ts index b9bdcf2d100ad..2915fce6a48ff 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/application-load-balanced-ecs-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/application-load-balanced-ecs-service.ts @@ -1,4 +1,5 @@ import { Ec2Service, Ec2TaskDefinition } from '@aws-cdk/aws-ecs'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { ApplicationLoadBalancedServiceBase, ApplicationLoadBalancedServiceBaseProps } from '../base/application-load-balanced-service-base'; @@ -117,9 +118,11 @@ export class ApplicationLoadBalancedEc2Service extends ApplicationLoadBalancedSe throw new Error('You must specify one of: taskDefinition or image'); } + const desiredCount = this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT) ? this.internalDesiredCount : this.desiredCount; + this.service = new Ec2Service(this, 'Service', { cluster: this.cluster, - desiredCount: this.desiredCount, + desiredCount: desiredCount, taskDefinition: this.taskDefinition, assignPublicIp: false, serviceName: props.serviceName, diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/application-multiple-target-groups-ecs-service.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/application-multiple-target-groups-ecs-service.ts index 6ed6b6b71802f..90f4afdd5fa8f 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/application-multiple-target-groups-ecs-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/application-multiple-target-groups-ecs-service.ts @@ -1,5 +1,6 @@ import { Ec2Service, Ec2TaskDefinition } from '@aws-cdk/aws-ecs'; import { ApplicationTargetGroup } from '@aws-cdk/aws-elasticloadbalancingv2'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { ApplicationMultipleTargetGroupsServiceBase, @@ -136,9 +137,11 @@ export class ApplicationMultipleTargetGroupsEc2Service extends ApplicationMultip } private createEc2Service(props: ApplicationMultipleTargetGroupsEc2ServiceProps): Ec2Service { + const desiredCount = this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT) ? this.internalDesiredCount : this.desiredCount; + return new Ec2Service(this, 'Service', { cluster: this.cluster, - desiredCount: this.desiredCount, + desiredCount: desiredCount, taskDefinition: this.taskDefinition, assignPublicIp: false, serviceName: props.serviceName, diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/network-load-balanced-ecs-service.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/network-load-balanced-ecs-service.ts index fae46b68e7380..881a346c74f8a 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/network-load-balanced-ecs-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/network-load-balanced-ecs-service.ts @@ -1,4 +1,5 @@ import { Ec2Service, Ec2TaskDefinition } from '@aws-cdk/aws-ecs'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { NetworkLoadBalancedServiceBase, NetworkLoadBalancedServiceBaseProps } from '../base/network-load-balanced-service-base'; @@ -115,9 +116,11 @@ export class NetworkLoadBalancedEc2Service extends NetworkLoadBalancedServiceBas throw new Error('You must specify one of: taskDefinition or image'); } + const desiredCount = this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT) ? this.internalDesiredCount : this.desiredCount; + this.service = new Ec2Service(this, 'Service', { cluster: this.cluster, - desiredCount: this.desiredCount, + desiredCount: desiredCount, taskDefinition: this.taskDefinition, assignPublicIp: false, serviceName: props.serviceName, diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/network-multiple-target-groups-ecs-service.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/network-multiple-target-groups-ecs-service.ts index eb8392d3b2148..f0d3b0a1571ce 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/network-multiple-target-groups-ecs-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/network-multiple-target-groups-ecs-service.ts @@ -1,5 +1,6 @@ import { Ec2Service, Ec2TaskDefinition } from '@aws-cdk/aws-ecs'; import { NetworkTargetGroup } from '@aws-cdk/aws-elasticloadbalancingv2'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { NetworkMultipleTargetGroupsServiceBase, @@ -136,9 +137,11 @@ export class NetworkMultipleTargetGroupsEc2Service extends NetworkMultipleTarget } private createEc2Service(props: NetworkMultipleTargetGroupsEc2ServiceProps): Ec2Service { + const desiredCount = this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT) ? this.internalDesiredCount : this.desiredCount; + return new Ec2Service(this, 'Service', { cluster: this.cluster, - desiredCount: this.desiredCount, + desiredCount: desiredCount, taskDefinition: this.taskDefinition, assignPublicIp: false, serviceName: props.serviceName, diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/queue-processing-ecs-service.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/queue-processing-ecs-service.ts index f9d9b98810aa0..6858813dfa9cc 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/queue-processing-ecs-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/ecs/queue-processing-ecs-service.ts @@ -1,4 +1,5 @@ import { Ec2Service, Ec2TaskDefinition } from '@aws-cdk/aws-ecs'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { QueueProcessingServiceBase, QueueProcessingServiceBaseProps } from '../base/queue-processing-service-base'; @@ -98,11 +99,14 @@ export class QueueProcessingEc2Service extends QueueProcessingServiceBase { logging: this.logDriver, }); + // The desiredCount should be removed from the fargate service when the feature flag is removed. + const desiredCount = this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT) ? undefined : this.desiredCount; + // Create an ECS service with the previously defined Task Definition and configure // autoscaling based on cpu utilization and number of messages visible in the SQS queue. this.service = new Ec2Service(this, 'QueueProcessingService', { cluster: this.cluster, - desiredCount: this.desiredCount, + desiredCount: desiredCount, taskDefinition: this.taskDefinition, serviceName: props.serviceName, minHealthyPercent: props.minHealthyPercent, @@ -111,6 +115,7 @@ export class QueueProcessingEc2Service extends QueueProcessingServiceBase { enableECSManagedTags: props.enableECSManagedTags, deploymentController: props.deploymentController, }); + this.configureAutoscalingForService(this.service); this.grantPermissionsToService(this.service); } diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/application-load-balanced-fargate-service.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/application-load-balanced-fargate-service.ts index 2ae468bcae558..fbb68aef84b2f 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/application-load-balanced-fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/application-load-balanced-fargate-service.ts @@ -1,5 +1,6 @@ import { ISecurityGroup, SubnetSelection } from '@aws-cdk/aws-ec2'; import { FargatePlatformVersion, FargateService, FargateTaskDefinition } from '@aws-cdk/aws-ecs'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { ApplicationLoadBalancedServiceBase, ApplicationLoadBalancedServiceBaseProps } from '../base/application-load-balanced-service-base'; @@ -153,9 +154,11 @@ export class ApplicationLoadBalancedFargateService extends ApplicationLoadBalanc throw new Error('You must specify one of: taskDefinition or image'); } + const desiredCount = this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT) ? this.internalDesiredCount : this.desiredCount; + this.service = new FargateService(this, 'Service', { cluster: this.cluster, - desiredCount: this.desiredCount, + desiredCount: desiredCount, taskDefinition: this.taskDefinition, assignPublicIp: this.assignPublicIp, serviceName: props.serviceName, diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/application-multiple-target-groups-fargate-service.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/application-multiple-target-groups-fargate-service.ts index 495049dfccfa8..6759e8e001376 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/application-multiple-target-groups-fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/application-multiple-target-groups-fargate-service.ts @@ -1,5 +1,6 @@ import { FargatePlatformVersion, FargateService, FargateTaskDefinition } from '@aws-cdk/aws-ecs'; import { ApplicationTargetGroup } from '@aws-cdk/aws-elasticloadbalancingv2'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { ApplicationMultipleTargetGroupsServiceBase, @@ -168,9 +169,11 @@ export class ApplicationMultipleTargetGroupsFargateService extends ApplicationMu } private createFargateService(props: ApplicationMultipleTargetGroupsFargateServiceProps): FargateService { + const desiredCount = this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT) ? this.internalDesiredCount : this.desiredCount; + return new FargateService(this, 'Service', { cluster: this.cluster, - desiredCount: this.desiredCount, + desiredCount: desiredCount, taskDefinition: this.taskDefinition, assignPublicIp: this.assignPublicIp, serviceName: props.serviceName, diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/network-load-balanced-fargate-service.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/network-load-balanced-fargate-service.ts index 4aad4b31e7efe..404d5429acfed 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/network-load-balanced-fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/network-load-balanced-fargate-service.ts @@ -1,5 +1,6 @@ import { SubnetSelection } from '@aws-cdk/aws-ec2'; import { FargatePlatformVersion, FargateService, FargateTaskDefinition } from '@aws-cdk/aws-ecs'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { NetworkLoadBalancedServiceBase, NetworkLoadBalancedServiceBaseProps } from '../base/network-load-balanced-service-base'; @@ -140,9 +141,11 @@ export class NetworkLoadBalancedFargateService extends NetworkLoadBalancedServic throw new Error('You must specify one of: taskDefinition or image'); } + const desiredCount = this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT) ? this.internalDesiredCount : this.desiredCount; + this.service = new FargateService(this, 'Service', { cluster: this.cluster, - desiredCount: this.desiredCount, + desiredCount: desiredCount, taskDefinition: this.taskDefinition, assignPublicIp: this.assignPublicIp, serviceName: props.serviceName, diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/network-multiple-target-groups-fargate-service.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/network-multiple-target-groups-fargate-service.ts index dab033b1938ce..4a4974af7cce3 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/network-multiple-target-groups-fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/network-multiple-target-groups-fargate-service.ts @@ -1,5 +1,6 @@ import { FargatePlatformVersion, FargateService, FargateTaskDefinition } from '@aws-cdk/aws-ecs'; import { NetworkTargetGroup } from '@aws-cdk/aws-elasticloadbalancingv2'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { NetworkMultipleTargetGroupsServiceBase, @@ -168,9 +169,11 @@ export class NetworkMultipleTargetGroupsFargateService extends NetworkMultipleTa } private createFargateService(props: NetworkMultipleTargetGroupsFargateServiceProps): FargateService { + const desiredCount = this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT) ? this.internalDesiredCount : this.desiredCount; + return new FargateService(this, 'Service', { cluster: this.cluster, - desiredCount: this.desiredCount, + desiredCount: desiredCount, taskDefinition: this.taskDefinition, assignPublicIp: this.assignPublicIp, serviceName: props.serviceName, diff --git a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/queue-processing-fargate-service.ts b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/queue-processing-fargate-service.ts index e3712b00ae4b1..6444d05f81da6 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/queue-processing-fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/lib/fargate/queue-processing-fargate-service.ts @@ -1,5 +1,6 @@ import * as ec2 from '@aws-cdk/aws-ec2'; import { FargatePlatformVersion, FargateService, FargateTaskDefinition } from '@aws-cdk/aws-ecs'; +import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import { QueueProcessingServiceBase, QueueProcessingServiceBaseProps } from '../base/queue-processing-service-base'; @@ -128,11 +129,14 @@ export class QueueProcessingFargateService extends QueueProcessingServiceBase { logging: this.logDriver, }); + // The desiredCount should be removed from the fargate service when the feature flag is removed. + const desiredCount = this.node.tryGetContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT) ? undefined : this.desiredCount; + // Create a Fargate service with the previously defined Task Definition and configure // autoscaling based on cpu utilization and number of messages visible in the SQS queue. this.service = new FargateService(this, 'QueueProcessingFargateService', { cluster: this.cluster, - desiredCount: this.desiredCount, + desiredCount: desiredCount, taskDefinition: this.taskDefinition, serviceName: props.serviceName, minHealthyPercent: props.minHealthyPercent, @@ -145,6 +149,7 @@ export class QueueProcessingFargateService extends QueueProcessingServiceBase { vpcSubnets: props.taskSubnets, assignPublicIp: props.assignPublicIp, }); + this.configureAutoscalingForService(this.service); this.grantPermissionsToService(this.service); } diff --git a/packages/@aws-cdk/aws-ecs-patterns/package.json b/packages/@aws-cdk/aws-ecs-patterns/package.json index bc2a816cd6b11..dcb4d8b436bda 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/package.json +++ b/packages/@aws-cdk/aws-ecs-patterns/package.json @@ -86,6 +86,7 @@ "@aws-cdk/aws-servicediscovery": "0.0.0", "@aws-cdk/aws-sqs": "0.0.0", "@aws-cdk/core": "0.0.0", + "@aws-cdk/cx-api": "0.0.0", "constructs": "^3.2.0" }, "homepage": "https://github.com/aws/aws-cdk", @@ -103,6 +104,7 @@ "@aws-cdk/aws-servicediscovery": "0.0.0", "@aws-cdk/aws-sqs": "0.0.0", "@aws-cdk/core": "0.0.0", + "@aws-cdk/cx-api": "0.0.0", "constructs": "^3.2.0" }, "engines": { diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/ec2/integ.multiple-application-load-balanced-ecs-service.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/ec2/integ.multiple-application-load-balanced-ecs-service.expected.json index 147a8bc8a7bfb..ec9686e054019 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/ec2/integ.multiple-application-load-balanced-ecs-service.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/ec2/integ.multiple-application-load-balanced-ecs-service.expected.json @@ -719,14 +719,12 @@ "Code": { "ZipFile": "import boto3, json, os, time\n\necs = boto3.client('ecs')\nautoscaling = boto3.client('autoscaling')\n\n\ndef lambda_handler(event, context):\n print(json.dumps(event))\n cluster = os.environ['CLUSTER']\n snsTopicArn = event['Records'][0]['Sns']['TopicArn']\n lifecycle_event = json.loads(event['Records'][0]['Sns']['Message'])\n instance_id = lifecycle_event.get('EC2InstanceId')\n if not instance_id:\n print('Got event without EC2InstanceId: %s', json.dumps(event))\n return\n\n instance_arn = container_instance_arn(cluster, instance_id)\n print('Instance %s has container instance ARN %s' % (lifecycle_event['EC2InstanceId'], instance_arn))\n\n if not instance_arn:\n return\n\n while has_tasks(cluster, instance_arn):\n time.sleep(10)\n\n try:\n print('Terminating instance %s' % instance_id)\n autoscaling.complete_lifecycle_action(\n LifecycleActionResult='CONTINUE',\n **pick(lifecycle_event, 'LifecycleHookName', 'LifecycleActionToken', 'AutoScalingGroupName'))\n except Exception as e:\n # Lifecycle action may have already completed.\n print(str(e))\n\n\ndef container_instance_arn(cluster, instance_id):\n \"\"\"Turn an instance ID into a container instance ARN.\"\"\"\n arns = ecs.list_container_instances(cluster=cluster, filter='ec2InstanceId==' + instance_id)['containerInstanceArns']\n if not arns:\n return None\n return arns[0]\n\n\ndef has_tasks(cluster, instance_arn):\n \"\"\"Return True if the instance is running tasks for the given cluster.\"\"\"\n instances = ecs.describe_container_instances(cluster=cluster, containerInstances=[instance_arn])['containerInstances']\n if not instances:\n return False\n instance = instances[0]\n\n if instance['status'] == 'ACTIVE':\n # Start draining, then try again later\n set_container_instance_to_draining(cluster, instance_arn)\n return True\n\n tasks = instance['runningTasksCount'] + instance['pendingTasksCount']\n print('Instance %s has %s tasks' % (instance_arn, tasks))\n\n return tasks > 0\n\n\ndef set_container_instance_to_draining(cluster, instance_arn):\n ecs.update_container_instances_state(\n cluster=cluster,\n containerInstances=[instance_arn], status='DRAINING')\n\n\ndef pick(dct, *keys):\n \"\"\"Pick a subset of a dict.\"\"\"\n return {k: v for k, v in dct.items() if k in keys}\n" }, - "Handler": "index.lambda_handler", "Role": { "Fn::GetAtt": [ "ClusterDefaultAutoScalingGroupDrainECSHookFunctionServiceRole2AC250B1", "Arn" ] }, - "Runtime": "python3.6", "Environment": { "Variables": { "CLUSTER": { @@ -734,6 +732,8 @@ } } }, + "Handler": "index.lambda_handler", + "Runtime": "python3.6", "Tags": [ { "Key": "Name", @@ -1128,7 +1128,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "EC2", diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/ec2/integ.scheduled-ecs-task.lit.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/ec2/integ.scheduled-ecs-task.lit.expected.json index 5d4345573b0c5..53c05f9aeba8e 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/ec2/integ.scheduled-ecs-task.lit.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/ec2/integ.scheduled-ecs-task.lit.expected.json @@ -536,14 +536,12 @@ "Code": { "ZipFile": "import boto3, json, os, time\n\necs = boto3.client('ecs')\nautoscaling = boto3.client('autoscaling')\n\n\ndef lambda_handler(event, context):\n print(json.dumps(event))\n cluster = os.environ['CLUSTER']\n snsTopicArn = event['Records'][0]['Sns']['TopicArn']\n lifecycle_event = json.loads(event['Records'][0]['Sns']['Message'])\n instance_id = lifecycle_event.get('EC2InstanceId')\n if not instance_id:\n print('Got event without EC2InstanceId: %s', json.dumps(event))\n return\n\n instance_arn = container_instance_arn(cluster, instance_id)\n print('Instance %s has container instance ARN %s' % (lifecycle_event['EC2InstanceId'], instance_arn))\n\n if not instance_arn:\n return\n\n while has_tasks(cluster, instance_arn):\n time.sleep(10)\n\n try:\n print('Terminating instance %s' % instance_id)\n autoscaling.complete_lifecycle_action(\n LifecycleActionResult='CONTINUE',\n **pick(lifecycle_event, 'LifecycleHookName', 'LifecycleActionToken', 'AutoScalingGroupName'))\n except Exception as e:\n # Lifecycle action may have already completed.\n print(str(e))\n\n\ndef container_instance_arn(cluster, instance_id):\n \"\"\"Turn an instance ID into a container instance ARN.\"\"\"\n arns = ecs.list_container_instances(cluster=cluster, filter='ec2InstanceId==' + instance_id)['containerInstanceArns']\n if not arns:\n return None\n return arns[0]\n\n\ndef has_tasks(cluster, instance_arn):\n \"\"\"Return True if the instance is running tasks for the given cluster.\"\"\"\n instances = ecs.describe_container_instances(cluster=cluster, containerInstances=[instance_arn])['containerInstances']\n if not instances:\n return False\n instance = instances[0]\n\n if instance['status'] == 'ACTIVE':\n # Start draining, then try again later\n set_container_instance_to_draining(cluster, instance_arn)\n return True\n\n tasks = instance['runningTasksCount'] + instance['pendingTasksCount']\n print('Instance %s has %s tasks' % (instance_arn, tasks))\n\n return tasks > 0\n\n\ndef set_container_instance_to_draining(cluster, instance_arn):\n ecs.update_container_instances_state(\n cluster=cluster,\n containerInstances=[instance_arn], status='DRAINING')\n\n\ndef pick(dct, *keys):\n \"\"\"Pick a subset of a dict.\"\"\"\n return {k: v for k, v in dct.items() if k in keys}\n" }, - "Handler": "index.lambda_handler", "Role": { "Fn::GetAtt": [ "EcsClusterDefaultAutoScalingGroupDrainECSHookFunctionServiceRole94543EDA", "Arn" ] }, - "Runtime": "python3.6", "Environment": { "Variables": { "CLUSTER": { @@ -551,6 +549,8 @@ } } }, + "Handler": "index.lambda_handler", + "Runtime": "python3.6", "Tags": [ { "Key": "Name", diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/ec2/test.l3s.ts b/packages/@aws-cdk/aws-ecs-patterns/test/ec2/test.l3s.ts index 959a6348fde11..6f5a4aa0b3337 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/ec2/test.l3s.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/test/ec2/test.l3s.ts @@ -1,4 +1,4 @@ -import { arrayWith, expect, haveResource, haveResourceLike, objectLike } from '@aws-cdk/assert'; +import { ABSENT, arrayWith, expect, haveResource, haveResourceLike, objectLike } from '@aws-cdk/assert'; import { Certificate } from '@aws-cdk/aws-certificatemanager'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecs from '@aws-cdk/aws-ecs'; @@ -6,6 +6,7 @@ import { ApplicationLoadBalancer, ApplicationProtocol, NetworkLoadBalancer } fro import { PublicHostedZone } from '@aws-cdk/aws-route53'; import * as cloudmap from '@aws-cdk/aws-servicediscovery'; import * as cdk from '@aws-cdk/core'; +import * as cxapi from '@aws-cdk/cx-api'; import { Test } from 'nodeunit'; import * as ecsPatterns from '../../lib'; @@ -60,6 +61,102 @@ export = { test.done(); }, + 'ApplicationLoadBalancedEc2Service desiredCount can be undefined when feature flag is set'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + stack.node.setContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT, true); + + const vpc = new ec2.Vpc(stack, 'VPC'); + const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + + // WHEN + new ecsPatterns.ApplicationLoadBalancedEc2Service(stack, 'Service', { + cluster, + memoryLimitMiB: 1024, + taskImageOptions: { + image: ecs.ContainerImage.fromRegistry('test'), + }, + }); + + expect(stack).to(haveResource('AWS::ECS::Service', { + DesiredCount: ABSENT, + })); + + test.done(); + }, + + 'ApplicationLoadBalancedFargateService desiredCount can be undefined when feature flag is set'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + stack.node.setContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT, true); + + const vpc = new ec2.Vpc(stack, 'VPC'); + const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + + // WHEN + new ecsPatterns.ApplicationLoadBalancedFargateService(stack, 'Service', { + cluster, + taskImageOptions: { + image: ecs.ContainerImage.fromRegistry('test'), + }, + }); + + expect(stack).to(haveResource('AWS::ECS::Service', { + DesiredCount: ABSENT, + })); + + test.done(); + }, + + 'NetworkLoadBalancedEc2Service desiredCount can be undefined when feature flag is set'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + stack.node.setContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT, true); + + const vpc = new ec2.Vpc(stack, 'VPC'); + const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + + // WHEN + new ecsPatterns.NetworkLoadBalancedEc2Service(stack, 'Service', { + cluster, + memoryLimitMiB: 1024, + taskImageOptions: { + image: ecs.ContainerImage.fromRegistry('test'), + }, + }); + + expect(stack).to(haveResource('AWS::ECS::Service', { + DesiredCount: ABSENT, + })); + + test.done(); + }, + + 'NetworkLoadBalancedFargateService desiredCount can be undefined when feature flag is set'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + stack.node.setContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT, true); + + const vpc = new ec2.Vpc(stack, 'VPC'); + const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + + // WHEN + new ecsPatterns.NetworkLoadBalancedFargateService(stack, 'Service', { + cluster, + taskImageOptions: { + image: ecs.ContainerImage.fromRegistry('test'), + }, + }); + + expect(stack).to(haveResource('AWS::ECS::Service', { + DesiredCount: ABSENT, + })); + + test.done(); + }, + 'set vpc instead of cluster'(test: Test) { // GIVEN const stack = new cdk.Stack(); diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/ec2/test.queue-processing-ecs-service.ts b/packages/@aws-cdk/aws-ecs-patterns/test/ec2/test.queue-processing-ecs-service.ts index d449cc27db2c4..2c410f6581b1e 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/ec2/test.queue-processing-ecs-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/test/ec2/test.queue-processing-ecs-service.ts @@ -1,8 +1,9 @@ -import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; +import { ABSENT, expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecs from '@aws-cdk/aws-ecs'; import * as sqs from '@aws-cdk/aws-sqs'; import * as cdk from '@aws-cdk/core'; +import * as cxapi from '@aws-cdk/cx-api'; import { Test } from 'nodeunit'; import * as ecsPatterns from '../../lib'; @@ -80,6 +81,31 @@ export = { test.done(); }, + 'test ECS queue worker service construct - with remove default desiredCount feature flag'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + stack.node.setContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT, true); + + const vpc = new ec2.Vpc(stack, 'VPC'); + const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + + // WHEN + new ecsPatterns.QueueProcessingEc2Service(stack, 'Service', { + cluster, + memoryLimitMiB: 512, + image: ecs.ContainerImage.fromRegistry('test'), + }); + + // THEN - QueueWorker is of EC2 launch type, and desiredCount is not defined on the Ec2Service. + expect(stack).to(haveResource('AWS::ECS::Service', { + DesiredCount: ABSENT, + LaunchType: 'EC2', + })); + + test.done(); + }, + 'test ECS queue worker service construct - with optional props for queues'(test: Test) { // GIVEN const stack = new cdk.Stack(); diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.asset-image.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.asset-image.expected.json index 364f0a27d8b15..4fdde5753fdf6 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.asset-image.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.asset-image.expected.json @@ -637,7 +637,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", @@ -752,4 +751,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.executionrole.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.executionrole.expected.json index 2d70c2c40cae2..93c7a7307a6ee 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.executionrole.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.executionrole.expected.json @@ -589,7 +589,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3-autocreate.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3-autocreate.expected.json index 84a73622ee2d8..5813cd78e41f3 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3-autocreate.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3-autocreate.expected.json @@ -228,7 +228,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", @@ -894,7 +893,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3-vpconly.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3-vpconly.expected.json index c549746d8769e..5556df70a59b7 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3-vpconly.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3-vpconly.expected.json @@ -583,7 +583,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", @@ -1249,7 +1248,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", @@ -1560,7 +1558,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3.expected.json index c79a622e159a9..40d86be5d331b 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.l3.expected.json @@ -586,7 +586,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.load-balanced-fargate-service.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.load-balanced-fargate-service.expected.json index 532377e2accdd..1d670f79f58a6 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.load-balanced-fargate-service.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.load-balanced-fargate-service.expected.json @@ -664,7 +664,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": true, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.multiple-network-load-balanced-fargate-service.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.multiple-network-load-balanced-fargate-service.expected.json index 815cfe99b94f6..2cdd6991792e8 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.multiple-network-load-balanced-fargate-service.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.multiple-network-load-balanced-fargate-service.expected.json @@ -597,7 +597,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service-isolated.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service-isolated.expected.json index b1d88ed107154..6124fba473584 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service-isolated.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service-isolated.expected.json @@ -903,7 +903,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "LaunchType": "FARGATE", "NetworkConfiguration": { diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service-public.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service-public.expected.json index fd8f791e3a868..de873260aa209 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service-public.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service-public.expected.json @@ -753,7 +753,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "LaunchType": "FARGATE", "NetworkConfiguration": { diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service.expected.json index 889eeefcd985e..6fbcb79ecfa00 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.queue-processing-fargate-service.expected.json @@ -594,7 +594,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "LaunchType": "FARGATE", "NetworkConfiguration": { diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.special-listener.expected.json b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.special-listener.expected.json index 76e5e6a13aaf7..5075f6511573b 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.special-listener.expected.json +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/integ.special-listener.expected.json @@ -539,7 +539,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", @@ -826,7 +825,6 @@ "MaximumPercent": 200, "MinimumHealthyPercent": 50 }, - "DesiredCount": 1, "EnableECSManagedTags": false, "HealthCheckGracePeriodSeconds": 60, "LaunchType": "FARGATE", diff --git a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/test.queue-processing-fargate-service.ts b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/test.queue-processing-fargate-service.ts index 9d2d5dd0747a6..27671ae22e70d 100644 --- a/packages/@aws-cdk/aws-ecs-patterns/test/fargate/test.queue-processing-fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs-patterns/test/fargate/test.queue-processing-fargate-service.ts @@ -1,8 +1,9 @@ -import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; +import { ABSENT, expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecs from '@aws-cdk/aws-ecs'; import * as sqs from '@aws-cdk/aws-sqs'; import * as cdk from '@aws-cdk/core'; +import * as cxapi from '@aws-cdk/cx-api'; import { Test } from 'nodeunit'; import * as ecsPatterns from '../../lib'; @@ -102,6 +103,30 @@ export = { test.done(); }, + 'test fargate queue worker service construct - with remove default desiredCount feature flag'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + stack.node.setContext(cxapi.ECS_REMOVE_DEFAULT_DESIRED_COUNT, true); + + const vpc = new ec2.Vpc(stack, 'VPC'); + const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + + // WHEN + new ecsPatterns.QueueProcessingFargateService(stack, 'Service', { + cluster, + memoryLimitMiB: 512, + image: ecs.ContainerImage.fromRegistry('test'), + }); + + // THEN - QueueWorker is of FARGATE launch type, and desiredCount is not defined on the FargateService. + expect(stack).to(haveResource('AWS::ECS::Service', { + DesiredCount: ABSENT, + LaunchType: 'FARGATE', + })); + + test.done(); + }, + 'test fargate queue worker service construct - with optional props for queues'(test: Test) { // GIVEN const stack = new cdk.Stack(); @@ -199,6 +224,97 @@ export = { test.done(); }, + 'test Fargate queue worker service construct - without desiredCount specified'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const cluster = new ecs.Cluster(stack, 'Cluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + const queue = new sqs.Queue(stack, 'fargate-test-queue', { + queueName: 'fargate-test-sqs-queue', + }); + + // WHEN + new ecsPatterns.QueueProcessingFargateService(stack, 'Service', { + cluster, + memoryLimitMiB: 512, + image: ecs.ContainerImage.fromRegistry('test'), + command: ['-c', '4', 'amazon.com'], + enableLogging: false, + environment: { + TEST_ENVIRONMENT_VARIABLE1: 'test environment variable 1 value', + TEST_ENVIRONMENT_VARIABLE2: 'test environment variable 2 value', + }, + queue, + maxScalingCapacity: 5, + minScalingCapacity: 2, + minHealthyPercent: 60, + maxHealthyPercent: 150, + serviceName: 'fargate-test-service', + family: 'fargate-task-family', + platformVersion: ecs.FargatePlatformVersion.VERSION1_4, + deploymentController: { + type: ecs.DeploymentControllerType.CODE_DEPLOY, + }, + }); + + // THEN - QueueWorker is of FARGATE launch type, an SQS queue is created and all optional properties are set. + expect(stack).to(haveResource('AWS::ECS::Service', { + DeploymentConfiguration: { + MinimumHealthyPercent: 60, + MaximumPercent: 150, + }, + LaunchType: 'FARGATE', + ServiceName: 'fargate-test-service', + PlatformVersion: ecs.FargatePlatformVersion.VERSION1_4, + DeploymentController: { + Type: 'CODE_DEPLOY', + }, + })); + + expect(stack).to(haveResource('AWS::ApplicationAutoScaling::ScalableTarget', { + MaxCapacity: 5, + MinCapacity: 2, + })); + + expect(stack).to(haveResource('AWS::SQS::Queue', { QueueName: 'fargate-test-sqs-queue' })); + + expect(stack).to(haveResourceLike('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [ + { + Command: [ + '-c', + '4', + 'amazon.com', + ], + Environment: [ + { + Name: 'TEST_ENVIRONMENT_VARIABLE1', + Value: 'test environment variable 1 value', + }, + { + Name: 'TEST_ENVIRONMENT_VARIABLE2', + Value: 'test environment variable 2 value', + }, + { + Name: 'QUEUE_NAME', + Value: { + 'Fn::GetAtt': [ + 'fargatetestqueue28B43841', + 'QueueName', + ], + }, + }, + ], + Image: 'test', + }, + ], + Family: 'fargate-task-family', + })); + + test.done(); + }, + 'test Fargate queue worker service construct - with optional props'(test: Test) { // GIVEN const stack = new cdk.Stack(); diff --git a/packages/@aws-cdk/aws-ecs/README.md b/packages/@aws-cdk/aws-ecs/README.md index 438f63e14e009..efe5126caf004 100644 --- a/packages/@aws-cdk/aws-ecs/README.md +++ b/packages/@aws-cdk/aws-ecs/README.md @@ -237,23 +237,35 @@ const container = ec2TaskDefinition.addContainer("WebContainer", { You can specify container properties when you add them to the task definition, or with various methods, e.g.: +To add a port mapping when adding a container to the task definition, specify the `portMappings` option: + +```ts +taskDefinition.addContainer("WebContainer", { + image: ecs.ContainerImage.fromRegistry("amazon/amazon-ecs-sample"), + memoryLimitMiB: 1024, + portMappings: [{ containerPort: 3000 }] +}); +``` + +To add port mappings directly to a container definition, call `addPortMappings()`: + ```ts container.addPortMappings({ containerPort: 3000 -}) +}); ``` To add data volumes to a task definition, call `addVolume()`: ```ts -const volume = ecs.Volume("Volume", { +const volume = { // Use an Elastic FileSystem name: "mydatavolume", efsVolumeConfiguration: ecs.EfsVolumeConfiguration({ fileSystemId: "EFS" // ... other options here ... }) -}); +}; const container = fargateTaskDefinition.addVolume("mydatavolume"); ``` diff --git a/packages/@aws-cdk/aws-ecs/lib/container-definition.ts b/packages/@aws-cdk/aws-ecs/lib/container-definition.ts index 983489743482f..9911a49a039cf 100644 --- a/packages/@aws-cdk/aws-ecs/lib/container-definition.ts +++ b/packages/@aws-cdk/aws-ecs/lib/container-definition.ts @@ -288,6 +288,12 @@ export interface ContainerDefinitionOptions { * @default - No GPUs assigned. */ readonly gpuCount?: number; + + /** + * The port mappings to add to the container definition. + * @default - No ports are mapped. + */ + readonly portMappings?: PortMapping[]; } /** @@ -433,6 +439,10 @@ export class ContainerDefinition extends CoreConstruct { } props.taskDefinition._linkContainer(this); + + if (props.portMappings) { + this.addPortMappings(...props.portMappings); + } } /** diff --git a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts index 18c6df350fb4e..4cf4de8a83292 100644 --- a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts @@ -229,9 +229,9 @@ export class Ec2Service extends BaseService implements IEc2Service { this.addPlacementConstraints(...props.placementConstraints || []); this.addPlacementStrategies(...props.placementStrategies || []); - if (!this.taskDefinition.defaultContainer) { - throw new Error('A TaskDefinition must have at least one essential container'); - } + this.node.addValidation({ + validate: () => !this.taskDefinition.defaultContainer ? ['A TaskDefinition must have at least one essential container'] : [], + }); } /** @@ -249,7 +249,7 @@ export class Ec2Service extends BaseService implements IEc2Service { } /** - * Adds one or more placement contstraints to use for tasks in the service. For more information, see + * Adds one or more placement constraints to use for tasks in the service. For more information, see * [Amazon ECS Task Placement Constraints](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-placement-constraints.html). */ public addPlacementConstraints(...constraints: PlacementConstraint[]) { diff --git a/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts b/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts index 1db94fc5286e0..793fb633e83d0 100644 --- a/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts @@ -172,9 +172,9 @@ export class FargateService extends BaseService implements IFargateService { this.configureAwsVpcNetworkingWithSecurityGroups(props.cluster.vpc, props.assignPublicIp, props.vpcSubnets, securityGroups); - if (!props.taskDefinition.defaultContainer) { - throw new Error('A TaskDefinition must have at least one essential container'); - } + this.node.addValidation({ + validate: () => !this.taskDefinition.defaultContainer ? ['A TaskDefinition must have at least one essential container'] : [], + }); } } diff --git a/packages/@aws-cdk/aws-ecs/lib/images/ecr.ts b/packages/@aws-cdk/aws-ecs/lib/images/ecr.ts index e8dc339bf9e82..b9786f13e2816 100644 --- a/packages/@aws-cdk/aws-ecs/lib/images/ecr.ts +++ b/packages/@aws-cdk/aws-ecs/lib/images/ecr.ts @@ -22,10 +22,14 @@ export class EcrImage extends ContainerImage { /** * Constructs a new instance of the EcrImage class. */ - constructor(private readonly repository: ecr.IRepository, private readonly tag: string) { + constructor(private readonly repository: ecr.IRepository, private readonly tagOrDigest: string) { super(); - this.imageName = this.repository.repositoryUriForTag(this.tag); + if (tagOrDigest?.startsWith('sha256:')) { + this.imageName = this.repository.repositoryUriForDigest(this.tagOrDigest); + } else { + this.imageName = this.repository.repositoryUriForTag(this.tagOrDigest); + } } public bind(_scope: CoreConstruct, containerDefinition: ContainerDefinition): ContainerImageConfig { diff --git a/packages/@aws-cdk/aws-ecs/test/container-definition.test.ts b/packages/@aws-cdk/aws-ecs/test/container-definition.test.ts index 0e42a0aecc381..81e9f274160b7 100644 --- a/packages/@aws-cdk/aws-ecs/test/container-definition.test.ts +++ b/packages/@aws-cdk/aws-ecs/test/container-definition.test.ts @@ -703,6 +703,55 @@ describe('container definition', () => { }); + test('can add port mappings to the container definition by props', () => { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef'); + + // WHEN + taskDefinition.addContainer('cont', { + image: ecs.ContainerImage.fromRegistry('test'), + memoryLimitMiB: 1024, + portMappings: [{ containerPort: 80 }], + }); + + // THEN + expect(stack).toHaveResourceLike('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [ + { + PortMappings: [{ ContainerPort: 80 }], + }, + ], + }); + }); + + test('can add port mappings using props and addPortMappings and both are included', () => { + // GIVEN + const stack = new cdk.Stack(); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'TaskDef'); + + // WHEN + const containerDefinition = taskDefinition.addContainer('cont', { + image: ecs.ContainerImage.fromRegistry('test'), + memoryLimitMiB: 1024, + portMappings: [{ containerPort: 80 }], + }); + + containerDefinition.addPortMappings({ containerPort: 443 }); + + // THEN + expect(stack).toHaveResourceLike('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [ + { + PortMappings: [ + { ContainerPort: 80 }, + { ContainerPort: 443 }, + ], + }, + ], + }); + }); + describe('Environment Files', () => { describe('with EC2 task definitions', () => { test('can add asset environment file to the container definition', () => { diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts b/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts index 2279245aebaa4..d88c5ecafd1d7 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts @@ -1,4 +1,4 @@ -import { expect, haveResource } from '@aws-cdk/assert'; +import { expect, haveResource, haveResourceLike } from '@aws-cdk/assert'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as elb from '@aws-cdk/aws-elasticloadbalancing'; import * as elbv2 from '@aws-cdk/aws-elasticloadbalancingv2'; @@ -541,14 +541,48 @@ nodeunitShim({ const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + // Errors on validation, not on construction. + new ecs.Ec2Service(stack, 'Ec2Service', { + cluster, + taskDefinition, + }); + // THEN test.throws(() => { - new ecs.Ec2Service(stack, 'Ec2Service', { - cluster, - taskDefinition, - }); + expect(stack); + }, /one essential container/); + + test.done(); + }, + + 'allows adding the default container after creating the service'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + cluster.addCapacity('DefaultAutoScalingGroup', { instanceType: new ec2.InstanceType('t2.micro') }); + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + new ecs.Ec2Service(stack, 'FargateService', { + cluster, + taskDefinition, }); + // Add the container *after* creating the service + taskDefinition.addContainer('main', { + image: ecs.ContainerImage.fromRegistry('somecontainer'), + memoryReservationMiB: 10, + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [ + { + Name: 'main', + }, + ], + })); + test.done(); }, diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/ec2-task-definition.test.ts b/packages/@aws-cdk/aws-ecs/test/ec2/ec2-task-definition.test.ts index 60c39d2103daf..bd13e8b83b524 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/ec2-task-definition.test.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/ec2-task-definition.test.ts @@ -531,6 +531,146 @@ describe('ec2 task definition', () => { }); + test('correctly sets containers from ECR repository using an image tag', () => { + // GIVEN + const stack = new cdk.Stack(); + + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer('web', { + image: ecs.ContainerImage.fromEcrRepository(new Repository(stack, 'myECRImage'), 'myTag'), + memoryLimitMiB: 512, + }); + + // THEN + expect(stack).toHaveResource('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [{ + Essential: true, + Memory: 512, + Image: { + 'Fn::Join': [ + '', + [ + { + 'Fn::Select': [ + 4, + { + 'Fn::Split': [ + ':', + { + 'Fn::GetAtt': [ + 'myECRImage7DEAE474', + 'Arn', + ], + }, + ], + }, + ], + }, + '.dkr.ecr.', + { + 'Fn::Select': [ + 3, + { + 'Fn::Split': [ + ':', + { + 'Fn::GetAtt': [ + 'myECRImage7DEAE474', + 'Arn', + ], + }, + ], + }, + ], + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + '/', + { + Ref: 'myECRImage7DEAE474', + }, + ':myTag', + ], + ], + }, + Name: 'web', + }], + }); + }); + + test('correctly sets containers from ECR repository using an image digest', () => { + // GIVEN + const stack = new cdk.Stack(); + + const taskDefinition = new ecs.Ec2TaskDefinition(stack, 'Ec2TaskDef'); + + taskDefinition.addContainer('web', { + image: ecs.ContainerImage.fromEcrRepository(new Repository(stack, 'myECRImage'), 'sha256:94afd1f2e64d908bc90dbca0035a5b567EXAMPLE'), + memoryLimitMiB: 512, + }); + + // THEN + expect(stack).toHaveResource('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [{ + Essential: true, + Memory: 512, + Image: { + 'Fn::Join': [ + '', + [ + { + 'Fn::Select': [ + 4, + { + 'Fn::Split': [ + ':', + { + 'Fn::GetAtt': [ + 'myECRImage7DEAE474', + 'Arn', + ], + }, + ], + }, + ], + }, + '.dkr.ecr.', + { + 'Fn::Select': [ + 3, + { + 'Fn::Split': [ + ':', + { + 'Fn::GetAtt': [ + 'myECRImage7DEAE474', + 'Arn', + ], + }, + ], + }, + ], + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + '/', + { + Ref: 'myECRImage7DEAE474', + }, + '@sha256:94afd1f2e64d908bc90dbca0035a5b567EXAMPLE', + ], + ], + }, + Name: 'web', + }], + }); + }); + test('correctly sets containers from ECR repository using default props', () => { // GIVEN const stack = new cdk.Stack(); diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts b/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts index acddf713d6d0a..c7aa1fc633a1d 100644 --- a/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts +++ b/packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts @@ -482,14 +482,46 @@ nodeunitShim({ const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); const taskDefinition = new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + // Errors on validation, not on construction. + new ecs.FargateService(stack, 'FargateService', { + cluster, + taskDefinition, + }); + // THEN test.throws(() => { - new ecs.FargateService(stack, 'FargateService', { - cluster, - taskDefinition, - }); + expect(stack); + }, /one essential container/); + + test.done(); + }, + + 'allows adding the default container after creating the service'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'MyVpc', {}); + const cluster = new ecs.Cluster(stack, 'EcsCluster', { vpc }); + const taskDefinition = new ecs.FargateTaskDefinition(stack, 'FargateTaskDef'); + + new ecs.FargateService(stack, 'FargateService', { + cluster, + taskDefinition, }); + // Add the container *after* creating the service + taskDefinition.addContainer('main', { + image: ecs.ContainerImage.fromRegistry('somecontainer'), + }); + + // THEN + expect(stack).to(haveResourceLike('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [ + { + Name: 'main', + }, + ], + })); + test.done(); }, 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 index d333fd9df89e2..e2d40e4ef52f6 100644 --- 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 @@ -15,13 +15,12 @@ const taskDefinition = new ecs.FargateTaskDefinition(stack, 'TaskDef', { cpu: 512, }); -const container = taskDefinition.addContainer('web', { +taskDefinition.addContainer('web', { image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), -}); - -container.addPortMappings({ - containerPort: 80, - protocol: ecs.Protocol.TCP, + portMappings: [{ + containerPort: 80, + protocol: ecs.Protocol.TCP, + }], }); const service = new ecs.FargateService(stack, 'Service', { diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md b/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md index 89baa6791ff2c..e0f3b1f4e5ea5 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/README.md @@ -182,6 +182,9 @@ lb.addRedirect({ If you do not provide any options for this method, it redirects HTTP port 80 to HTTPS port 443. +By default all ingress traffic will be allowed on the source port. If you want to be more selective with your +ingress rules then set `open: false` and use the listener's `connections` object to selectively grant access to the listener. + ## Defining a Network Load Balancer Network Load Balancers are defined in a similar way to Application Load 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 8f0ccf963cc5b..4ad4dcb5fa081 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 @@ -119,7 +119,7 @@ export class ApplicationLoadBalancer extends BaseLoadBalancer implements IApplic return this.addListener(`Redirect${sourcePort}To${targetPort}`, { protocol: props.sourceProtocol ?? ApplicationProtocol.HTTP, port: sourcePort, - open: true, + open: props.open ?? true, defaultAction: ListenerAction.redirect({ port: targetPort, protocol: props.targetProtocol ?? ApplicationProtocol.HTTPS, @@ -665,4 +665,19 @@ export interface ApplicationLoadBalancerRedirectConfig { */ readonly targetPort?: number; + /** + * Allow anyone to connect to this listener + * + * If this is specified, the listener will be opened up to anyone who can reach it. + * For internal load balancers this is anyone in the same VPC. For public load + * balancers, this is anyone on the internet. + * + * If you want to be more selective about who can access this load + * balancer, set this to `false` and use the listener's `connections` + * object to selectively grant access to the listener. + * + * @default true + */ + readonly open?: boolean; + } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-target-group.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-target-group.ts index c6556a61e2021..74938a08ee745 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-target-group.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/lib/alb/application-target-group.ts @@ -123,6 +123,9 @@ export class ApplicationTargetGroup extends TargetGroupBase implements IApplicat if (props) { if (props.slowStart !== undefined) { + if (props.slowStart.toSeconds() < 30 || props.slowStart.toSeconds() > 900) { + throw new Error('Slow start duration value must be between 30 and 900 seconds.'); + } this.setAttribute('slow_start.duration_seconds', props.slowStart.toSeconds().toString()); } if (props.stickinessCookieDuration) { diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/listener.test.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/listener.test.ts index 231279ffb932e..3e7b639cb1a8f 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/listener.test.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/listener.test.ts @@ -727,6 +727,31 @@ describe('tests', () => { }); }); + test('Can supress default ingress rules on a simple redirect response', () => { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'Stack'); + + const loadBalancer = new elbv2.ApplicationLoadBalancer(stack, 'LB', { + vpc, + }); + + // WHEN + loadBalancer.addRedirect({ open: false }); + + // THEN + expect(stack).not.toHaveResourceLike('AWS::EC2::SecurityGroup', { + SecurityGroupIngress: [ + { + CidrIp: '0.0.0.0/0', + Description: 'Allow from anyone on port 80', + IpProtocol: 'tcp', + }, + ], + }); + + }); + test('Can add simple redirect responses with custom values', () => { // GIVEN const stack = new cdk.Stack(); diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/target-group.test.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/target-group.test.ts index 06b79c87d244f..77858e9c21af4 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/target-group.test.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/alb/target-group.test.ts @@ -205,4 +205,21 @@ describe('tests', () => { }); }).toThrow(/Stickiness cookie duration value must be between 1 second and 7 days \(604800 seconds\)./); }); + + test('Bad slow start duration value', () => { + // GIVEN + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'Stack'); + const vpc = new ec2.Vpc(stack, 'VPC', {}); + + // THEN + [cdk.Duration.minutes(16), cdk.Duration.seconds(29)].forEach((badDuration, i) => { + expect(() => { + new elbv2.ApplicationTargetGroup(stack, `TargetGroup${i}`, { + slowStart: badDuration, + vpc, + }); + }).toThrow(/Slow start duration value must be between 30 and 900 seconds./); + }); + }); }); diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.expected.json b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.expected.json index c1dfdcb095bec..56acbde9bb1f1 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.expected.json +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.expected.json @@ -469,6 +469,10 @@ "Port": 80, "Protocol": "HTTP", "TargetGroupAttributes": [ + { + "Key": "slow_start.duration_seconds", + "Value": "60" + }, { "Key": "stickiness.enabled", "Value": "true" diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.ts b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.ts index df716e80e0f4f..8643f7b4c1f69 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.ts +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2/test/integ.alb.ts @@ -32,6 +32,7 @@ const group2 = listener.addTargets('ConditionalTarget', { targets: [new elbv2.IpTarget('10.0.128.5')], stickinessCookieDuration: cdk.Duration.minutes(5), stickinessCookieName: 'MyDeliciousCookie', + slowStart: cdk.Duration.minutes(1), }); group1.metricTargetResponseTime().createAlarm(stack, 'ResponseTimeHigh1', { diff --git a/packages/@aws-cdk/aws-events-targets/README.md b/packages/@aws-cdk/aws-events-targets/README.md index 787bfcc433d30..ba0e83c2a82ba 100644 --- a/packages/@aws-cdk/aws-events-targets/README.md +++ b/packages/@aws-cdk/aws-events-targets/README.md @@ -27,6 +27,7 @@ Currently supported are: * Put a record to a Kinesis stream * Log an event into a LogGroup * Put a record to a Kinesis Data Firehose stream +* Put an event on an EventBridge bus See the README of the `@aws-cdk/aws-events` library for more information on EventBridge. diff --git a/packages/@aws-cdk/aws-events-targets/lib/event-bus.ts b/packages/@aws-cdk/aws-events-targets/lib/event-bus.ts new file mode 100644 index 0000000000000..1d07261a8eace --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/lib/event-bus.ts @@ -0,0 +1,45 @@ +import * as events from '@aws-cdk/aws-events'; +import * as iam from '@aws-cdk/aws-iam'; +import { singletonEventRole } from './util'; + +/** + * Configuration properties of an Event Bus event + */ +export interface EventBusProps { + /** + * Role to be used to publish the event + * + * @default a new role is created. + */ + readonly role?: iam.IRole; +} + +/** + * Notify an existing Event Bus of an event + */ +export class EventBus implements events.IRuleTarget { + private readonly role?: iam.IRole; + + constructor(private readonly eventBus: events.IEventBus, props: EventBusProps = {}) { + this.role = props.role; + } + + bind(rule: events.IRule, id?: string): events.RuleTargetConfig { + if (this.role) { + this.role.addToPrincipalPolicy(this.putEventStatement()); + } + const role = this.role ?? singletonEventRole(rule, [this.putEventStatement()]); + return { + id: id ?? '', + arn: this.eventBus.eventBusArn, + role, + }; + } + + private putEventStatement() { + return new iam.PolicyStatement({ + actions: ['events:PutEvents'], + resources: [this.eventBus.eventBusArn], + }); + } +} diff --git a/packages/@aws-cdk/aws-events-targets/lib/index.ts b/packages/@aws-cdk/aws-events-targets/lib/index.ts index bef8ce2463ffa..155791c195d1e 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/index.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/index.ts @@ -7,6 +7,7 @@ export * from './aws-api'; export * from './lambda'; export * from './ecs-task-properties'; export * from './ecs-task'; +export * from './event-bus'; export * from './state-machine'; export * from './kinesis-stream'; export * from './log-group'; diff --git a/packages/@aws-cdk/aws-events-targets/lib/util.ts b/packages/@aws-cdk/aws-events-targets/lib/util.ts index 74465558bb3f9..1026d1ae35a1a 100644 --- a/packages/@aws-cdk/aws-events-targets/lib/util.ts +++ b/packages/@aws-cdk/aws-events-targets/lib/util.ts @@ -33,13 +33,14 @@ export function singletonEventRole(scope: IConstruct, policyStatements: iam.Poli export function addLambdaPermission(rule: events.IRule, handler: lambda.IFunction): void { let scope: Construct | undefined; let node: ConstructNode = handler.permissionsNode; + let permissionId = `AllowEventRule${Names.nodeUniqueId(rule.node)}`; if (rule instanceof Construct) { // Place the Permission resource in the same stack as Rule rather than the Function // This is to reduce circular dependency when the lambda handler and the rule are across stacks. scope = rule; node = rule.node; + permissionId = `AllowEventRule${Names.nodeUniqueId(handler.node)}`; } - const permissionId = `AllowEventRule${Names.nodeUniqueId(rule.node)}`; if (!node.tryFindChild(permissionId)) { handler.addPermission(permissionId, { scope, diff --git a/packages/@aws-cdk/aws-events-targets/test/aws-api/integ.aws-api.expected.json b/packages/@aws-cdk/aws-events-targets/test/aws-api/integ.aws-api.expected.json index fe5ac3a5a17e6..f5ae531bc1a27 100644 --- a/packages/@aws-cdk/aws-events-targets/test/aws-api/integ.aws-api.expected.json +++ b/packages/@aws-cdk/aws-events-targets/test/aws-api/integ.aws-api.expected.json @@ -29,7 +29,26 @@ ] } }, - "ScheduleRuleAllowEventRuleawscdkawsapitargetintegScheduleRule51140722763E20C1": { + "ScheduleRuleAllowEventRuleawscdkawsapitargetintegScheduleRuleScheduleRuleTarget0HandlerF2C0C898874A4805": { + "Type": "AWS::Lambda::Permission", + "Properties": { + "Action": "lambda:InvokeFunction", + "FunctionName": { + "Fn::GetAtt": [ + "AWSb4cf1abd4e4f4bc699441af7ccd9ec371511E620", + "Arn" + ] + }, + "Principal": "events.amazonaws.com", + "SourceArn": { + "Fn::GetAtt": [ + "ScheduleRuleDA5BD877", + "Arn" + ] + } + } + }, + "ScheduleRuleAllowEventRuleawscdkawsapitargetintegScheduleRuleScheduleRuleTarget1Handler4688817C0179F894": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", @@ -198,7 +217,7 @@ ] } }, - "PatternRuleAllowEventRuleawscdkawsapitargetintegPatternRule3D388581AA4F776B": { + "PatternRuleAllowEventRuleawscdkawsapitargetintegPatternRulePatternRuleTarget0HandlerA0821464BB49C5D3": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", diff --git a/packages/@aws-cdk/aws-events-targets/test/event-bus/event-rule-target.test.ts b/packages/@aws-cdk/aws-events-targets/test/event-bus/event-rule-target.test.ts new file mode 100644 index 0000000000000..4c39a907210d7 --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/test/event-bus/event-rule-target.test.ts @@ -0,0 +1,93 @@ +import '@aws-cdk/assert/jest'; +import * as events from '@aws-cdk/aws-events'; +import * as iam from '@aws-cdk/aws-iam'; +import { Stack } from '@aws-cdk/core'; +import * as targets from '../../lib'; + +test('Use EventBus as an event rule target', () => { + const stack = new Stack(); + const rule = new events.Rule(stack, 'Rule', { + schedule: events.Schedule.expression('rate(1 min)'), + }); + + rule.addTarget(new targets.EventBus(events.EventBus.fromEventBusArn( + stack, + 'External', + 'arn:aws:events:us-east-1:111111111111:default', + ), + )); + + expect(stack).toHaveResource('AWS::Events::Rule', { + Targets: [ + { + Arn: 'arn:aws:events:us-east-1:111111111111:default', + Id: 'Target0', + RoleArn: { + 'Fn::GetAtt': [ + 'RuleEventsRoleC51A4248', + 'Arn', + ], + }, + }, + ], + }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Effect: 'Allow', + Action: 'events:PutEvents', + Resource: 'arn:aws:events:us-east-1:111111111111:default', + }], + Version: '2012-10-17', + }, + Roles: [{ + Ref: 'RuleEventsRoleC51A4248', + }], + }); +}); + +test('with supplied role', () => { + const stack = new Stack(); + const rule = new events.Rule(stack, 'Rule', { + schedule: events.Schedule.expression('rate(1 min)'), + }); + const role = new iam.Role(stack, 'Role', { + assumedBy: new iam.ServicePrincipal('events.amazonaws.com'), + roleName: 'GivenRole', + }); + + rule.addTarget(new targets.EventBus( + events.EventBus.fromEventBusArn( + stack, + 'External', + 'arn:aws:events:us-east-1:123456789012:default', + ), + { role }, + )); + + expect(stack).toHaveResource('AWS::Events::Rule', { + Targets: [{ + Arn: 'arn:aws:events:us-east-1:123456789012:default', + Id: 'Target0', + RoleArn: { + 'Fn::GetAtt': [ + 'Role1ABCC5F0', + 'Arn', + ], + }, + }], + }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [{ + Effect: 'Allow', + Action: 'events:PutEvents', + Resource: 'arn:aws:events:us-east-1:123456789012:default', + }], + Version: '2012-10-17', + }, + Roles: [{ + Ref: 'Role1ABCC5F0', + }], + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-events-targets/test/event-bus/integ.event-bus.expected.json b/packages/@aws-cdk/aws-events-targets/test/event-bus/integ.event-bus.expected.json new file mode 100644 index 0000000000000..632ddf1767598 --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/test/event-bus/integ.event-bus.expected.json @@ -0,0 +1,83 @@ +{ + "Resources": { + "Rule4C995B7F": { + "Type": "AWS::Events::Rule", + "Properties": { + "ScheduleExpression": "rate(1 minute)", + "State": "ENABLED", + "Targets": [ + { + "Arn": { + "Fn::Join": [ + "", + [ + "arn:aws:events:", + { + "Ref": "AWS::Region" + }, + ":999999999999:event-bus/test-bus" + ] + ] + }, + "Id": "Target0", + "RoleArn": { + "Fn::GetAtt": [ + "RuleEventsRoleC51A4248", + "Arn" + ] + } + } + ] + } + }, + "RuleEventsRoleC51A4248": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "RuleEventsRoleDefaultPolicy0510525D": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "events:PutEvents", + "Effect": "Allow", + "Resource": { + "Fn::Join": [ + "", + [ + "arn:aws:events:", + { + "Ref": "AWS::Region" + }, + ":999999999999:event-bus/test-bus" + ] + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "RuleEventsRoleDefaultPolicy0510525D", + "Roles": [ + { + "Ref": "RuleEventsRoleC51A4248" + } + ] + } + } + } +} diff --git a/packages/@aws-cdk/aws-events-targets/test/event-bus/integ.event-bus.ts b/packages/@aws-cdk/aws-events-targets/test/event-bus/integ.event-bus.ts new file mode 100644 index 0000000000000..c0ec2ea421b85 --- /dev/null +++ b/packages/@aws-cdk/aws-events-targets/test/event-bus/integ.event-bus.ts @@ -0,0 +1,26 @@ +/// !cdk-integ pragma:ignore-assets +import * as events from '@aws-cdk/aws-events'; +import * as cdk from '@aws-cdk/core'; +import * as targets from '../../lib'; + +const app = new cdk.App(); + +class EventSourceStack extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const rule = new events.Rule(this, 'Rule', { + schedule: events.Schedule.expression('rate(1 minute)'), + }); + rule.addTarget(new targets.EventBus( + events.EventBus.fromEventBusArn( + this, + 'External', + `arn:aws:events:${this.region}:999999999999:event-bus/test-bus`, + ), + )); + } +} + +new EventSourceStack(app, 'event-source-stack'); +app.synth(); diff --git a/packages/@aws-cdk/aws-events-targets/test/lambda/integ.events.expected.json b/packages/@aws-cdk/aws-events-targets/test/lambda/integ.events.expected.json index 8e20529aa21d1..c12d92ef34810 100644 --- a/packages/@aws-cdk/aws-events-targets/test/lambda/integ.events.expected.json +++ b/packages/@aws-cdk/aws-events-targets/test/lambda/integ.events.expected.json @@ -68,7 +68,7 @@ ] } }, - "TimerAllowEventRulelambdaeventsTimer0E6AB6D890F582F4": { + "TimerAllowEventRulelambdaeventsMyFunc910E580F793D7BBB": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", @@ -105,7 +105,7 @@ ] } }, - "Timer2AllowEventRulelambdaeventsTimer27F866A1E50659689": { + "Timer2AllowEventRulelambdaeventsMyFunc910E580FCCD9CDCE": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", diff --git a/packages/@aws-cdk/aws-events-targets/test/lambda/lambda.test.ts b/packages/@aws-cdk/aws-events-targets/test/lambda/lambda.test.ts index 28df0932c9ba4..29be8973fe3c3 100644 --- a/packages/@aws-cdk/aws-events-targets/test/lambda/lambda.test.ts +++ b/packages/@aws-cdk/aws-events-targets/test/lambda/lambda.test.ts @@ -78,6 +78,27 @@ test('adding same lambda function as target mutiple times creates permission onl expect(stack).toCountResources('AWS::Lambda::Permission', 1); }); +test('adding different lambda functions as target mutiple times creates multiple permissions', () => { + // GIVEN + const stack = new cdk.Stack(); + const fn1 = newTestLambda(stack); + const fn2 = newTestLambda(stack, '2'); + const rule = new events.Rule(stack, 'Rule', { + schedule: events.Schedule.rate(cdk.Duration.minutes(1)), + }); + + // WHEN + rule.addTarget(new targets.LambdaFunction(fn1, { + event: events.RuleTargetInput.fromObject({ key: 'value1' }), + })); + rule.addTarget(new targets.LambdaFunction(fn2, { + event: events.RuleTargetInput.fromObject({ key: 'value2' }), + })); + + // THEN + expect(stack).toCountResources('AWS::Lambda::Permission', 2); +}); + test('adding same singleton lambda function as target mutiple times creates permission only once', () => { // GIVEN const stack = new cdk.Stack(); @@ -126,8 +147,8 @@ test('lambda handler and cloudwatch event across stacks', () => { expect(eventStack).toCountResources('AWS::Lambda::Permission', 1); }); -function newTestLambda(scope: constructs.Construct) { - return new lambda.Function(scope, 'MyLambda', { +function newTestLambda(scope: constructs.Construct, suffix = '') { + return new lambda.Function(scope, `MyLambda${suffix}`, { code: new lambda.InlineCode('foo'), handler: 'bar', runtime: lambda.Runtime.PYTHON_2_7, diff --git a/packages/@aws-cdk/aws-events/README.md b/packages/@aws-cdk/aws-events/README.md index d19309455559d..565b0537d091d 100644 --- a/packages/@aws-cdk/aws-events/README.md +++ b/packages/@aws-cdk/aws-events/README.md @@ -163,3 +163,26 @@ In this situation, the CDK will wire the 2 accounts together: For more information, see the [AWS documentation on cross-account events](https://docs.aws.amazon.com/eventbridge/latest/userguide/eventbridge-cross-account-event-delivery.html). + +## Archiving + +It is possible to archive all or some events sent to an event bus. It is then possible to [replay these events](https://aws.amazon.com/blogs/aws/new-archive-and-replay-events-with-amazon-eventbridge/). + +```ts +import * as cdk from '@aws-cdk/core'; + +const stack = new stack(); + +const bus = new EventBus(stack, 'bus', { + eventBusName: 'MyCustomEventBus' +}); + +bus.archive('MyArchive', { + archiveName: 'MyCustomEventBusArchive', + description: 'MyCustomerEventBus Archive', + eventPattern: { + account: [stack.account], + }, + retention: cdk.Duration.days(365), +}); +``` diff --git a/packages/@aws-cdk/aws-events/lib/archive.ts b/packages/@aws-cdk/aws-events/lib/archive.ts new file mode 100644 index 0000000000000..3da79df6682a2 --- /dev/null +++ b/packages/@aws-cdk/aws-events/lib/archive.ts @@ -0,0 +1,77 @@ +import { Duration, Resource } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { IEventBus } from './event-bus'; +import { EventPattern } from './event-pattern'; +import { CfnArchive } from './events.generated'; + +/** + * The event archive base properties + */ +export interface BaseArchiveProps { + /** + * The name of the archive. + * + * @default - Automatically generated + */ + readonly archiveName?: string; + /** + * A description for the archive. + * + * @default - none + */ + readonly description?: string; + /** + * An event pattern to use to filter events sent to the archive. + */ + readonly eventPattern: EventPattern; + /** + * The number of days to retain events for. Default value is 0. If set to 0, events are retained indefinitely. + * @default - Infinite + */ + readonly retention?: Duration; +} + + +/** + * The event archive properties + */ +export interface ArchiveProps extends BaseArchiveProps { + /** + * The event source associated with the archive. + */ + readonly sourceEventBus: IEventBus; +} + +/** + * Define an EventBridge Archive + * + * @resource AWS::Events::Archive + */ +export class Archive extends Resource { + /** + * The archive name. + * @attribute + */ + public readonly archiveName: string; + + /** + * The ARN of the archive created. + * @attribute + */ + public readonly archiveArn: string; + + constructor(scope: Construct, id: string, props: ArchiveProps) { + super(scope, id, { physicalName: props.archiveName }); + + let archive = new CfnArchive(this, 'Archive', { + sourceArn: props.sourceEventBus.eventBusArn, + description: props.description, + eventPattern: props.eventPattern, + retentionDays: props.retention?.toDays({ integral: true }) || 0, + archiveName: this.physicalName, + }); + + this.archiveArn = archive.attrArn; + this.archiveName = archive.attrArchiveName; + } +} diff --git a/packages/@aws-cdk/aws-events/lib/event-bus.ts b/packages/@aws-cdk/aws-events/lib/event-bus.ts index 27c79c9c7fe3a..cd0c7f913cbf6 100644 --- a/packages/@aws-cdk/aws-events/lib/event-bus.ts +++ b/packages/@aws-cdk/aws-events/lib/event-bus.ts @@ -1,6 +1,7 @@ import * as iam from '@aws-cdk/aws-iam'; import { IResource, Lazy, Names, Resource, Stack, Token } from '@aws-cdk/core'; import { Construct } from 'constructs'; +import { Archive, BaseArchiveProps } from './archive'; import { CfnEventBus } from './events.generated'; /** @@ -37,6 +38,15 @@ export interface IEventBus extends IResource { * @link https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-events-eventbus.html#cfn-events-eventbus-eventsourcename */ readonly eventSourceName?: string; + + /** + * Create an EventBridge archive to send events to. + * When you create an archive, incoming events might not immediately start being sent to the archive. + * Allow a short period of time for changes to take effect. + * + * @param props Properties of the archive + */ + archive(id: string, props: BaseArchiveProps): Archive; } /** @@ -96,12 +106,45 @@ export interface EventBusAttributes { readonly eventSourceName?: string; } +abstract class EventBusBase extends Resource implements IEventBus { + /** + * The physical ID of this event bus resource + */ + public abstract readonly eventBusName: string; + + /** + * The ARN of the event bus, such as: + * arn:aws:events:us-east-2:123456789012:event-bus/aws.partner/PartnerName/acct1/repo1. + */ + public abstract readonly eventBusArn: string; + + /** + * The policy for the event bus in JSON form. + */ + public abstract readonly eventBusPolicy: string; + + /** + * The name of the partner event source + */ + public abstract readonly eventSourceName?: string; + + public archive(id: string, props: BaseArchiveProps): Archive { + return new Archive(this, id, { + sourceEventBus: this, + description: props.description || `Event Archive for ${this.eventBusName} Event Bus`, + eventPattern: props.eventPattern, + retention: props.retention, + archiveName: props.archiveName, + }); + } +} + /** * Define an EventBridge EventBus * * @resource AWS::Events::EventBus */ -export class EventBus extends Resource implements IEventBus { +export class EventBus extends EventBusBase { /** * Import an existing event bus resource @@ -112,13 +155,11 @@ export class EventBus extends Resource implements IEventBus { public static fromEventBusArn(scope: Construct, id: string, eventBusArn: string): IEventBus { const parts = Stack.of(scope).parseArn(eventBusArn); - class Import extends Resource implements IEventBus { - public readonly eventBusArn = eventBusArn; - public readonly eventBusName = parts.resourceName || ''; - public readonly eventBusPolicy = ''; - } - - return new Import(scope, id); + return new ImportedEventBus(scope, id, { + eventBusArn: eventBusArn, + eventBusName: parts.resourceName || '', + eventBusPolicy: '', + }); } /** @@ -128,14 +169,7 @@ export class EventBus extends Resource implements IEventBus { * @param attrs Imported event bus properties */ public static fromEventBusAttributes(scope: Construct, id: string, attrs: EventBusAttributes): IEventBus { - class Import extends Resource implements IEventBus { - public readonly eventBusArn = attrs.eventBusArn; - public readonly eventBusName = attrs.eventBusName; - public readonly eventBusPolicy = attrs.eventBusPolicy; - public readonly eventSourceName = attrs.eventSourceName; - } - - return new Import(scope, id); + return new ImportedEventBus(scope, id, attrs); } /** @@ -241,3 +275,18 @@ export class EventBus extends Resource implements IEventBus { this.eventSourceName = eventBus.eventSourceName; } } + +class ImportedEventBus extends EventBusBase { + public readonly eventBusArn: string; + public readonly eventBusName: string; + public readonly eventBusPolicy: string; + public readonly eventSourceName?: string; + constructor(scope: Construct, id: string, attrs: EventBusAttributes) { + super(scope, id); + + this.eventBusArn = attrs.eventBusArn; + this.eventBusName = attrs.eventBusName; + this.eventBusPolicy = attrs.eventBusPolicy; + this.eventSourceName = attrs.eventSourceName; + } +} diff --git a/packages/@aws-cdk/aws-events/lib/index.ts b/packages/@aws-cdk/aws-events/lib/index.ts index e0aa655afaf72..718b236bf6e91 100644 --- a/packages/@aws-cdk/aws-events/lib/index.ts +++ b/packages/@aws-cdk/aws-events/lib/index.ts @@ -6,6 +6,7 @@ export * from './event-bus'; export * from './event-pattern'; export * from './schedule'; export * from './on-event-options'; +export * from './archive'; // AWS::Events CloudFormation Resources: export * from './events.generated'; diff --git a/packages/@aws-cdk/aws-events/test/test.archive.ts b/packages/@aws-cdk/aws-events/test/test.archive.ts new file mode 100644 index 0000000000000..fc8f38a516f2b --- /dev/null +++ b/packages/@aws-cdk/aws-events/test/test.archive.ts @@ -0,0 +1,45 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import { Duration, Stack } from '@aws-cdk/core'; +import { Test } from 'nodeunit'; +import { EventBus } from '../lib'; +import { Archive } from '../lib/archive'; + +export = { + 'creates an archive for an EventBus'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + let eventBus = new EventBus(stack, 'Bus'); + + new Archive(stack, 'Archive', { + sourceEventBus: eventBus, + eventPattern: { + account: [stack.account], + }, + retention: Duration.days(10), + }); + + // THEN + expect(stack).to(haveResource('AWS::Events::EventBus', { + Name: 'Bus', + })); + + expect(stack).to(haveResource('AWS::Events::Archive', { + EventPattern: { + account: [{ + Ref: 'AWS::AccountId', + }], + }, + RetentionDays: 10, + SourceArn: { + 'Fn::GetAtt': [ + 'BusEA82B648', + 'Arn', + ], + }, + })); + + test.done(); + }, +} diff --git a/packages/@aws-cdk/aws-events/test/test.event-bus.ts b/packages/@aws-cdk/aws-events/test/test.event-bus.ts index a6c32885ec4a9..2e8434e147bb1 100644 --- a/packages/@aws-cdk/aws-events/test/test.event-bus.ts +++ b/packages/@aws-cdk/aws-events/test/test.event-bus.ts @@ -245,6 +245,133 @@ export = { ], })); + test.done(); + }, + 'can archive events'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const event = new EventBus(stack, 'Bus'); + + event.archive('MyArchive', { + eventPattern: { + account: [stack.account], + }, + archiveName: 'MyArchive', + }); + + // THEN + expect(stack).to(haveResource('AWS::Events::EventBus', { + Name: 'Bus', + })); + + expect(stack).to(haveResource('AWS::Events::Archive', { + SourceArn: { + 'Fn::GetAtt': [ + 'BusEA82B648', + 'Arn', + ], + }, + Description: { + 'Fn::Join': [ + '', + [ + 'Event Archive for ', + { + Ref: 'BusEA82B648', + }, + ' Event Bus', + ], + ], + }, + EventPattern: { + account: [ + { + Ref: 'AWS::AccountId', + }, + ], + }, + RetentionDays: 0, + ArchiveName: 'MyArchive', + })); + + test.done(); + }, + 'can archive events from an imported EventBus'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const bus = new EventBus(stack, 'Bus'); + + const importedBus = EventBus.fromEventBusArn(stack, 'ImportedBus', bus.eventBusArn); + + importedBus.archive('MyArchive', { + eventPattern: { + account: [stack.account], + }, + archiveName: 'MyArchive', + }); + + // THEN + expect(stack).to(haveResource('AWS::Events::EventBus', { + Name: 'Bus', + })); + + expect(stack).to(haveResource('AWS::Events::Archive', { + SourceArn: { + 'Fn::GetAtt': [ + 'BusEA82B648', + 'Arn', + ], + }, + Description: { + 'Fn::Join': [ + '', + [ + 'Event Archive for ', + { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '/', + { + 'Fn::Select': [ + 5, + { + 'Fn::Split': [ + ':', + { + 'Fn::GetAtt': [ + 'BusEA82B648', + 'Arn', + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + ' Event Bus', + ], + ], + }, + EventPattern: { + account: [ + { + Ref: 'AWS::AccountId', + }, + ], + }, + RetentionDays: 0, + ArchiveName: 'MyArchive', + })); + test.done(); }, }; diff --git a/packages/@aws-cdk/aws-lambda-destinations/README.md b/packages/@aws-cdk/aws-lambda-destinations/README.md index 8459820e9686e..404b0b3157adb 100644 --- a/packages/@aws-cdk/aws-lambda-destinations/README.md +++ b/packages/@aws-cdk/aws-lambda-destinations/README.md @@ -21,7 +21,7 @@ The following destinations are supported * SNS topic * EventBridge event bus -Example with a SNS topic for sucessful invocations: +Example with a SNS topic for successful invocations: ```ts import * as lambda from '@aws-cdk/aws-lambda'; diff --git a/packages/@aws-cdk/aws-lambda-destinations/test/integ.lambda-chain.expected.json b/packages/@aws-cdk/aws-lambda-destinations/test/integ.lambda-chain.expected.json index f8f6f78713d64..5fc64df8417f3 100644 --- a/packages/@aws-cdk/aws-lambda-destinations/test/integ.lambda-chain.expected.json +++ b/packages/@aws-cdk/aws-lambda-destinations/test/integ.lambda-chain.expected.json @@ -58,13 +58,13 @@ "Code": { "ZipFile": "exports.handler = async (event) => {\n console.log('Event: %j', event);\n if (event === 'error') throw new Error('UnkownError');\n return event;\n };" }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "FirstServiceRole097DB3A5", "Arn" ] }, + "Handler": "index.handler", "Runtime": "nodejs10.x" }, "DependsOn": [ @@ -114,7 +114,7 @@ ] } }, - "FirstEventInvokeConfigFailureAllowEventRuleawscdklambdachainFirstEventInvokeConfigFailure7180F42FA8F1F1F0": { + "FirstEventInvokeConfigFailureAllowEventRuleawscdklambdachainErrorC073CD8DCAD68018": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", @@ -175,7 +175,7 @@ ] } }, - "FirstEventInvokeConfigSuccessAllowEventRuleawscdklambdachainFirstEventInvokeConfigSuccess2DCAE39FC2495AB7": { + "FirstEventInvokeConfigSuccessAllowEventRuleawscdklambdachainSecond178F48F8A8DE2790": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", @@ -308,13 +308,13 @@ "Code": { "ZipFile": "exports.handler = async (event) => {\n console.log('Event: %j', event);\n if (event === 'error') throw new Error('UnkownError');\n return event;\n };" }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "SecondServiceRole55940A31", "Arn" ] }, + "Handler": "index.handler", "Runtime": "nodejs10.x" }, "DependsOn": [ @@ -364,7 +364,7 @@ ] } }, - "SecondEventInvokeConfigSuccessAllowEventRuleawscdklambdachainSecondEventInvokeConfigSuccess2078CDC9C7FB9F61": { + "SecondEventInvokeConfigSuccessAllowEventRuleawscdklambdachainThird031C7FF6ABA1C15A": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", @@ -453,13 +453,13 @@ "Code": { "ZipFile": "exports.handler = async (event) => {\n console.log('Event: %j', event);\n if (event === 'error') throw new Error('UnkownError');\n return event;\n };" }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "ThirdServiceRole42701801", "Arn" ] }, + "Handler": "index.handler", "Runtime": "nodejs10.x" }, "DependsOn": [ @@ -503,13 +503,13 @@ "Code": { "ZipFile": "exports.handler = async (event) => {\n console.log('Event: %j', event);\n if (event === 'error') throw new Error('UnkownError');\n return event;\n };" }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "ErrorServiceRoleCE484966", "Arn" ] }, + "Handler": "index.handler", "Runtime": "nodejs10.x" }, "DependsOn": [ diff --git a/packages/@aws-cdk/aws-lambda-nodejs/README.md b/packages/@aws-cdk/aws-lambda-nodejs/README.md index f35062120fb9d..d11602c5e656e 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/README.md +++ b/packages/@aws-cdk/aws-lambda-nodejs/README.md @@ -148,7 +148,7 @@ new lambda.NodejsFunction(this, 'my-handler', { }, logLevel: LogLevel.SILENT, // defaults to LogLevel.WARNING keepNames: true, // defaults to false - tsconfig: 'custom-tsconfig.json' // use custom-tsconfig.json instead of default, + tsconfig: 'custom-tsconfig.json' // use custom-tsconfig.json instead of default, metafile: true, // include meta file, defaults to false banner : '/* comments */', // by default no comments are passed footer : '/* comments */', // by default no comments are passed @@ -220,7 +220,7 @@ Use `bundling.dockerImage` to use a custom Docker bundling image: ```ts new lambda.NodejsFunction(this, 'my-handler', { bundling: { - dockerImage: cdk.BundlingDockerImage.fromAsset('/path/to/Dockerfile'), + dockerImage: cdk.DockerImage.fromBuild('/path/to/Dockerfile'), }, }); ``` diff --git a/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts b/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts index a6dcddde7709d..536ca1ea7646a 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/lib/bundling.ts @@ -140,10 +140,10 @@ export class Bundling implements cdk.BundlingOptions { const esbuildCommand: string = [ npx, 'esbuild', - '--bundle', pathJoin(inputDir, this.relativeEntryPath), + '--bundle', `"${pathJoin(inputDir, this.relativeEntryPath)}"`, `--target=${this.props.target ?? toTarget(this.props.runtime)}`, '--platform=node', - `--outfile=${pathJoin(outputDir, 'index.js')}`, + `--outfile="${pathJoin(outputDir, 'index.js')}"`, ...this.props.minify ? ['--minify'] : [], ...this.props.sourceMap ? ['--sourcemap'] : [], ...this.externals.map(external => `--external:${external}`), diff --git a/packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts b/packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts index bd69394ae757c..e6c32c496b2ff 100644 --- a/packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts +++ b/packages/@aws-cdk/aws-lambda-nodejs/test/bundling.test.ts @@ -20,7 +20,7 @@ beforeEach(() => { getEsBuildVersionMock.mockReturnValue('0.8.8'); fromAssetMock.mockReturnValue({ image: 'built-image', - cp: () => {}, + cp: () => 'dest-path', run: () => {}, toJSON: () => 'built-image', }); @@ -53,7 +53,7 @@ test('esbuild bundling in Docker', () => { }, command: [ 'bash', '-c', - 'npx esbuild --bundle /asset-input/lib/handler.ts --target=node12 --platform=node --outfile=/asset-output/index.js --external:aws-sdk --loader:.png=dataurl', + 'npx esbuild --bundle "/asset-input/lib/handler.ts" --target=node12 --platform=node --outfile="/asset-output/index.js" --external:aws-sdk --loader:.png=dataurl', ], workingDirectory: '/', }), @@ -74,7 +74,7 @@ test('esbuild bundling with handler named index.ts', () => { bundling: expect.objectContaining({ command: [ 'bash', '-c', - 'npx esbuild --bundle /asset-input/lib/index.ts --target=node12 --platform=node --outfile=/asset-output/index.js --external:aws-sdk', + 'npx esbuild --bundle "/asset-input/lib/index.ts" --target=node12 --platform=node --outfile="/asset-output/index.js" --external:aws-sdk', ], }), }); @@ -94,7 +94,7 @@ test('esbuild bundling with tsx handler', () => { bundling: expect.objectContaining({ command: [ 'bash', '-c', - 'npx esbuild --bundle /asset-input/lib/handler.tsx --target=node12 --platform=node --outfile=/asset-output/index.js --external:aws-sdk', + 'npx esbuild --bundle "/asset-input/lib/handler.tsx" --target=node12 --platform=node --outfile="/asset-output/index.js" --external:aws-sdk', ], }), }); @@ -139,7 +139,7 @@ test('esbuild bundling with externals and dependencies', () => { command: [ 'bash', '-c', [ - 'npx esbuild --bundle /asset-input/test/bundling.test.js --target=node12 --platform=node --outfile=/asset-output/index.js --external:abc --external:delay', + 'npx esbuild --bundle "/asset-input/test/bundling.test.js" --target=node12 --platform=node --outfile="/asset-output/index.js" --external:abc --external:delay', `echo \'{\"dependencies\":{\"delay\":\"${delayVersion}\"}}\' > /asset-output/package.json`, 'cp /asset-input/package-lock.json /asset-output/package-lock.json', 'cd /asset-output', @@ -181,8 +181,8 @@ test('esbuild bundling with esbuild options', () => { command: [ 'bash', '-c', [ - 'npx esbuild --bundle /asset-input/lib/handler.ts', - '--target=es2020 --platform=node --outfile=/asset-output/index.js', + 'npx esbuild --bundle "/asset-input/lib/handler.ts"', + '--target=es2020 --platform=node --outfile="/asset-output/index.js"', '--minify --sourcemap --external:aws-sdk --loader:.png=dataurl', '--define:DEBUG=true --define:process.env.KEY="VALUE"', '--log-level=silent --keep-names --tsconfig=/asset-input/lib/custom-tsconfig.ts', diff --git a/packages/@aws-cdk/aws-lambda/README.md b/packages/@aws-cdk/aws-lambda/README.md index b3fb466ec7253..ae21d6ea9216e 100644 --- a/packages/@aws-cdk/aws-lambda/README.md +++ b/packages/@aws-cdk/aws-lambda/README.md @@ -36,6 +36,9 @@ runtime code. * `lambda.Code.fromAsset(path)` - specify a directory or a .zip file in the local filesystem which will be zipped and uploaded to S3 before deployment. See also [bundling asset code](#bundling-asset-code). + * `lambda.Code.fromDockerBuild(path, options)` - use the result of a Docker + build as code. The runtime code is expected to be located at `/asset` in the + image and will be zipped and uploaded to S3 as an asset. The following example shows how to define a Python function and deploy the code from the local directory `my-lambda-handler` to it: @@ -450,7 +453,7 @@ new lambda.Function(this, 'Function', { bundling: { image: lambda.Runtime.PYTHON_3_6.bundlingDockerImage, command: [ - 'bash', '-c', + 'bash', '-c', 'pip install -r requirements.txt -t /asset-output && cp -au . /asset-output' ], }, @@ -462,8 +465,8 @@ new lambda.Function(this, 'Function', { Runtimes expose a `bundlingDockerImage` property that points to the [AWS SAM](https://github.com/awslabs/aws-sam-cli) build image. -Use `cdk.BundlingDockerImage.fromRegistry(image)` to use an existing image or -`cdk.BundlingDockerImage.fromAsset(path)` to build a specific image: +Use `cdk.DockerImage.fromRegistry(image)` to use an existing image or +`cdk.DockerImage.fromBuild(path)` to build a specific image: ```ts import * as cdk from '@aws-cdk/core'; @@ -471,7 +474,7 @@ import * as cdk from '@aws-cdk/core'; new lambda.Function(this, 'Function', { code: lambda.Code.fromAsset('/path/to/handler', { bundling: { - image: cdk.BundlingDockerImage.fromAsset('/path/to/dir/with/DockerFile', { + image: cdk.DockerImage.fromBuild('/path/to/dir/with/DockerFile', { buildArgs: { ARG1: 'value1', }, @@ -489,3 +492,27 @@ Language-specific higher level constructs are provided in separate modules: * `@aws-cdk/aws-lambda-nodejs`: [Github](https://github.com/aws/aws-cdk/tree/master/packages/%40aws-cdk/aws-lambda-nodejs) & [CDK Docs](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-lambda-nodejs-readme.html) * `@aws-cdk/aws-lambda-python`: [Github](https://github.com/aws/aws-cdk/tree/master/packages/%40aws-cdk/aws-lambda-python) & [CDK Docs](https://docs.aws.amazon.com/cdk/api/latest/docs/aws-lambda-python-readme.html) + +## Code Signing + +Code signing for AWS Lambda helps to ensure that only trusted code runs in your Lambda functions. +When enabled, AWS Lambda checks every code deployment and verifies that the code package is signed by a trusted source. +For more information, see [Configuring code signing for AWS Lambda](https://docs.aws.amazon.com/lambda/latest/dg/configuration-codesigning.html). +The following code configures a function with code signing. + +```typescript +import * as signer from '@aws-cdk/aws-signer'; + +const signerProfile = signer.SigningProfile(this, 'SigningProfile', { + platform: Platform.AWS_LAMBDA_SHA384_ECDSA +}); + +const codeSigningConfig = new lambda.CodeSigningConfig(stack, 'CodeSigningConfig', { + signingProfiles: [signingProfile], +}); + +new lambda.Function(this, 'Function', { + codeSigningConfig, + // ... +}); +``` diff --git a/packages/@aws-cdk/aws-lambda/lib/code-signing-config.ts b/packages/@aws-cdk/aws-lambda/lib/code-signing-config.ts new file mode 100644 index 0000000000000..0472eb5d048f5 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/lib/code-signing-config.ts @@ -0,0 +1,120 @@ +import { ISigningProfile } from '@aws-cdk/aws-signer'; +import { IResource, Resource, Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnCodeSigningConfig } from './lambda.generated'; + +/** + * Code signing configuration policy for deployment validation failure. + */ +export enum UntrustedArtifactOnDeployment { + /** + * Lambda blocks the deployment request if signature validation checks fail. + */ + ENFORCE = 'enforce', + + /** + * Lambda allows the deployment of the code package, but issues a warning. + * Lambda issues a new Amazon CloudWatch metric, called a signature validation error and also stores the warning in CloudTrail. + */ + WARN = 'warn', +} + +/** + * A Code Signing Config + */ +export interface ICodeSigningConfig extends IResource { + /** + * The ARN of Code Signing Config + * @attribute + */ + readonly codeSigningConfigArn: string; + + /** + * The id of Code Signing Config + * @attribute + */ + readonly codeSigningConfigId: string; +} + +/** + * Construction properties for a Code Signing Config object + */ +export interface CodeSigningConfigProps { + /** + * List of signing profiles that defines a + * trusted user who can sign a code package. + */ + readonly signingProfiles: ISigningProfile[], + + /** + * Code signing configuration policy for deployment validation failure. + * If you set the policy to Enforce, Lambda blocks the deployment request + * if signature validation checks fail. + * If you set the policy to Warn, Lambda allows the deployment and + * creates a CloudWatch log. + * + * @default UntrustedArtifactOnDeployment.WARN + */ + readonly untrustedArtifactOnDeployment?: UntrustedArtifactOnDeployment, + + /** + * Code signing configuration description. + * + * @default - No description. + */ + readonly description?: string, +} + +/** + * Defines a Code Signing Config. + * + * @resource AWS::Lambda::CodeSigningConfig + */ +export class CodeSigningConfig extends Resource implements ICodeSigningConfig { + /** + * Creates a Signing Profile construct that represents an external Signing Profile. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param codeSigningConfigArn The ARN of code signing config. + */ + public static fromCodeSigningConfigArn( scope: Construct, id: string, codeSigningConfigArn: string): ICodeSigningConfig { + const codeSigningProfileId = Stack.of(scope).parseArn(codeSigningConfigArn).resourceName; + if (!codeSigningProfileId) { + throw new Error(`Code signing config ARN must be in the format 'arn:aws:lambda:::code-signing-config:', got: '${codeSigningConfigArn}'`); + } + const assertedCodeSigningProfileId = codeSigningProfileId; + class Import extends Resource implements ICodeSigningConfig { + public readonly codeSigningConfigArn = codeSigningConfigArn; + public readonly codeSigningConfigId = assertedCodeSigningProfileId; + + constructor() { + super(scope, id); + } + } + return new Import(); + } + + public readonly codeSigningConfigArn: string; + public readonly codeSigningConfigId: string; + + constructor(scope: Construct, id: string, props: CodeSigningConfigProps) { + super(scope, id); + + const signingProfileVersionArns = props.signingProfiles.map(signingProfile => { + return signingProfile.signingProfileVersionArn; + }); + + const resource: CfnCodeSigningConfig = new CfnCodeSigningConfig(this, 'Resource', { + allowedPublishers: { + signingProfileVersionArns, + }, + codeSigningPolicies: { + untrustedArtifactOnDeployment: props.untrustedArtifactOnDeployment ?? UntrustedArtifactOnDeployment.WARN, + }, + description: props.description, + }); + this.codeSigningConfigArn = resource.attrCodeSigningConfigArn; + this.codeSigningConfigId = resource.attrCodeSigningConfigId; + } +} diff --git a/packages/@aws-cdk/aws-lambda/lib/code.ts b/packages/@aws-cdk/aws-lambda/lib/code.ts index 29cd3d02ae4de..b4f41b2804257 100644 --- a/packages/@aws-cdk/aws-lambda/lib/code.ts +++ b/packages/@aws-cdk/aws-lambda/lib/code.ts @@ -57,6 +57,22 @@ export abstract class Code { return new AssetCode(path, options); } + /** + * Loads the function code from an asset created by a Docker build. + * + * By defaut, the asset is expected to be located at `/asset` in the + * image. + * + * @param path The path to the directory containing the Docker file + * @param options Docker build options + */ + public static fromDockerBuild(path: string, options: DockerBuildAssetOptions = {}): AssetCode { + const assetPath = cdk.DockerImage + .fromBuild(path, options) + .cp(options.imagePath ?? '/asset', options.outputPath); + return new AssetCode(assetPath); + } + /** * DEPRECATED * @deprecated use `fromAsset` @@ -488,3 +504,24 @@ export class AssetImageCode extends Code { }; } } + +/** + * Options when creating an asset from a Docker build. + */ +export interface DockerBuildAssetOptions extends cdk.DockerBuildOptions { + /** + * The path in the Docker image where the asset is located after the build + * operation. + * + * @default /asset + */ + readonly imagePath?: string; + + /** + * The path on the local filesystem where the asset will be copied + * using `docker cp`. + * + * @default - a unique temporary directory in the system temp directory + */ + readonly outputPath?: string; +} diff --git a/packages/@aws-cdk/aws-lambda/lib/function.ts b/packages/@aws-cdk/aws-lambda/lib/function.ts index fdcf4b4e0ec24..8d487276a6176 100644 --- a/packages/@aws-cdk/aws-lambda/lib/function.ts +++ b/packages/@aws-cdk/aws-lambda/lib/function.ts @@ -8,6 +8,7 @@ import * as sqs from '@aws-cdk/aws-sqs'; import { Annotations, CfnResource, Duration, Fn, Lazy, Names, Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { Code, CodeConfig } from './code'; +import { ICodeSigningConfig } from './code-signing-config'; import { EventInvokeConfigOptions } from './event-invoke-config'; import { IEventSource } from './event-source'; import { FileSystem } from './filesystem'; @@ -290,6 +291,13 @@ export interface FunctionOptions extends EventInvokeConfigOptions { * @default - AWS Lambda creates and uses an AWS managed customer master key (CMK). */ readonly environmentEncryption?: kms.IKey; + + /** + * Code signing config associated with this function + * + * @default - Not Sign the Code + */ + readonly codeSigningConfig?: ICodeSigningConfig; } export interface FunctionProps extends FunctionOptions { @@ -641,6 +649,7 @@ export class Function extends FunctionBase { }), kmsKeyArn: props.environmentEncryption?.keyArn, fileSystemConfigs, + codeSigningConfigArn: props.codeSigningConfig?.codeSigningConfigArn, }); resource.node.addDependency(this.role); diff --git a/packages/@aws-cdk/aws-lambda/lib/index.ts b/packages/@aws-cdk/aws-lambda/lib/index.ts index 1ba17427c5210..2d936755d6ad1 100644 --- a/packages/@aws-cdk/aws-lambda/lib/index.ts +++ b/packages/@aws-cdk/aws-lambda/lib/index.ts @@ -16,6 +16,7 @@ export * from './event-source-mapping'; export * from './destination'; export * from './event-invoke-config'; export * from './scalable-attribute-api'; +export * from './code-signing-config'; export * from './log-retention'; diff --git a/packages/@aws-cdk/aws-lambda/package.json b/packages/@aws-cdk/aws-lambda/package.json index de51f290aa4f5..6eab5cd11b870 100644 --- a/packages/@aws-cdk/aws-lambda/package.json +++ b/packages/@aws-cdk/aws-lambda/package.json @@ -99,6 +99,7 @@ "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/aws-s3-assets": "0.0.0", + "@aws-cdk/aws-signer": "0.0.0", "@aws-cdk/aws-sqs": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", @@ -119,6 +120,7 @@ "@aws-cdk/aws-logs": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@aws-cdk/aws-s3-assets": "0.0.0", + "@aws-cdk/aws-signer": "0.0.0", "@aws-cdk/aws-sqs": "0.0.0", "@aws-cdk/core": "0.0.0", "@aws-cdk/cx-api": "0.0.0", @@ -169,7 +171,8 @@ "props-default-doc:@aws-cdk/aws-lambda.Permission.sourceArn", "docs-public-apis:@aws-cdk/aws-lambda.ResourceBindOptions", "docs-public-apis:@aws-cdk/aws-lambda.VersionAttributes", - "props-physical-name:@aws-cdk/aws-lambda.EventInvokeConfigProps" + "props-physical-name:@aws-cdk/aws-lambda.EventInvokeConfigProps", + "props-physical-name:@aws-cdk/aws-lambda.CodeSigningConfigProps" ] }, "stability": "stable", diff --git a/packages/@aws-cdk/aws-lambda/test/code-signing-config.test.ts b/packages/@aws-cdk/aws-lambda/test/code-signing-config.test.ts new file mode 100644 index 0000000000000..3e123ab5d5d89 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/code-signing-config.test.ts @@ -0,0 +1,102 @@ +import '@aws-cdk/assert/jest'; +import * as signer from '@aws-cdk/aws-signer'; +import * as cdk from '@aws-cdk/core'; +import * as lambda from '../lib'; + +let app: cdk.App; +let stack: cdk.Stack; +beforeEach( () => { + app = new cdk.App( {} ); + stack = new cdk.Stack( app ); +} ); + +describe('code signing config', () => { + test('default', () => { + const platform = signer.Platform.AWS_LAMBDA_SHA384_ECDSA; + const signingProfile = new signer.SigningProfile(stack, 'SigningProfile', { platform }); + new lambda.CodeSigningConfig(stack, 'CodeSigningConfig', { + signingProfiles: [signingProfile], + }); + + expect(stack).toHaveResource('AWS::Lambda::CodeSigningConfig', { + AllowedPublishers: { + SigningProfileVersionArns: [{ + 'Fn::GetAtt': [ + 'SigningProfile2139A0F9', + 'ProfileVersionArn', + ], + }], + }, + CodeSigningPolicies: { + UntrustedArtifactOnDeployment: lambda.UntrustedArtifactOnDeployment.WARN, + }, + }); + }); + + test('with multiple signing profiles', () => { + const signingProfile1 = new signer.SigningProfile(stack, 'SigningProfile1', { platform: signer.Platform.AWS_LAMBDA_SHA384_ECDSA }); + const signingProfile2 = new signer.SigningProfile(stack, 'SigningProfile2', { platform: signer.Platform.AMAZON_FREE_RTOS_DEFAULT }); + const signingProfile3 = new signer.SigningProfile(stack, 'SigningProfile3', { platform: signer.Platform.AWS_IOT_DEVICE_MANAGEMENT_SHA256_ECDSA }); + new lambda.CodeSigningConfig(stack, 'CodeSigningConfig', { + signingProfiles: [signingProfile1, signingProfile2, signingProfile3], + }); + + expect(stack).toHaveResource('AWS::Lambda::CodeSigningConfig', { + AllowedPublishers: { + SigningProfileVersionArns: [ + { + 'Fn::GetAtt': [ + 'SigningProfile1D4191686', + 'ProfileVersionArn', + ], + }, + { + 'Fn::GetAtt': [ + 'SigningProfile2E013C934', + 'ProfileVersionArn', + ], + }, + { + 'Fn::GetAtt': [ + 'SigningProfile3A38DE231', + 'ProfileVersionArn', + ], + }, + ], + }, + }); + }); + + test('with description and with untrustedArtifactOnDeployment of "ENFORCE"', () => { + const platform = signer.Platform.AWS_LAMBDA_SHA384_ECDSA; + const signingProfile = new signer.SigningProfile(stack, 'SigningProfile', { platform }); + new lambda.CodeSigningConfig(stack, 'CodeSigningConfig', { + signingProfiles: [signingProfile], + untrustedArtifactOnDeployment: lambda.UntrustedArtifactOnDeployment.ENFORCE, + description: 'test description', + }); + + expect(stack).toHaveResource('AWS::Lambda::CodeSigningConfig', { + CodeSigningPolicies: { + UntrustedArtifactOnDeployment: lambda.UntrustedArtifactOnDeployment.ENFORCE, + }, + Description: 'test description', + }); + }); + + test('import does not create any resources', () => { + const codeSigningConfigId = 'aaa-xxxxxxxxxx'; + const codeSigningConfigArn = `arn:aws:lambda:::code-signing-config:${codeSigningConfigId}`; + const codeSigningConfig = lambda.CodeSigningConfig.fromCodeSigningConfigArn(stack, 'Imported', codeSigningConfigArn ); + + expect(codeSigningConfig.codeSigningConfigArn).toBe(codeSigningConfigArn); + expect(codeSigningConfig.codeSigningConfigId).toBe(codeSigningConfigId); + expect(stack).toCountResources('AWS::Lambda::CodeSigningConfig', 0); + }); + + test('fail import with malformed code signing config arn', () => { + const codeSigningConfigArn = 'arn:aws:lambda:::code-signing-config'; + + expect(() => lambda.CodeSigningConfig.fromCodeSigningConfigArn(stack, 'Imported', codeSigningConfigArn ) ).toThrow(/ARN must be in the format/); + }); +}); diff --git a/packages/@aws-cdk/aws-lambda/test/code.test.ts b/packages/@aws-cdk/aws-lambda/test/code.test.ts index abdbc0e080daa..91de07a17c5a6 100644 --- a/packages/@aws-cdk/aws-lambda/test/code.test.ts +++ b/packages/@aws-cdk/aws-lambda/test/code.test.ts @@ -329,6 +329,29 @@ describe('code', () => { }); }); }); + + describe('lambda.Code.fromDockerBuild', () => { + test('can use the result of a Docker build as an asset', () => { + // given + const stack = new cdk.Stack(); + stack.node.setContext(cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT, true); + + // when + new lambda.Function(stack, 'Fn', { + code: lambda.Code.fromDockerBuild(path.join(__dirname, 'docker-build-lambda')), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_12_X, + }); + + // then + expect(stack).toHaveResource('AWS::Lambda::Function', { + Metadata: { + [cxapi.ASSET_RESOURCE_METADATA_PATH_KEY]: 'asset.38cd320fa97b348accac88e48d9cede4923f7cab270ce794c95a665be83681a8', + [cxapi.ASSET_RESOURCE_METADATA_PROPERTY_KEY]: 'Code', + }, + }, ResourcePart.CompleteDefinition); + }); + }); }); function defineFunction(code: lambda.Code, runtime: lambda.Runtime = lambda.Runtime.NODEJS_10_X) { diff --git a/packages/@aws-cdk/aws-lambda/test/docker-build-lambda/Dockerfile b/packages/@aws-cdk/aws-lambda/test/docker-build-lambda/Dockerfile new file mode 100644 index 0000000000000..4643fde141850 --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/docker-build-lambda/Dockerfile @@ -0,0 +1,3 @@ +FROM public.ecr.aws/amazonlinux/amazonlinux:latest + +COPY index.js /asset diff --git a/packages/@aws-cdk/aws-lambda/test/docker-build-lambda/index.ts b/packages/@aws-cdk/aws-lambda/test/docker-build-lambda/index.ts new file mode 100644 index 0000000000000..cc867895b4efc --- /dev/null +++ b/packages/@aws-cdk/aws-lambda/test/docker-build-lambda/index.ts @@ -0,0 +1,5 @@ +/* eslint-disable no-console */ +export async function handler(event: any) { + console.log('Event: %j', event); + return event; +} diff --git a/packages/@aws-cdk/aws-lambda/test/function.test.ts b/packages/@aws-cdk/aws-lambda/test/function.test.ts index 51cfe70fd878c..50cf6b0c9b72b 100644 --- a/packages/@aws-cdk/aws-lambda/test/function.test.ts +++ b/packages/@aws-cdk/aws-lambda/test/function.test.ts @@ -9,6 +9,7 @@ import * as kms from '@aws-cdk/aws-kms'; import * as logs from '@aws-cdk/aws-logs'; import * as s3 from '@aws-cdk/aws-s3'; import * as sqs from '@aws-cdk/aws-sqs'; +import * as signer from '@aws-cdk/aws-signer'; import * as cdk from '@aws-cdk/core'; import * as constructs from 'constructs'; import * as _ from 'lodash'; @@ -2003,6 +2004,36 @@ describe('function', () => { }); }); }); + + describe('code signing config', () => { + test('default', () => { + const stack = new cdk.Stack(); + + const signingProfile = new signer.SigningProfile(stack, 'SigningProfile', { + platform: signer.Platform.AWS_LAMBDA_SHA384_ECDSA, + }); + + const codeSigningConfig = new lambda.CodeSigningConfig(stack, 'CodeSigningConfig', { + signingProfiles: [signingProfile], + }); + + new lambda.Function(stack, 'MyLambda', { + code: new lambda.InlineCode('foo'), + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_10_X, + codeSigningConfig, + }); + + expect(stack).toHaveResource('AWS::Lambda::Function', { + CodeSigningConfigArn: { + 'Fn::GetAtt': [ + 'CodeSigningConfigD8D41C10', + 'CodeSigningConfigArn', + ], + }, + }); + }); + }); }); function newTestLambda(scope: constructs.Construct) { diff --git a/packages/@aws-cdk/aws-logs/test/test.log-retention-provider.ts b/packages/@aws-cdk/aws-logs/test/test.log-retention-provider.ts index b13eebe9aabe2..a08ff060dc2a4 100644 --- a/packages/@aws-cdk/aws-logs/test/test.log-retention-provider.ts +++ b/packages/@aws-cdk/aws-logs/test/test.log-retention-provider.ts @@ -208,7 +208,7 @@ export = { }, async 'responds with FAILED on error'(test: Test) { - const createLogGroupFake = sinon.fake.rejects(new Error('UnkownError')); + const createLogGroupFake = sinon.fake.rejects(new Error('UnknownError')); AWS.mock('CloudWatchLogs', 'createLogGroup', createLogGroupFake); diff --git a/packages/@aws-cdk/aws-rds/test/instance.test.ts b/packages/@aws-cdk/aws-rds/test/instance.test.ts index ba14bf54bd399..497bfbea36c32 100644 --- a/packages/@aws-cdk/aws-rds/test/instance.test.ts +++ b/packages/@aws-cdk/aws-rds/test/instance.test.ts @@ -1274,4 +1274,4 @@ test.each([ DeletionPolicy: subnetValue, UpdateReplacePolicy: subnetValue, }, ResourcePart.CompleteDefinition); -}); +}); \ No newline at end of file diff --git a/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json b/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json index 8a05969830f29..0de11e4fd5c4f 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json +++ b/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json @@ -909,7 +909,7 @@ ] } }, - "InstanceAvailabilityAllowEventRuleawscdkrdsinstanceInstanceAvailabilityCE39A6A7B066AA0D": { + "InstanceAvailabilityAllowEventRuleawscdkrdsinstanceFunctionD515EE1969208105": { "Type": "AWS::Lambda::Permission", "Properties": { "Action": "lambda:InvokeFunction", diff --git a/packages/@aws-cdk/aws-s3-assets/README.md b/packages/@aws-cdk/aws-s3-assets/README.md index aab4c46d9c44d..7a751410a2b22 100644 --- a/packages/@aws-cdk/aws-s3-assets/README.md +++ b/packages/@aws-cdk/aws-s3-assets/README.md @@ -88,8 +88,8 @@ The following example uses custom asset bundling to convert a markdown file to h [Example of using asset bundling](./test/integ.assets.bundling.lit.ts). -The bundling docker image (`image`) can either come from a registry (`BundlingDockerImage.fromRegistry`) -or it can be built from a `Dockerfile` located inside your project (`BundlingDockerImage.fromAsset`). +The bundling docker image (`image`) can either come from a registry (`DockerImage.fromRegistry`) +or it can be built from a `Dockerfile` located inside your project (`DockerImage.fromBuild`). You can set the `CDK_DOCKER` environment variable in order to provide a custom docker program to execute. This may sometime be needed when building in @@ -114,7 +114,7 @@ new assets.Asset(this, 'BundledAsset', { }, }, // Docker bundling fallback - image: BundlingDockerImage.fromRegistry('alpine'), + image: DockerImage.fromRegistry('alpine'), entrypoint: ['/bin/sh', '-c'], command: ['bundle'], }, @@ -124,6 +124,27 @@ new assets.Asset(this, 'BundledAsset', { Although optional, it's recommended to provide a local bundling method which can greatly improve performance. +If the bundling output contains a single archive file (zip or jar) it will be +uploaded to S3 as-is and will not be zipped. Otherwise the contents of the +output directory will be zipped and the zip file will be uploaded to S3. This +is the default behavior for `bundling.outputType` (`BundlingOutput.AUTO_DISCOVER`). + +Use `BundlingOutput.NOT_ARCHIVED` if the bundling output must always be zipped: + +```ts +const asset = new assets.Asset(this, 'BundledAsset', { + path: '/path/to/asset', + bundling: { + image: DockerImage.fromRegistry('alpine'), + command: ['command-that-produces-an-archive.sh'], + outputType: BundlingOutput.NOT_ARCHIVED, // Bundling output will be zipped even though it produces a single archive file. + }, +}); +``` + +Use `BundlingOutput.ARCHIVED` if the bundling output contains a single archive file and +you don't want it to be zippped. + ## CloudFormation Resource Metadata > NOTE: This section is relevant for authors of AWS Resource Constructs. diff --git a/packages/@aws-cdk/aws-s3-assets/lib/asset.ts b/packages/@aws-cdk/aws-s3-assets/lib/asset.ts index 938778d1381f4..510834a61c634 100644 --- a/packages/@aws-cdk/aws-s3-assets/lib/asset.ts +++ b/packages/@aws-cdk/aws-s3-assets/lib/asset.ts @@ -1,4 +1,3 @@ -import * as fs from 'fs'; import * as path from 'path'; import * as assets from '@aws-cdk/assets'; import * as iam from '@aws-cdk/aws-iam'; @@ -13,8 +12,6 @@ import { toSymlinkFollow } from './compat'; // eslint-disable-next-line no-duplicate-imports, import/order import { Construct as CoreConstruct } from '@aws-cdk/core'; -const ARCHIVE_EXTENSIONS = ['.zip', '.jar']; - export interface AssetOptions extends assets.CopyOptions, cdk.AssetOptions { /** * A list of principals that should be able to read this asset from S3. @@ -139,17 +136,12 @@ export class Asset extends CoreConstruct implements cdk.IAsset { this.assetPath = staging.relativeStagedPath(stack); - const packaging = determinePackaging(staging.sourcePath); - - this.isFile = packaging === cdk.FileAssetPackaging.FILE; + this.isFile = staging.packaging === cdk.FileAssetPackaging.FILE; - // sets isZipArchive based on the type of packaging and file extension - this.isZipArchive = packaging === cdk.FileAssetPackaging.ZIP_DIRECTORY - ? true - : ARCHIVE_EXTENSIONS.some(ext => staging.sourcePath.toLowerCase().endsWith(ext)); + this.isZipArchive = staging.isArchive; const location = stack.synthesizer.addFileAsset({ - packaging, + packaging: staging.packaging, sourceHash: this.sourceHash, fileName: this.assetPath, }); @@ -210,19 +202,3 @@ export class Asset extends CoreConstruct implements cdk.IAsset { this.bucket.grantRead(grantee); } } - -function determinePackaging(assetPath: string): cdk.FileAssetPackaging { - if (!fs.existsSync(assetPath)) { - throw new Error(`Cannot find asset at ${assetPath}`); - } - - if (fs.statSync(assetPath).isDirectory()) { - return cdk.FileAssetPackaging.ZIP_DIRECTORY; - } - - if (fs.statSync(assetPath).isFile()) { - return cdk.FileAssetPackaging.FILE; - } - - throw new Error(`Asset ${assetPath} is expected to be either a directory or a regular file`); -} diff --git a/packages/@aws-cdk/aws-s3/lib/bucket.ts b/packages/@aws-cdk/aws-s3/lib/bucket.ts index 1edbb9c7a5040..630aa9f02bfcc 100644 --- a/packages/@aws-cdk/aws-s3/lib/bucket.ts +++ b/packages/@aws-cdk/aws-s3/lib/bucket.ts @@ -1830,6 +1830,7 @@ export class Bucket extends BucketBase { const provider = CustomResourceProvider.getOrCreateProvider(this, AUTO_DELETE_OBJECTS_RESOURCE_TYPE, { codeDirectory: path.join(__dirname, 'auto-delete-objects-handler'), runtime: CustomResourceProviderRuntime.NODEJS_12, + description: `Lambda function for auto-deleting objects in ${this.bucketName} S3 bucket.`, }); // Use a bucket policy to allow the custom resource to delete diff --git a/packages/@aws-cdk/aws-s3/test/integ.bucket-auto-delete-objects.expected.json b/packages/@aws-cdk/aws-s3/test/integ.bucket-auto-delete-objects.expected.json index d9f263a8d840d..831d072339649 100644 --- a/packages/@aws-cdk/aws-s3/test/integ.bucket-auto-delete-objects.expected.json +++ b/packages/@aws-cdk/aws-s3/test/integ.bucket-auto-delete-objects.expected.json @@ -148,7 +148,19 @@ "Arn" ] }, - "Runtime": "nodejs12.x" + "Runtime": "nodejs12.x", + "Description": { + "Fn::Join": [ + "", + [ + "Lambda function for auto-deleting objects in ", + { + "Ref": "Bucket83908E77" + }, + " S3 bucket." + ] + ] + } }, "DependsOn": [ "CustomS3AutoDeleteObjectsCustomResourceProviderRole3B1BD092" diff --git a/packages/@aws-cdk/aws-signer/README.md b/packages/@aws-cdk/aws-signer/README.md index 5482a0b23c900..925261fd4be52 100644 --- a/packages/@aws-cdk/aws-signer/README.md +++ b/packages/@aws-cdk/aws-signer/README.md @@ -9,12 +9,55 @@ > > [CFN Resources]: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. +> They are subject to non-backward compatible changes or removal in any future version. These are +> not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be +> announced in the release notes. This means that while you may use them, you may need to update +> your source code when upgrading to a newer version of this package. + --- -This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. +AWS Signer is a fully managed code-signing service to ensure the trust and integrity of your code. Organizations validate code against +a digital signature to confirm that the code is unaltered and from a trusted publisher. For more information, see [What Is AWS +Signer?](https://docs.aws.amazon.com/signer/latest/developerguide/Welcome.html) + +## Table of Contents + +- [Signing Platform](#signing-platform) +- [Signing Profile](#signing-profile) + +## Signing Platform + +A signing platform is a predefined set of instructions that specifies the signature format and signing algorithms that AWS Signer should use +to sign a zip file. For more information go to [Signing Platforms in AWS Signer](https://docs.aws.amazon.com/signer/latest/developerguide/gs-platform.html). + +AWS Signer provides a pre-defined set of signing platforms. They are available in the CDK as - ```ts -import signer = require('@aws-cdk/aws-signer'); +Platform.AWS_IOT_DEVICE_MANAGEMENT_SHA256_ECDSA +Platform.AWS_LAMBDA_SHA384_ECDSA +Platform.AMAZON_FREE_RTOS_TI_CC3220SF +Platform.AMAZON_FREE_RTOS_DEFAULT ``` + +## Signing Profile + +A signing profile is a code-signing template that can be used to pre-define the signature specifications for a signing job. +A signing profile includes a signing platform to designate the file type to be signed, the signature format, and the signature algorithms. +For more information, visit [Signing Profiles in AWS Signer](https://docs.aws.amazon.com/signer/latest/developerguide/gs-profile.html). + +The following code sets up a signing profile for signing lambda code bundles - + +```ts +import * as signer from '@aws-cdk/aws-signer'; + +const signingProfile = new signer.SigningProfile(this, 'SigningProfile', { + platform: signer.Platform.AWS_LAMBDA_SHA384_ECDSA, +} ); +``` + +A signing profile is valid by default for 135 months. This can be modified by specifying the `signatureValidityPeriod` property. diff --git a/packages/@aws-cdk/aws-signer/lib/index.ts b/packages/@aws-cdk/aws-signer/lib/index.ts index 9c56379e86c19..090dec21fac3b 100644 --- a/packages/@aws-cdk/aws-signer/lib/index.ts +++ b/packages/@aws-cdk/aws-signer/lib/index.ts @@ -1,2 +1,3 @@ // AWS::Signer CloudFormation Resources: export * from './signer.generated'; +export * from './signing-profile'; diff --git a/packages/@aws-cdk/aws-signer/lib/signing-profile.ts b/packages/@aws-cdk/aws-signer/lib/signing-profile.ts new file mode 100644 index 0000000000000..8a0d14c3d194a --- /dev/null +++ b/packages/@aws-cdk/aws-signer/lib/signing-profile.ts @@ -0,0 +1,178 @@ +import { Duration, IResource, Resource, Stack } from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnSigningProfile } from './signer.generated'; + +/** + * Platforms that are allowed with signing config. + * @see https://docs.aws.amazon.com/signer/latest/developerguide/gs-platform.html + */ +export class Platform { + /** + * Specification of signature format and signing algorithms for AWS IoT Device. + */ + public static readonly AWS_IOT_DEVICE_MANAGEMENT_SHA256_ECDSA = new Platform('AWSIoTDeviceManagement-SHA256-ECDSA'); + + /** + * Specification of signature format and signing algorithms for AWS Lambda. + */ + public static readonly AWS_LAMBDA_SHA384_ECDSA = new Platform('AWSLambda-SHA384-ECDSA'); + + /** + * Specification of signature format and signing algorithms with + * SHA1 hash and RSA encryption for Amazon FreeRTOS. + */ + public static readonly AMAZON_FREE_RTOS_TI_CC3220SF = new Platform('AmazonFreeRTOS-TI-CC3220SF'); + + /** + * Specification of signature format and signing algorithms with + * SHA256 hash and ECDSA encryption for Amazon FreeRTOS. + */ + public static readonly AMAZON_FREE_RTOS_DEFAULT = new Platform('AmazonFreeRTOS-Default'); + + /** + * The id of signing platform. + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-signer-signingprofile.html#cfn-signer-signingprofile-platformid + */ + public readonly platformId: string; + + private constructor(platformId: string) { + this.platformId = platformId; + } +} + +/** + * A Signer Profile + */ +export interface ISigningProfile extends IResource { + /** + * The ARN of the signing profile. + * @attribute + */ + readonly signingProfileArn: string; + + /** + * The name of signing profile. + * @attribute ProfileName + */ + readonly signingProfileName: string; + + /** + * The version of signing profile. + * @attribute ProfileVersion + */ + readonly signingProfileVersion: string; + + /** + * The ARN of signing profile version. + * @attribute ProfileVersionArn + */ + readonly signingProfileVersionArn: string; +} + +/** + * Construction properties for a Signing Profile object + */ +export interface SigningProfileProps { + /** + * The Signing Platform available for signing profile. + * @see https://docs.aws.amazon.com/signer/latest/developerguide/gs-platform.html + */ + readonly platform: Platform; + + /** + * The validity period for signatures generated using + * this signing profile. + * + * @default - 135 months + */ + readonly signatureValidity?: Duration; + + /** + * Physical name of this Signing Profile. + * + * @default - Assigned by CloudFormation (recommended). + */ + readonly signingProfileName?: string; +} + +/** + * A reference to a Signing Profile + */ +export interface SigningProfileAttributes { + /** + * The name of signing profile. + */ + readonly signingProfileName: string; + + /** + * The version of signing profile. + */ + readonly signingProfileVersion: string; +} + +/** + * Defines a Signing Profile. + * + * @resource AWS::Signer::SigningProfile + */ +export class SigningProfile extends Resource implements ISigningProfile { + /** + * Creates a Signing Profile construct that represents an external Signing Profile. + * + * @param scope The parent creating construct (usually `this`). + * @param id The construct's name. + * @param attrs A `SigningProfileAttributes` object. + */ + public static fromSigningProfileAttributes( scope: Construct, id: string, attrs: SigningProfileAttributes): ISigningProfile { + class Import extends Resource implements ISigningProfile { + public readonly signingProfileArn: string; + public readonly signingProfileName = attrs.signingProfileName; + public readonly signingProfileVersion = attrs.signingProfileVersion; + public readonly signingProfileVersionArn: string; + + constructor(signingProfileArn: string, signingProfileProfileVersionArn: string) { + super(scope, id); + this.signingProfileArn = signingProfileArn; + this.signingProfileVersionArn = signingProfileProfileVersionArn; + } + } + const signingProfileArn = Stack.of(scope).formatArn({ + service: 'signer', + resource: '', + resourceName: `/signing-profiles/${attrs.signingProfileName}`, + }); + const SigningProfileVersionArn = Stack.of(scope).formatArn({ + service: 'signer', + resource: '', + resourceName: `/signing-profiles/${attrs.signingProfileName}/${attrs.signingProfileVersion}`, + }); + return new Import(signingProfileArn, SigningProfileVersionArn); + } + + public readonly signingProfileArn: string; + public readonly signingProfileName: string; + public readonly signingProfileVersion: string; + public readonly signingProfileVersionArn: string; + + constructor(scope: Construct, id: string, props: SigningProfileProps) { + super(scope, id, { + physicalName: props.signingProfileName, + }); + + const resource = new CfnSigningProfile( this, 'Resource', { + platformId: props.platform.platformId, + signatureValidityPeriod: props.signatureValidity ? { + type: 'DAYS', + value: props.signatureValidity?.toDays(), + } : { + type: 'MONTHS', + value: 135, + }, + } ); + + this.signingProfileArn = resource.attrArn; + this.signingProfileName = resource.attrProfileName; + this.signingProfileVersion = resource.attrProfileVersion; + this.signingProfileVersionArn = resource.attrProfileVersionArn; + } +} diff --git a/packages/@aws-cdk/aws-signer/package.json b/packages/@aws-cdk/aws-signer/package.json index 40a8f5872b5b1..f01a984dfdd28 100644 --- a/packages/@aws-cdk/aws-signer/package.json +++ b/packages/@aws-cdk/aws-signer/package.json @@ -79,16 +79,18 @@ "pkglint": "0.0.0" }, "dependencies": { - "@aws-cdk/core": "0.0.0" + "@aws-cdk/core": "0.0.0", + "constructs": "^3.2.0" }, "peerDependencies": { - "@aws-cdk/core": "0.0.0" + "@aws-cdk/core": "0.0.0", + "constructs": "^3.2.0" }, "engines": { "node": ">= 10.13.0 <13 || >=13.7.0" }, "stability": "experimental", - "maturity": "cfn-only", + "maturity": "experimental", "awscdkio": { "announce": false }, diff --git a/packages/@aws-cdk/aws-signer/test/signer.test.ts b/packages/@aws-cdk/aws-signer/test/signer.test.ts deleted file mode 100644 index e394ef336bfb4..0000000000000 --- a/packages/@aws-cdk/aws-signer/test/signer.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import '@aws-cdk/assert/jest'; -import {} from '../lib'; - -test('No tests are specified for this package', () => { - expect(true).toBe(true); -}); diff --git a/packages/@aws-cdk/aws-signer/test/signing-profile.test.ts b/packages/@aws-cdk/aws-signer/test/signing-profile.test.ts new file mode 100644 index 0000000000000..6148a6be70bda --- /dev/null +++ b/packages/@aws-cdk/aws-signer/test/signing-profile.test.ts @@ -0,0 +1,115 @@ +import '@aws-cdk/assert/jest'; +import * as cdk from '@aws-cdk/core'; +import * as signer from '../lib'; + +let app: cdk.App; +let stack: cdk.Stack; +beforeEach( () => { + app = new cdk.App( {} ); + stack = new cdk.Stack( app ); +} ); + +describe('signing profile', () => { + test( 'default', () => { + const platform = signer.Platform.AWS_LAMBDA_SHA384_ECDSA; + new signer.SigningProfile( stack, 'SigningProfile', { platform } ); + + expect(stack).toHaveResource('AWS::Signer::SigningProfile', { + PlatformId: platform.platformId, + SignatureValidityPeriod: { + Type: 'MONTHS', + Value: 135, + }, + }); + }); + + test( 'default with signature validity period', () => { + const platform = signer.Platform.AWS_LAMBDA_SHA384_ECDSA; + new signer.SigningProfile( stack, 'SigningProfile', { + platform, + signatureValidity: cdk.Duration.days( 7 ), + } ); + + expect(stack).toHaveResource('AWS::Signer::SigningProfile', { + PlatformId: platform.platformId, + SignatureValidityPeriod: { + Type: 'DAYS', + Value: 7, + }, + }); + }); + + test( 'default with some tags', () => { + const platform = signer.Platform.AWS_LAMBDA_SHA384_ECDSA; + const signing = new signer.SigningProfile( stack, 'SigningProfile', { platform } ); + + cdk.Tags.of(signing).add('tag1', 'value1'); + cdk.Tags.of(signing).add('tag2', 'value2'); + cdk.Tags.of(signing).add('tag3', ''); + + expect(stack).toHaveResource('AWS::Signer::SigningProfile', { + PlatformId: platform.platformId, + SignatureValidityPeriod: { + Type: 'MONTHS', + Value: 135, + }, + Tags: [ + { + Key: 'tag1', + Value: 'value1', + }, + { + Key: 'tag2', + Value: 'value2', + }, + { + Key: 'tag3', + Value: '', + }, + ], + }); + }); + + describe('import', () => { + test('from signingProfileProfileName and signingProfileProfileVersion', () => { + const signingProfileName = 'test'; + const signingProfileVersion = 'xxxxxxxx'; + const signingProfile = signer.SigningProfile.fromSigningProfileAttributes(stack, 'Imported', { + signingProfileName, + signingProfileVersion, + }); + + expect(stack.resolve(signingProfile.signingProfileArn)).toStrictEqual( + { + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':signer:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + `://signing-profiles/${signingProfileName}`, + ], + ], + }, + ); + expect(stack.resolve(signingProfile.signingProfileVersionArn)).toStrictEqual({ + 'Fn::Join': [ + '', + [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':signer:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + `://signing-profiles/${signingProfileName}/${signingProfileVersion}`, + ], + ], + }); + expect(stack).toMatchTemplate({}); + }); + } ); +}); diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/README.md b/packages/@aws-cdk/aws-stepfunctions-tasks/README.md index 38305b270da69..d7c2d1498394d 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/README.md +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/README.md @@ -189,7 +189,7 @@ const convertToSeconds = new tasks.EvaluateExpression(this, 'Convert to seconds' const createMessage = new tasks.EvaluateExpression(this, 'Create message', { // Note: this is a string inside a string. expression: '`Now waiting ${$.waitSeconds} seconds...`', - runtime: lambda.Runtime.NODEJS_10_X, + runtime: lambda.Runtime.NODEJS_14_X, resultPath: '$.message', }); @@ -212,9 +212,8 @@ new sfn.StateMachine(this, 'StateMachine', { ``` The `EvaluateExpression` supports a `runtime` prop to specify the Lambda -runtime to use to evaluate the expression. Currently, the only runtime -supported is `lambda.Runtime.NODEJS_10_X`. - +runtime to use to evaluate the expression. Currently, only runtimes +of the Node.js family are supported. ## Athena diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/eval-nodejs10.x-handler/index.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/eval-nodejs-handler/index.ts similarity index 100% rename from packages/@aws-cdk/aws-stepfunctions-tasks/lib/eval-nodejs10.x-handler/index.ts rename to packages/@aws-cdk/aws-stepfunctions-tasks/lib/eval-nodejs-handler/index.ts diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/evaluate-expression.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/evaluate-expression.ts index 45457a1c377c8..64c25d5e3dd3a 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/lib/evaluate-expression.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/lib/evaluate-expression.ts @@ -20,7 +20,7 @@ export interface EvaluateExpressionProps extends sfn.TaskStateBaseProps { /** * The runtime language to use to evaluate the expression. * - * @default lambda.Runtime.NODEJS_10_X + * @default lambda.Runtime.NODEJS_14_X */ readonly runtime?: lambda.Runtime; } @@ -58,7 +58,7 @@ export class EvaluateExpression extends sfn.TaskStateBase { constructor(scope: Construct, id: string, private readonly props: EvaluateExpressionProps) { super(scope, id, props); - this.evalFn = createEvalFn(this.props.runtime || lambda.Runtime.NODEJS_10_X, this); + this.evalFn = createEvalFn(this.props.runtime ?? lambda.Runtime.NODEJS_14_X, this); this.taskPolicies = [ new iam.PolicyStatement({ @@ -97,17 +97,18 @@ export class EvaluateExpression extends sfn.TaskStateBase { } function createEvalFn(runtime: lambda.Runtime, scope: Construct) { - const code = lambda.Code.asset(path.join(__dirname, `eval-${runtime.name}-handler`)); const lambdaPurpose = 'Eval'; switch (runtime) { + case lambda.Runtime.NODEJS_14_X: + case lambda.Runtime.NODEJS_12_X: case lambda.Runtime.NODEJS_10_X: return new lambda.SingletonFunction(scope, 'EvalFunction', { runtime, handler: 'index.handler', uuid: 'a0d2ce44-871b-4e74-87a1-f5e63d7c3bdc', lambdaPurpose, - code, + code: lambda.Code.fromAsset(path.join(__dirname, 'eval-nodejs-handler')), }); // TODO: implement other runtimes default: diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/eval-nodejs10.x-handler.test.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/eval-nodejs-handler.test.ts similarity index 96% rename from packages/@aws-cdk/aws-stepfunctions-tasks/test/eval-nodejs10.x-handler.test.ts rename to packages/@aws-cdk/aws-stepfunctions-tasks/test/eval-nodejs-handler.test.ts index d42be11d7aaa4..8d69e9d3b8105 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/test/eval-nodejs10.x-handler.test.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/eval-nodejs-handler.test.ts @@ -1,5 +1,5 @@ import { Event } from '../lib'; -import { handler } from '../lib/eval-nodejs10.x-handler'; +import { handler } from '../lib/eval-nodejs-handler'; test('with numbers', async () => { // GIVEN diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/evaluate-expression.test.ts b/packages/@aws-cdk/aws-stepfunctions-tasks/test/evaluate-expression.test.ts index 679e817dfbead..7a2aa196b3de2 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/test/evaluate-expression.test.ts +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/evaluate-expression.test.ts @@ -34,7 +34,7 @@ test('Eval with Node.js', () => { }); expect(stack).toHaveResource('AWS::Lambda::Function', { - Runtime: 'nodejs10.x', + Runtime: 'nodejs14.x', }); }); diff --git a/packages/@aws-cdk/aws-stepfunctions-tasks/test/integ.evaluate-expression.expected.json b/packages/@aws-cdk/aws-stepfunctions-tasks/test/integ.evaluate-expression.expected.json index efdf3878e67e2..c48b04a826783 100644 --- a/packages/@aws-cdk/aws-stepfunctions-tasks/test/integ.evaluate-expression.expected.json +++ b/packages/@aws-cdk/aws-stepfunctions-tasks/test/integ.evaluate-expression.expected.json @@ -36,7 +36,7 @@ "Properties": { "Code": { "S3Bucket": { - "Ref": "AssetParameters640b7d3e1a6ff78c1cad25c2d7396d04c74d6eee31b116f4c86d910338d480d0S3BucketA16CB30E" + "Ref": "AssetParametersbc320c7bd6a0eba90db647aa586cf65548560a54c141153fdc12f22eb3b2626bS3Bucket743A2950" }, "S3Key": { "Fn::Join": [ @@ -49,7 +49,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters640b7d3e1a6ff78c1cad25c2d7396d04c74d6eee31b116f4c86d910338d480d0S3VersionKey102DBBD9" + "Ref": "AssetParametersbc320c7bd6a0eba90db647aa586cf65548560a54c141153fdc12f22eb3b2626bS3VersionKey2DBCB833" } ] } @@ -62,7 +62,7 @@ "Fn::Split": [ "||", { - "Ref": "AssetParameters640b7d3e1a6ff78c1cad25c2d7396d04c74d6eee31b116f4c86d910338d480d0S3VersionKey102DBBD9" + "Ref": "AssetParametersbc320c7bd6a0eba90db647aa586cf65548560a54c141153fdc12f22eb3b2626bS3VersionKey2DBCB833" } ] } @@ -72,14 +72,14 @@ ] } }, - "Handler": "index.handler", "Role": { "Fn::GetAtt": [ "Evala0d2ce44871b4e7487a1f5e63d7c3bdcServiceRoleDC85DDD3", "Arn" ] }, - "Runtime": "nodejs10.x" + "Handler": "index.handler", + "Runtime": "nodejs14.x" }, "DependsOn": [ "Evala0d2ce44871b4e7487a1f5e63d7c3bdcServiceRoleDC85DDD3" @@ -185,17 +185,17 @@ } }, "Parameters": { - "AssetParameters640b7d3e1a6ff78c1cad25c2d7396d04c74d6eee31b116f4c86d910338d480d0S3BucketA16CB30E": { + "AssetParametersbc320c7bd6a0eba90db647aa586cf65548560a54c141153fdc12f22eb3b2626bS3Bucket743A2950": { "Type": "String", - "Description": "S3 bucket for asset \"640b7d3e1a6ff78c1cad25c2d7396d04c74d6eee31b116f4c86d910338d480d0\"" + "Description": "S3 bucket for asset \"bc320c7bd6a0eba90db647aa586cf65548560a54c141153fdc12f22eb3b2626b\"" }, - "AssetParameters640b7d3e1a6ff78c1cad25c2d7396d04c74d6eee31b116f4c86d910338d480d0S3VersionKey102DBBD9": { + "AssetParametersbc320c7bd6a0eba90db647aa586cf65548560a54c141153fdc12f22eb3b2626bS3VersionKey2DBCB833": { "Type": "String", - "Description": "S3 key for asset version \"640b7d3e1a6ff78c1cad25c2d7396d04c74d6eee31b116f4c86d910338d480d0\"" + "Description": "S3 key for asset version \"bc320c7bd6a0eba90db647aa586cf65548560a54c141153fdc12f22eb3b2626b\"" }, - "AssetParameters640b7d3e1a6ff78c1cad25c2d7396d04c74d6eee31b116f4c86d910338d480d0ArtifactHash43D553D7": { + "AssetParametersbc320c7bd6a0eba90db647aa586cf65548560a54c141153fdc12f22eb3b2626bArtifactHashAD6C554B": { "Type": "String", - "Description": "Artifact hash for asset \"640b7d3e1a6ff78c1cad25c2d7396d04c74d6eee31b116f4c86d910338d480d0\"" + "Description": "Artifact hash for asset \"bc320c7bd6a0eba90db647aa586cf65548560a54c141153fdc12f22eb3b2626b\"" } }, "Outputs": { @@ -205,4 +205,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/README.md b/packages/@aws-cdk/core/README.md index 0e0b38943a803..714e7139f0807 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -428,6 +428,7 @@ stack-unique identifier and returns the service token: const serviceToken = CustomResourceProvider.getOrCreate(this, 'Custom::MyCustomResourceType', { codeDirectory: `${__dirname}/my-handler`, runtime: CustomResourceProviderRuntime.NODEJS_12, // currently the only supported runtime + description: "Lambda function created by the custom resource provider", }); new CustomResource(this, 'MyResource', { diff --git a/packages/@aws-cdk/core/lib/asset-staging.ts b/packages/@aws-cdk/core/lib/asset-staging.ts index 66c65e3d14864..6a34bd9b4b1ac 100644 --- a/packages/@aws-cdk/core/lib/asset-staging.ts +++ b/packages/@aws-cdk/core/lib/asset-staging.ts @@ -5,8 +5,8 @@ import * as cxapi from '@aws-cdk/cx-api'; import { Construct } from 'constructs'; import * as fs from 'fs-extra'; import * as minimatch from 'minimatch'; -import { AssetHashType, AssetOptions } from './assets'; -import { BundlingOptions } from './bundling'; +import { AssetHashType, AssetOptions, FileAssetPackaging } from './assets'; +import { BundlingOptions, BundlingOutput } from './bundling'; import { FileSystem, FingerprintOptions } from './fs'; import { Names } from './names'; import { Cache } from './private/cache'; @@ -17,6 +17,8 @@ import { Stage } from './stage'; // eslint-disable-next-line import { Construct as CoreConstruct } from './construct-compat'; +const ARCHIVE_EXTENSIONS = ['.zip', '.jar']; + /** * A previously staged asset */ @@ -30,6 +32,16 @@ interface StagedAsset { * The hash we used previously */ readonly assetHash: string; + + /** + * The packaging of the asset + */ + readonly packaging: FileAssetPackaging, + + /** + * Whether this asset is an archive + */ + readonly isArchive: boolean; } /** @@ -124,6 +136,16 @@ export class AssetStaging extends CoreConstruct { */ public readonly assetHash: string; + /** + * How this asset should be packaged. + */ + public readonly packaging: FileAssetPackaging; + + /** + * Whether this asset is an archive (zip or jar). + */ + public readonly isArchive: boolean; + private readonly fingerprintOptions: FingerprintOptions; private readonly hashType: AssetHashType; @@ -138,12 +160,20 @@ export class AssetStaging extends CoreConstruct { private readonly cacheKey: string; + private readonly sourceStats: fs.Stats; + constructor(scope: Construct, id: string, props: AssetStagingProps) { super(scope, id); this.sourcePath = path.resolve(props.sourcePath); this.fingerprintOptions = props; + if (!fs.existsSync(this.sourcePath)) { + throw new Error(`Cannot find asset at ${this.sourcePath}`); + } + + this.sourceStats = fs.statSync(this.sourcePath); + const outdir = Stage.of(this)?.assetOutdir; if (!outdir) { throw new Error('unable to determine cloud assembly asset output directory. Assets must be defined indirectly within a "Stage" or an "App" scope'); @@ -192,6 +222,8 @@ export class AssetStaging extends CoreConstruct { this.stagedPath = staged.stagedPath; this.absoluteStagedPath = staged.stagedPath; this.assetHash = staged.assetHash; + this.packaging = staged.packaging; + this.isArchive = staged.isArchive; } /** @@ -248,8 +280,18 @@ export class AssetStaging extends CoreConstruct { ? this.sourcePath : path.resolve(this.assetOutdir, renderAssetFilename(assetHash, path.extname(this.sourcePath))); + if (!this.sourceStats.isDirectory() && !this.sourceStats.isFile()) { + throw new Error(`Asset ${this.sourcePath} is expected to be either a directory or a regular file`); + } + this.stageAsset(this.sourcePath, stagedPath, 'copy'); - return { assetHash, stagedPath }; + + return { + assetHash, + stagedPath, + packaging: this.sourceStats.isDirectory() ? FileAssetPackaging.ZIP_DIRECTORY : FileAssetPackaging.FILE, + isArchive: this.sourceStats.isDirectory() || ARCHIVE_EXTENSIONS.includes(path.extname(this.sourcePath).toLowerCase()), + }; } /** @@ -258,6 +300,10 @@ export class AssetStaging extends CoreConstruct { * Optionally skip, in which case we pretend we did something but we don't really. */ private stageByBundling(bundling: BundlingOptions, skip: boolean): StagedAsset { + if (!this.sourceStats.isDirectory()) { + throw new Error(`Asset ${this.sourcePath} is expected to be a directory when bundling`); + } + if (skip) { // We should have bundled, but didn't to save time. Still pretend to have a hash. // If the asset uses OUTPUT or BUNDLE, we use a CUSTOM hash to avoid fingerprinting @@ -270,6 +316,8 @@ export class AssetStaging extends CoreConstruct { return { assetHash: this.calculateHash(hashType, bundling), stagedPath: this.sourcePath, + packaging: FileAssetPackaging.ZIP_DIRECTORY, + isArchive: true, }; } @@ -281,12 +329,21 @@ export class AssetStaging extends CoreConstruct { const bundleDir = this.determineBundleDir(this.assetOutdir, assetHash); this.bundle(bundling, bundleDir); - // Calculate assetHash afterwards if we still must - assetHash = assetHash ?? this.calculateHash(this.hashType, bundling, bundleDir); - const stagedPath = path.resolve(this.assetOutdir, renderAssetFilename(assetHash)); + // Check bundling output content and determine if we will need to archive + const bundlingOutputType = bundling.outputType ?? BundlingOutput.AUTO_DISCOVER; + const bundledAsset = determineBundledAsset(bundleDir, bundlingOutputType); - this.stageAsset(bundleDir, stagedPath, 'move'); - return { assetHash, stagedPath }; + // Calculate assetHash afterwards if we still must + assetHash = assetHash ?? this.calculateHash(this.hashType, bundling, bundledAsset.path); + const stagedPath = path.resolve(this.assetOutdir, renderAssetFilename(assetHash, bundledAsset.extension)); + + this.stageAsset(bundledAsset.path, stagedPath, 'move'); + return { + assetHash, + stagedPath, + packaging: bundledAsset.packaging, + isArchive: true, // bundling always produces an archive + }; } /** @@ -320,10 +377,9 @@ export class AssetStaging extends CoreConstruct { } // Copy file/directory to staging directory - const stat = fs.statSync(sourcePath); - if (stat.isFile()) { + if (this.sourceStats.isFile()) { fs.copyFileSync(sourcePath, targetPath); - } else if (stat.isDirectory()) { + } else if (this.sourceStats.isDirectory()) { fs.mkdirSync(targetPath); FileSystem.copyDirectory(sourcePath, targetPath, this.fingerprintOptions); } else { @@ -502,3 +558,57 @@ function sortObject(object: { [key: string]: any }): { [key: string]: any } { } return ret; } + +/** + * Returns the single archive file of a directory or undefined + */ +function singleArchiveFile(directory: string): string | undefined { + if (!fs.existsSync(directory)) { + throw new Error(`Directory ${directory} does not exist.`); + } + + if (!fs.statSync(directory).isDirectory()) { + throw new Error(`${directory} is not a directory.`); + } + + const content = fs.readdirSync(directory); + if (content.length === 1) { + const file = path.join(directory, content[0]); + const extension = path.extname(content[0]).toLowerCase(); + if (fs.statSync(file).isFile() && ARCHIVE_EXTENSIONS.includes(extension)) { + return file; + } + } + + return undefined; +} + +interface BundledAsset { + path: string, + packaging: FileAssetPackaging, + extension?: string +} + +/** + * Returns the bundled asset to use based on the content of the bundle directory + * and the type of output. + */ +function determineBundledAsset(bundleDir: string, outputType: BundlingOutput): BundledAsset { + const archiveFile = singleArchiveFile(bundleDir); + + // auto-discover means that if there is an archive file, we take it as the + // bundle, otherwise, we will archive here. + if (outputType === BundlingOutput.AUTO_DISCOVER) { + outputType = archiveFile ? BundlingOutput.ARCHIVED : BundlingOutput.NOT_ARCHIVED; + } + + switch (outputType) { + case BundlingOutput.NOT_ARCHIVED: + return { path: bundleDir, packaging: FileAssetPackaging.ZIP_DIRECTORY }; + case BundlingOutput.ARCHIVED: + if (!archiveFile) { + throw new Error('Bundling output directory is expected to include only a single .zip or .jar file when `output` is set to `ARCHIVED`'); + } + return { path: archiveFile, packaging: FileAssetPackaging.FILE, extension: path.extname(archiveFile) }; + } +} diff --git a/packages/@aws-cdk/core/lib/bundling.ts b/packages/@aws-cdk/core/lib/bundling.ts index 0179b07d2f8f3..e3c1458aa0ab9 100644 --- a/packages/@aws-cdk/core/lib/bundling.ts +++ b/packages/@aws-cdk/core/lib/bundling.ts @@ -80,6 +80,41 @@ export interface BundlingOptions { * @experimental */ readonly local?: ILocalBundling; + + /** + * The type of output that this bundling operation is producing. + * + * @default BundlingOutput.AUTO_DISCOVER + * + * @experimental + */ + readonly outputType?: BundlingOutput; +} + +/** + * The type of output that a bundling operation is producing. + * + * @experimental + */ +export enum BundlingOutput { + /** + * The bundling output directory includes a single .zip or .jar file which + * will be used as the final bundle. If the output directory does not + * include exactly a single archive, bundling will fail. + */ + ARCHIVED = 'archived', + + /** + * The bundling output directory contains one or more files which will be + * archived and uploaded as a .zip file to S3. + */ + NOT_ARCHIVED = 'not-archived', + + /** + * If the bundling output directory contains a single archive file (zip or jar) + * it will be used as the bundle output as-is. Otherwise all the files in the bundling output directory will be zipped. + */ + AUTO_DISCOVER = 'auto-discover', } /** @@ -101,6 +136,8 @@ export interface ILocalBundling { /** * A Docker image used for asset bundling + * + * @deprecated use DockerImage */ export class BundlingDockerImage { /** @@ -117,6 +154,8 @@ export class BundlingDockerImage { * * @param path The path to the directory containing the Docker file * @param options Docker build options + * + * @deprecated use DockerImage.fromBuild() */ public static fromAsset(path: string, options: DockerBuildOptions = {}) { const buildArgs = options.buildArgs || {}; @@ -149,7 +188,7 @@ export class BundlingDockerImage { } /** @param image The Docker image */ - private constructor(public readonly image: string, private readonly _imageHash?: string) {} + protected constructor(public readonly image: string, private readonly _imageHash?: string) {} /** * Provides a stable representation of this image for JSON serialization. @@ -197,10 +236,16 @@ export class BundlingDockerImage { } /** - * Copies a file or directory out of the Docker image to the local filesystem + * Copies a file or directory out of the Docker image to the local filesystem. + * + * If `outputPath` is omitted the destination path is a temporary directory. + * + * @param imagePath the path in the Docker image + * @param outputPath the destination path for the copy operation + * @returns the destination path */ - public cp(imagePath: string, outputPath: string) { - const { stdout } = dockerExec(['create', this.image]); + public cp(imagePath: string, outputPath?: string): string { + const { stdout } = dockerExec(['create', this.image], {}); // Empty options to avoid stdout redirect here const match = stdout.toString().match(/([0-9a-f]{16,})/); if (!match) { throw new Error('Failed to extract container ID from Docker create output'); @@ -208,16 +253,33 @@ export class BundlingDockerImage { const containerId = match[1]; const containerPath = `${containerId}:${imagePath}`; + const destPath = outputPath ?? FileSystem.mkdtemp('cdk-docker-cp-'); try { - dockerExec(['cp', containerPath, outputPath]); + dockerExec(['cp', containerPath, destPath]); + return destPath; } catch (err) { - throw new Error(`Failed to copy files from ${containerPath} to ${outputPath}: ${err}`); + throw new Error(`Failed to copy files from ${containerPath} to ${destPath}: ${err}`); } finally { dockerExec(['rm', '-v', containerId]); } } } +/** + * A Docker image + */ +export class DockerImage extends BundlingDockerImage { + /** + * Builds a Docker image + * + * @param path The path to the directory containing the Docker file + * @param options Docker build options + */ + public static fromBuild(path: string, options: DockerBuildOptions = {}) { + return BundlingDockerImage.fromAsset(path, options); + } +} + /** * A Docker volume */ diff --git a/packages/@aws-cdk/core/lib/custom-resource-provider/custom-resource-provider.ts b/packages/@aws-cdk/core/lib/custom-resource-provider/custom-resource-provider.ts index f7905fc51447b..d6b0a2db982c7 100644 --- a/packages/@aws-cdk/core/lib/custom-resource-provider/custom-resource-provider.ts +++ b/packages/@aws-cdk/core/lib/custom-resource-provider/custom-resource-provider.ts @@ -67,6 +67,13 @@ export interface CustomResourceProviderProps { * @default - No environment variables. */ readonly environment?: { [key: string]: string }; + + /** + * A description of the function. + * + * @default - No description. + */ + readonly description?: string; } /** @@ -78,7 +85,7 @@ export enum CustomResourceProviderRuntime { /** * Node.js 12.x */ - NODEJS_12 = 'nodejs12' + NODEJS_12 = 'nodejs12.x' } /** @@ -203,8 +210,9 @@ export class CustomResourceProvider extends CoreConstruct { MemorySize: memory.toMebibytes(), Handler: `${ENTRYPOINT_FILENAME}.handler`, Role: role.getAtt('Arn'), - Runtime: 'nodejs12.x', + Runtime: props.runtime, Environment: this.renderEnvironmentVariables(props.environment), + Description: props.description ?? undefined, }, }); diff --git a/packages/@aws-cdk/core/test/archive/archive.zip b/packages/@aws-cdk/core/test/archive/archive.zip new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/@aws-cdk/core/test/bundling.test.ts b/packages/@aws-cdk/core/test/bundling.test.ts index cac2b494eb003..99548030c011a 100644 --- a/packages/@aws-cdk/core/test/bundling.test.ts +++ b/packages/@aws-cdk/core/test/bundling.test.ts @@ -3,7 +3,7 @@ import * as crypto from 'crypto'; import * as path from 'path'; import { nodeunitShim, Test } from 'nodeunit-shim'; import * as sinon from 'sinon'; -import { BundlingDockerImage, FileSystem } from '../lib'; +import { BundlingDockerImage, DockerImage, FileSystem } from '../lib'; nodeunitShim({ 'tearDown'(callback: any) { @@ -265,4 +265,25 @@ nodeunitShim({ test.ok(spawnSyncStub.calledWith(sinon.match.any, ['rm', '-v', containerId])); test.done(); }, + + 'cp utility copies to a temp dir of outputPath is omitted'(test: Test) { + // GIVEN + const containerId = '1234567890abcdef1234567890abcdef'; + sinon.stub(child_process, 'spawnSync').returns({ + status: 0, + stderr: Buffer.from('stderr'), + stdout: Buffer.from(`${containerId}\n`), + pid: 123, + output: ['stdout', 'stderr'], + signal: null, + }); + + // WHEN + const tempPath = DockerImage.fromRegistry('alpine').cp('/foo/bar'); + + // THEN + test.ok(/cdk-docker-cp-/.test(tempPath)); + + test.done(); + }, }); diff --git a/packages/@aws-cdk/core/test/custom-resource-provider/custom-resource-provider.test.ts b/packages/@aws-cdk/core/test/custom-resource-provider/custom-resource-provider.test.ts index b6c1e608e2f59..594f9c2936ff1 100644 --- a/packages/@aws-cdk/core/test/custom-resource-provider/custom-resource-provider.test.ts +++ b/packages/@aws-cdk/core/test/custom-resource-provider/custom-resource-provider.test.ts @@ -187,7 +187,7 @@ nodeunitShim({ test.done(); }, - 'memorySize and timeout'(test: Test) { + 'memorySize, timeout and description'(test: Test) { // GIVEN const stack = new Stack(); @@ -197,6 +197,7 @@ nodeunitShim({ runtime: CustomResourceProviderRuntime.NODEJS_12, memorySize: Size.gibibytes(2), timeout: Duration.minutes(5), + description: 'veni vidi vici', }); // THEN @@ -204,6 +205,7 @@ nodeunitShim({ const lambda = template.Resources.CustomMyResourceTypeCustomResourceProviderHandler29FBDD2A; test.deepEqual(lambda.Properties.MemorySize, 2048); test.deepEqual(lambda.Properties.Timeout, 300); + test.deepEqual(lambda.Properties.Description, 'veni vidi vici'); test.done(); }, diff --git a/packages/@aws-cdk/core/test/docker-stub.sh b/packages/@aws-cdk/core/test/docker-stub.sh index fe48e93d4a207..94f806f69a120 100755 --- a/packages/@aws-cdk/core/test/docker-stub.sh +++ b/packages/@aws-cdk/core/test/docker-stub.sh @@ -24,5 +24,18 @@ if echo "$@" | grep "DOCKER_STUB_SUCCESS"; then exit 0 fi -echo "Docker mock only supports one of the following commands: DOCKER_STUB_SUCCESS_NO_OUTPUT,DOCKER_STUB_FAIL,DOCKER_STUB_SUCCESS" +if echo "$@" | grep "DOCKER_STUB_MULTIPLE_FILES"; then + outdir=$(echo "$@" | xargs -n1 | grep "/asset-output" | head -n1 | cut -d":" -f1) + touch ${outdir}/test1.txt + touch ${outdir}/test2.txt + exit 0 +fi + +if echo "$@" | grep "DOCKER_STUB_SINGLE_ARCHIVE"; then + outdir=$(echo "$@" | xargs -n1 | grep "/asset-output" | head -n1 | cut -d":" -f1) + touch ${outdir}/test.zip + exit 0 +fi + +echo "Docker mock only supports one of the following commands: DOCKER_STUB_SUCCESS_NO_OUTPUT,DOCKER_STUB_FAIL,DOCKER_STUB_SUCCESS,DOCKER_STUB_MULTIPLE_FILES,DOCKER_SINGLE_ARCHIVE" exit 1 diff --git a/packages/@aws-cdk/core/test/staging.test.ts b/packages/@aws-cdk/core/test/staging.test.ts index 347c5fcea3b63..ee87780a0957e 100644 --- a/packages/@aws-cdk/core/test/staging.test.ts +++ b/packages/@aws-cdk/core/test/staging.test.ts @@ -1,10 +1,11 @@ import * as os from 'os'; import * as path from 'path'; +import { FileAssetPackaging } from '@aws-cdk/cloud-assembly-schema'; import * as cxapi from '@aws-cdk/cx-api'; import * as fs from 'fs-extra'; import { nodeunitShim, Test } from 'nodeunit-shim'; import * as sinon from 'sinon'; -import { App, AssetHashType, AssetStaging, BundlingDockerImage, BundlingOptions, FileSystem, Stack, Stage } from '../lib'; +import { App, AssetHashType, AssetStaging, BundlingDockerImage, BundlingOptions, BundlingOutput, FileSystem, Stack, Stage } from '../lib'; const STUB_INPUT_FILE = '/tmp/docker-stub.input'; const STUB_INPUT_CONCAT_FILE = '/tmp/docker-stub.input.concat'; @@ -12,7 +13,9 @@ const STUB_INPUT_CONCAT_FILE = '/tmp/docker-stub.input.concat'; enum DockerStubCommand { SUCCESS = 'DOCKER_STUB_SUCCESS', FAIL = 'DOCKER_STUB_FAIL', - SUCCESS_NO_OUTPUT = 'DOCKER_STUB_SUCCESS_NO_OUTPUT' + SUCCESS_NO_OUTPUT = 'DOCKER_STUB_SUCCESS_NO_OUTPUT', + MULTIPLE_FILES = 'DOCKER_STUB_MULTIPLE_FILES', + SINGLE_ARCHIVE = 'DOCKER_STUB_SINGLE_ARCHIVE', } const FIXTURE_TEST1_DIR = path.join(__dirname, 'fs', 'fixtures', 'test1'); @@ -50,6 +53,84 @@ nodeunitShim({ test.deepEqual(staging.sourcePath, sourcePath); test.deepEqual(path.basename(staging.stagedPath), 'asset.2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); test.deepEqual(path.basename(staging.relativeStagedPath(stack)), 'asset.2f37f937c51e2c191af66acf9b09f548926008ec68c575bd2ee54b6e997c0e00'); + test.deepEqual(staging.packaging, FileAssetPackaging.ZIP_DIRECTORY); + test.deepEqual(staging.isArchive, true); + test.done(); + }, + + 'staging of an archive file correctly sets packaging and isArchive'(test: Test) { + // GIVEN + const stack = new Stack(); + const sourcePath = path.join(__dirname, 'archive', 'archive.zip'); + + // WHEN + const staging = new AssetStaging(stack, 's1', { sourcePath }); + + test.deepEqual(staging.packaging, FileAssetPackaging.FILE); + test.deepEqual(staging.isArchive, true); + test.done(); + }, + + 'asset packaging type is correct when staging is skipped because of memory cache'(test: Test) { + // GIVEN + const stack = new Stack(); + const sourcePath = path.join(__dirname, 'archive', 'archive.zip'); + + // WHEN + const staging1 = new AssetStaging(stack, 's1', { sourcePath }); + const staging2 = new AssetStaging(stack, 's2', { sourcePath }); + + test.deepEqual(staging1.packaging, FileAssetPackaging.FILE); + test.deepEqual(staging1.isArchive, true); + test.deepEqual(staging2.packaging, staging1.packaging); + test.deepEqual(staging2.isArchive, staging1.isArchive); + test.done(); + }, + + 'asset packaging type is correct when staging is skipped because of disk cache'(test: Test) { + // GIVEN + const TEST_OUTDIR = path.join(__dirname, 'cdk.out'); + if (fs.existsSync(TEST_OUTDIR)) { + fs.removeSync(TEST_OUTDIR); + } + + const sourcePath = path.join(__dirname, 'archive', 'archive.zip'); + + const app1 = new App({ outdir: TEST_OUTDIR }); + const stack1 = new Stack(app1, 'Stack'); + + const app2 = new App({ outdir: TEST_OUTDIR }); // same OUTDIR + const stack2 = new Stack(app2, 'stack'); + + // WHEN + const staging1 = new AssetStaging(stack1, 'Asset', { sourcePath }); + + // Now clear asset hash cache to show that during the second staging + // even though the asset is already available on disk it will correctly + // be considered as a FileAssetPackaging.FILE. + AssetStaging.clearAssetHashCache(); + + const staging2 = new AssetStaging(stack2, 'Asset', { sourcePath }); + + // THEN + test.deepEqual(staging1.packaging, FileAssetPackaging.FILE); + test.deepEqual(staging1.isArchive, true); + test.deepEqual(staging2.packaging, staging1.packaging); + test.deepEqual(staging2.isArchive, staging1.isArchive); + + test.done(); + }, + + 'staging of a non-archive file correctly sets packaging and isArchive'(test: Test) { + // GIVEN + const stack = new Stack(); + const sourcePath = __filename; + + // WHEN + const staging = new AssetStaging(stack, 's1', { sourcePath }); + + test.deepEqual(staging.packaging, FileAssetPackaging.FILE); + test.deepEqual(staging.isArchive, false); test.done(); }, @@ -785,6 +866,89 @@ nodeunitShim({ ); test.equal(asset.assetHash, '33cbf2cae5432438e0f046bc45ba8c3cef7b6afcf47b59d1c183775c1918fb1f'); // hash of MyStack/Asset + test.done(); + }, + + 'bundling that produces a single archive file is autodiscovered'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // WHEN + const staging = new AssetStaging(stack, 'Asset', { + sourcePath: directory, + bundling: { + image: BundlingDockerImage.fromRegistry('alpine'), + command: [DockerStubCommand.SINGLE_ARCHIVE], + }, + }); + + // THEN + const assembly = app.synth(); + test.deepEqual(fs.readdirSync(assembly.directory), [ + 'asset.f43148c61174f444925231b5849b468f21e93b5d1469cd07c53625ffd039ef48', // this is the bundle dir but it's empty + 'asset.f43148c61174f444925231b5849b468f21e93b5d1469cd07c53625ffd039ef48.zip', + 'cdk.out', + 'manifest.json', + 'stack.template.json', + 'tree.json', + ]); + test.equal(fs.readdirSync(path.join(assembly.directory, 'asset.f43148c61174f444925231b5849b468f21e93b5d1469cd07c53625ffd039ef48')).length, 0); // empty bundle dir + test.deepEqual(staging.packaging, FileAssetPackaging.FILE); + test.deepEqual(staging.isArchive, true); + + test.done(); + }, + + 'bundling that produces a single archive file with NOT_ARCHIVED'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // WHEN + const staging = new AssetStaging(stack, 'Asset', { + sourcePath: directory, + bundling: { + image: BundlingDockerImage.fromRegistry('alpine'), + command: [DockerStubCommand.SINGLE_ARCHIVE], + outputType: BundlingOutput.NOT_ARCHIVED, + }, + }); + + // THEN + const assembly = app.synth(); + test.deepEqual(fs.readdirSync(assembly.directory), [ + 'asset.86ec07746e1d859290cfd8b9c648e581555649c75f51f741f11e22cab6775abc', + 'cdk.out', + 'manifest.json', + 'stack.template.json', + 'tree.json', + ]); + test.deepEqual(staging.packaging, FileAssetPackaging.ZIP_DIRECTORY); + test.deepEqual(staging.isArchive, true); + + test.done(); + }, + + 'throws with ARCHIVED and bundling that does not produce a single archive file'(test: Test) { + // GIVEN + const app = new App(); + const stack = new Stack(app, 'stack'); + const directory = path.join(__dirname, 'fs', 'fixtures', 'test1'); + + // WHEN + test.throws(() => new AssetStaging(stack, 'Asset', { + sourcePath: directory, + bundling: { + image: BundlingDockerImage.fromRegistry('alpine'), + command: [DockerStubCommand.MULTIPLE_FILES], + outputType: BundlingOutput.ARCHIVED, + }, + }), /Bundling output directory is expected to include only a single .zip or .jar file when `output` is set to `ARCHIVED`/); + + test.done(); }, }); diff --git a/packages/@aws-cdk/cx-api/lib/features.ts b/packages/@aws-cdk/cx-api/lib/features.ts index 24ba471882644..162ec0de9d7f4 100644 --- a/packages/@aws-cdk/cx-api/lib/features.ts +++ b/packages/@aws-cdk/cx-api/lib/features.ts @@ -89,6 +89,21 @@ export const KMS_DEFAULT_KEY_POLICIES = '@aws-cdk/aws-kms:defaultKeyPolicies'; */ export const S3_GRANT_WRITE_WITHOUT_ACL = '@aws-cdk/aws-s3:grantWriteWithoutAcl'; +/** + * ApplicationLoadBalancedServiceBase, ApplicationMultipleTargetGroupServiceBase, + * NetworkLoadBalancedServiceBase, NetworkMultipleTargetGroupServiceBase, and + * QueueProcessingServiceBase currently determine a default value for the desired count of + * a CfnService if a desiredCount is not provided. + * + * If this flag is not set, the default behaviour for CfnService.desiredCount is to set a + * desiredCount of 1, if one is not provided. If true, a default will not be defined for + * CfnService.desiredCount and as such desiredCount will be undefined, if one is not provided. + * + * This is a feature flag as the old behavior was technically incorrect, but + * users may have come to depend on it. + */ +export const ECS_REMOVE_DEFAULT_DESIRED_COUNT = '@aws-cdk/aws-ecs-patterns:removeDefaultDesiredCount'; + /** * This map includes context keys and values for feature flags that enable * capabilities "from the future", which we could not introduce as the default @@ -110,6 +125,7 @@ export const FUTURE_FLAGS: { [key: string]: any } = { [SECRETS_MANAGER_PARSE_OWNED_SECRET_NAME]: true, [KMS_DEFAULT_KEY_POLICIES]: true, [S3_GRANT_WRITE_WITHOUT_ACL]: true, + [ECS_REMOVE_DEFAULT_DESIRED_COUNT]: true, // We will advertise this flag when the feature is complete // [NEW_STYLE_STACK_SYNTHESIS_CONTEXT]: 'true', @@ -135,6 +151,7 @@ const FUTURE_FLAGS_DEFAULTS: { [key: string]: boolean } = { [SECRETS_MANAGER_PARSE_OWNED_SECRET_NAME]: false, [KMS_DEFAULT_KEY_POLICIES]: false, [S3_GRANT_WRITE_WITHOUT_ACL]: false, + [ECS_REMOVE_DEFAULT_DESIRED_COUNT]: false, }; export function futureFlagDefault(flag: string): boolean { diff --git a/packages/@aws-cdk/pipelines/README.md b/packages/@aws-cdk/pipelines/README.md index 718ba9b503690..512ee5e97c92b 100644 --- a/packages/@aws-cdk/pipelines/README.md +++ b/packages/@aws-cdk/pipelines/README.md @@ -655,6 +655,12 @@ These command lines explained: > Make sure you trust all the code and dependencies that make up your CDK app. > Check with the appropriate department within your organization to decide on the > proper policy to use. +> +> If your policy includes permissions to create on attach permission to a role, +> developers can escalate their privilege with more permissive permission. +> Thus, we recommend implementing [permissions boundary](https://aws.amazon.com/premiumsupport/knowledge-center/iam-permission-boundaries/) +> in the CDK Execution role. To do this, you can bootstrap with the `--template` option with +> [a customized template](https://github.com/aws-samples/aws-bootstrap-kit-examples/blob/ba28a97d289128281bc9483bcba12c1793f2c27a/source/1-SDLC-organization/lib/cdk-bootstrap-template.yml#L395) that contains a permission boundary. ### Migrating from old bootstrap stack diff --git a/packages/@monocdk-experiment/assert/package.json b/packages/@monocdk-experiment/assert/package.json index 86806f041880f..dbd0d792e8a60 100644 --- a/packages/@monocdk-experiment/assert/package.json +++ b/packages/@monocdk-experiment/assert/package.json @@ -47,7 +47,7 @@ "@aws-cdk/cloudformation-diff": "0.0.0" }, "peerDependencies": { - "constructs": "^3.0.4", + "constructs": "^3.2.0", "jest": "^26.6.3", "monocdk": "^0.0.0" }, diff --git a/packages/aws-cdk-lib/package.json b/packages/aws-cdk-lib/package.json index 6eadd958185df..89dbc64979387 100644 --- a/packages/aws-cdk-lib/package.json +++ b/packages/aws-cdk-lib/package.json @@ -293,7 +293,7 @@ "ubergen": "0.0.0" }, "peerDependencies": { - "constructs": "^3.0.4" + "constructs": "^3.2.0" }, "homepage": "https://github.com/aws/aws-cdk", "engines": { diff --git a/packages/aws-cdk/lib/init-templates/v1/app/java/pom.template.xml b/packages/aws-cdk/lib/init-templates/v1/app/java/pom.template.xml index 37be67aa2c7a5..fb0b6cf828aaf 100644 --- a/packages/aws-cdk/lib/init-templates/v1/app/java/pom.template.xml +++ b/packages/aws-cdk/lib/init-templates/v1/app/java/pom.template.xml @@ -10,7 +10,7 @@ UTF-8 %cdk-version% - 5.7.0 + 5.7.1 diff --git a/packages/aws-cdk/lib/init-templates/v1/app/python/%name.PythonModule%/%name.PythonModule%_stack.template.py b/packages/aws-cdk/lib/init-templates/v1/app/python/%name.PythonModule%/%name.PythonModule%_stack.template.py index 9d86ad16906e6..6b4ed6e8ea6ed 100644 --- a/packages/aws-cdk/lib/init-templates/v1/app/python/%name.PythonModule%/%name.PythonModule%_stack.template.py +++ b/packages/aws-cdk/lib/init-templates/v1/app/python/%name.PythonModule%/%name.PythonModule%_stack.template.py @@ -1,9 +1,15 @@ +from aws_cdk import core as cdk + +# For consistency with other languages, `cdk` is the preferred import name for +# the CDK's core module. The following line also imports it as `core` for use +# with examples from the CDK Developer's Guide, which are in the process of +# being updated to use `cdk`. You may delete this import if you don't need it. from aws_cdk import core -class %name.PascalCased%Stack(core.Stack): +class %name.PascalCased%Stack(cdk.Stack): - def __init__(self, scope: core.Construct, construct_id: str, **kwargs) -> None: + def __init__(self, scope: cdk.Construct, construct_id: str, **kwargs) -> None: super().__init__(scope, construct_id, **kwargs) # The code that defines your stack goes here diff --git a/packages/aws-cdk/lib/init-templates/v1/app/python/app.template.py b/packages/aws-cdk/lib/init-templates/v1/app/python/app.template.py index 808bc22af32e4..bc43099fd2026 100644 --- a/packages/aws-cdk/lib/init-templates/v1/app/python/app.template.py +++ b/packages/aws-cdk/lib/init-templates/v1/app/python/app.template.py @@ -1,11 +1,17 @@ #!/usr/bin/env python3 +from aws_cdk import core as cdk + +# For consistency with TypeScript code, `cdk` is the preferred import name for +# the CDK's core module. The following line also imports it as `core` for use +# with examples from the CDK Developer's Guide, which are in the process of +# being updated to use `cdk`. You may delete this import if you don't need it. from aws_cdk import core from %name.PythonModule%.%name.PythonModule%_stack import %name.PascalCased%Stack -app = core.App() +app = cdk.App() %name.PascalCased%Stack(app, "%name.StackName%") app.synth() diff --git a/packages/aws-cdk/lib/init-templates/v1/sample-app/java/pom.template.xml b/packages/aws-cdk/lib/init-templates/v1/sample-app/java/pom.template.xml index 0355825bf0a7a..5d679d2570040 100644 --- a/packages/aws-cdk/lib/init-templates/v1/sample-app/java/pom.template.xml +++ b/packages/aws-cdk/lib/init-templates/v1/sample-app/java/pom.template.xml @@ -8,7 +8,7 @@ UTF-8 %cdk-version% - 5.7.0 + 5.7.1 diff --git a/packages/aws-cdk/lib/init-templates/v2/app/java/pom.template.xml b/packages/aws-cdk/lib/init-templates/v2/app/java/pom.template.xml index f49fe7b60e963..5defab0b3a0b6 100644 --- a/packages/aws-cdk/lib/init-templates/v2/app/java/pom.template.xml +++ b/packages/aws-cdk/lib/init-templates/v2/app/java/pom.template.xml @@ -10,7 +10,7 @@ UTF-8 %cdk-version% - 5.7.0 + 5.7.1 diff --git a/packages/aws-cdk/lib/init-templates/v2/app/python/.template.gitignore b/packages/aws-cdk/lib/init-templates/v2/app/python/.template.gitignore index 383cdd5040f7e..37833f8beb2a3 100644 --- a/packages/aws-cdk/lib/init-templates/v2/app/python/.template.gitignore +++ b/packages/aws-cdk/lib/init-templates/v2/app/python/.template.gitignore @@ -2,7 +2,7 @@ package-lock.json __pycache__ .pytest_cache -.env +.venv *.egg-info # CDK asset staging directory diff --git a/packages/aws-cdk/lib/init-templates/v2/sample-app/java/pom.template.xml b/packages/aws-cdk/lib/init-templates/v2/sample-app/java/pom.template.xml index 1236d332e9bab..7f10c89a54dd4 100644 --- a/packages/aws-cdk/lib/init-templates/v2/sample-app/java/pom.template.xml +++ b/packages/aws-cdk/lib/init-templates/v2/sample-app/java/pom.template.xml @@ -8,7 +8,7 @@ UTF-8 %cdk-version% - 5.7.0 + 5.7.1 diff --git a/scripts/check-pack-prerequisites.sh b/scripts/check-pack-prerequisites.sh index 8dca0902114e5..6b648054fe253 100755 --- a/scripts/check-pack-prerequisites.sh +++ b/scripts/check-pack-prerequisites.sh @@ -54,12 +54,12 @@ app_v=$(${app} -version 2>&1) echo -e "Checking javac version... \c" # 1.8 if [ $(echo $app_v | grep -c -E "1\.8\.[0-9].*") -eq 1 ] -then +then echo "Ok" else # 11 or 14 or 15 if [ $(echo $app_v | grep -c -E "1[145]\.[0-9]\.[0-9].*") -eq 1 ] - then + then echo "Ok" else wrong_version @@ -73,7 +73,7 @@ check_which $app $app_min app_v=$(${app} --version) echo -e "Checking mvn version... \c" if [ $(echo $app_v | grep -c -E "3\.[6789]\.[0-9].*") -eq 1 ] -then +then echo "Ok" else wrong_version @@ -85,8 +85,8 @@ app_min="3.1.0" check_which $app $app_min app_v=$(${app} --version) echo -e "Checking $app version... \c" -if [ $(echo $app_v | grep -c -E "3\.1\.[0-9].*") -eq 1 ] -then +if [ $(echo $app_v | grep -c -E "3\.1\.[0-9].*|[4-9]\..*") -eq 1 ] +then echo "Ok" else wrong_version @@ -99,7 +99,7 @@ check_which $app $app_min app_v=$(${app} --version) echo -e "Checking $app version... \c" if [ $(echo $app_v | grep -c -E "3\.[6789]\.[0-9].*") -eq 1 ] -then +then echo "Ok" else wrong_version diff --git a/tools/pkglint/lib/rules.ts b/tools/pkglint/lib/rules.ts index 67e26c9634712..1dd32c1c96392 100644 --- a/tools/pkglint/lib/rules.ts +++ b/tools/pkglint/lib/rules.ts @@ -617,6 +617,38 @@ export class NoPeerDependenciesMonocdk extends ValidationRule { } } +/** + * Validates that the same version of `constructs` is used wherever a dependency + * is specified, so that they must all be udpated at the same time (through an + * update to this rule). + * + * Note: v1 and v2 use different versions respectively. + */ +export class ConstructsVersion extends ValidationRule { + public readonly name = 'deps/constructs'; + private readonly expectedRange = cdkMajorVersion() === 2 + ? '10.0.0-pre.5' + : '^3.2.0'; + + public validate(pkg: PackageJson) { + const toCheck = new Array(); + + if ('constructs' in pkg.dependencies) { + toCheck.push('dependencies'); + } + if ('constructs' in pkg.devDependencies) { + toCheck.push('devDependencies'); + } + if ('constructs' in pkg.peerDependencies) { + toCheck.push('peerDependencies'); + } + + for (const cfg of toCheck) { + expectJSON(this.name, pkg, `${cfg}.constructs`, this.expectedRange); + } + } +} + /** * JSII Java package is required and must look sane */