From 48849b5e9cb29228051ef0a8a1f1b41d4daf8c36 Mon Sep 17 00:00:00 2001 From: "dependabot-preview[bot]" <27856297+dependabot-preview[bot]@users.noreply.github.com> Date: Tue, 5 May 2020 18:15:11 +0000 Subject: [PATCH 1/5] chore(deps): bump aws-sdk from 2.668.0 to 2.669.0 (#7780) Bumps [aws-sdk](https://github.com/aws/aws-sdk-js) from 2.668.0 to 2.669.0. - [Release notes](https://github.com/aws/aws-sdk-js/releases) - [Changelog](https://github.com/aws/aws-sdk-js/blob/master/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-js/compare/v2.668.0...v2.669.0) Signed-off-by: dependabot-preview[bot] Co-authored-by: dependabot-preview[bot] <27856297+dependabot-preview[bot]@users.noreply.github.com> --- packages/@aws-cdk/aws-cloudfront/package.json | 2 +- packages/@aws-cdk/aws-cloudtrail/package.json | 2 +- packages/@aws-cdk/aws-codebuild/package.json | 2 +- packages/@aws-cdk/aws-codecommit/package.json | 2 +- packages/@aws-cdk/aws-dynamodb/package.json | 2 +- packages/@aws-cdk/aws-eks/package.json | 2 +- packages/@aws-cdk/aws-events-targets/package.json | 2 +- packages/@aws-cdk/aws-lambda/package.json | 2 +- packages/@aws-cdk/aws-route53/package.json | 2 +- packages/@aws-cdk/aws-sqs/package.json | 2 +- packages/@aws-cdk/custom-resources/package.json | 2 +- packages/aws-cdk/package.json | 2 +- packages/cdk-assets/package.json | 2 +- yarn.lock | 8 ++++---- 14 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/@aws-cdk/aws-cloudfront/package.json b/packages/@aws-cdk/aws-cloudfront/package.json index a6f76f9812234..e23dd27e64577 100644 --- a/packages/@aws-cdk/aws-cloudfront/package.json +++ b/packages/@aws-cdk/aws-cloudfront/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@aws-cdk/assert": "0.0.0", "@types/nodeunit": "^0.0.30", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", diff --git a/packages/@aws-cdk/aws-cloudtrail/package.json b/packages/@aws-cdk/aws-cloudtrail/package.json index a40f8c3dbc57f..092b84bbcd0fd 100644 --- a/packages/@aws-cdk/aws-cloudtrail/package.json +++ b/packages/@aws-cdk/aws-cloudtrail/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@aws-cdk/assert": "0.0.0", "@types/nodeunit": "^0.0.30", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", diff --git a/packages/@aws-cdk/aws-codebuild/package.json b/packages/@aws-cdk/aws-codebuild/package.json index 1ffebd83828ae..e39c7172e902f 100644 --- a/packages/@aws-cdk/aws-codebuild/package.json +++ b/packages/@aws-cdk/aws-codebuild/package.json @@ -70,7 +70,7 @@ "@aws-cdk/aws-sns": "0.0.0", "@aws-cdk/aws-sqs": "0.0.0", "@types/nodeunit": "^0.0.30", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", diff --git a/packages/@aws-cdk/aws-codecommit/package.json b/packages/@aws-cdk/aws-codecommit/package.json index 1cfef13e9218f..b34d2d20c28f9 100644 --- a/packages/@aws-cdk/aws-codecommit/package.json +++ b/packages/@aws-cdk/aws-codecommit/package.json @@ -70,7 +70,7 @@ "@aws-cdk/assert": "0.0.0", "@aws-cdk/aws-sns": "0.0.0", "@types/nodeunit": "^0.0.30", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", diff --git a/packages/@aws-cdk/aws-dynamodb/package.json b/packages/@aws-cdk/aws-dynamodb/package.json index 7cc73751141ea..a99e57aa7e64a 100644 --- a/packages/@aws-cdk/aws-dynamodb/package.json +++ b/packages/@aws-cdk/aws-dynamodb/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@aws-cdk/assert": "0.0.0", "@types/jest": "^25.2.1", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "aws-sdk-mock": "^5.1.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", diff --git a/packages/@aws-cdk/aws-eks/package.json b/packages/@aws-cdk/aws-eks/package.json index 163503378762b..de58b4ef7faa1 100644 --- a/packages/@aws-cdk/aws-eks/package.json +++ b/packages/@aws-cdk/aws-eks/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@aws-cdk/assert": "0.0.0", "@types/nodeunit": "^0.0.30", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", diff --git a/packages/@aws-cdk/aws-events-targets/package.json b/packages/@aws-cdk/aws-events-targets/package.json index 304225705ae61..80e4c90b84f75 100644 --- a/packages/@aws-cdk/aws-events-targets/package.json +++ b/packages/@aws-cdk/aws-events-targets/package.json @@ -86,7 +86,7 @@ "devDependencies": { "@aws-cdk/assert": "0.0.0", "@aws-cdk/aws-codecommit": "0.0.0", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "aws-sdk-mock": "^5.1.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", diff --git a/packages/@aws-cdk/aws-lambda/package.json b/packages/@aws-cdk/aws-lambda/package.json index 6c6f65e31fb34..73a87d957fbb2 100644 --- a/packages/@aws-cdk/aws-lambda/package.json +++ b/packages/@aws-cdk/aws-lambda/package.json @@ -71,7 +71,7 @@ "@types/lodash": "^4.14.150", "@types/nodeunit": "^0.0.30", "@types/sinon": "^9.0.0", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "aws-sdk-mock": "^5.1.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", diff --git a/packages/@aws-cdk/aws-route53/package.json b/packages/@aws-cdk/aws-route53/package.json index 393c1d6fe47e2..f1cfa2ab41c02 100644 --- a/packages/@aws-cdk/aws-route53/package.json +++ b/packages/@aws-cdk/aws-route53/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@aws-cdk/assert": "0.0.0", "@types/nodeunit": "^0.0.30", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", diff --git a/packages/@aws-cdk/aws-sqs/package.json b/packages/@aws-cdk/aws-sqs/package.json index c6ca2484781aa..4581369a774aa 100644 --- a/packages/@aws-cdk/aws-sqs/package.json +++ b/packages/@aws-cdk/aws-sqs/package.json @@ -65,7 +65,7 @@ "@aws-cdk/assert": "0.0.0", "@aws-cdk/aws-s3": "0.0.0", "@types/nodeunit": "^0.0.30", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", "cfn2ts": "0.0.0", diff --git a/packages/@aws-cdk/custom-resources/package.json b/packages/@aws-cdk/custom-resources/package.json index 8a3e812e6a868..b2a16998cd043 100644 --- a/packages/@aws-cdk/custom-resources/package.json +++ b/packages/@aws-cdk/custom-resources/package.json @@ -73,7 +73,7 @@ "@types/aws-lambda": "^8.10.39", "@types/fs-extra": "^8.1.0", "@types/sinon": "^9.0.0", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "aws-sdk-mock": "^5.1.0", "cdk-build-tools": "0.0.0", "cdk-integ-tools": "0.0.0", diff --git a/packages/aws-cdk/package.json b/packages/aws-cdk/package.json index 534bafb6de5ad..e47ba41359f58 100644 --- a/packages/aws-cdk/package.json +++ b/packages/aws-cdk/package.json @@ -68,7 +68,7 @@ "@aws-cdk/cloud-assembly-schema": "0.0.0", "@aws-cdk/region-info": "0.0.0", "archiver": "^4.0.1", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "camelcase": "^6.0.0", "cdk-assets": "0.0.0", "colors": "^1.4.0", diff --git a/packages/cdk-assets/package.json b/packages/cdk-assets/package.json index 61f45483f8e18..909ee39cb1850 100644 --- a/packages/cdk-assets/package.json +++ b/packages/cdk-assets/package.json @@ -44,7 +44,7 @@ "dependencies": { "@aws-cdk/cdk-assets-schema": "0.0.0", "archiver": "^4.0.1", - "aws-sdk": "^2.668.0", + "aws-sdk": "^2.669.0", "glob": "^7.1.6", "yargs": "^15.3.1" }, diff --git a/yarn.lock b/yarn.lock index 66c763dd7cefd..97329ca4529ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2765,10 +2765,10 @@ aws-sdk-mock@^5.1.0: sinon "^9.0.1" traverse "^0.6.6" -aws-sdk@^2.637.0, aws-sdk@^2.668.0: - version "2.668.0" - resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.668.0.tgz#18b3e64a47f86c109586422596e53dc733117696" - integrity sha512-mmZJmeenNM9hRR4k+JAStBhYFym2+VCPTRWv0Vn2oqqXIaIaNVdNf9xag/WMG8b8M80R3XXfVHKmDPST0/EfHA== +aws-sdk@^2.637.0, aws-sdk@^2.669.0: + version "2.669.0" + resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.669.0.tgz#7e8e7985120102da6bdbf40a18d8b9d692dea1ba" + integrity sha512-kuVcSRpDzvkgmeSmMX6Q32eTOb8UeihhUdavMrvUOP6fzSU19cNWS9HAIkYOi/jrEDK85cCZxXjxqE3JGZIGcw== dependencies: buffer "4.9.1" events "1.1.1" From 4a7697370c9d04fdbb2c9fb0be71d67122573390 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Tue, 5 May 2020 22:42:26 +0300 Subject: [PATCH 2/5] feat(core): custom resource provider helper A helper for implementing simple node.js-based custom resource providers. This is a simpler framework from what is offered `@aws-cdk/custom-resources.Provider`, designed to enable implementing custom resources with minimal dependencies. To that end, this helper uses `CfnResource` to define the AWS Lambda function and the low-level asset mechanism in order to create an asset for the lambda bundle. Comparing to the advanced provider framework, this provider DOES NOT support: - Arbitrary lambda function handlers, only node.js function. - Asynchronous "isComplete" waiters (limited to 15min lambda timeout). Since `cdk-integ` depends on `cdk` which depends on `@aws-cdk/core` (as a "dev dependency"), I've added the integ test for this in @aws-cdk/aws-cloudformation. This probably belongs to some other module (`@aws-cdk/core-tests`?). This is a precursor for implementing support for Open ID connect providers in the AWS IAM module, which is a very low-level module in our stack. --- .../index.ts | 10 + .../integ.core-custom-resources.expected.json | 137 ++++++++++++ .../test/integ.core-custom-resources.ts | 42 ++++ packages/@aws-cdk/core/README.md | 185 ++++++++++++++-- .../custom-resource-provider.ts | 183 ++++++++++++++++ .../lib/custom-resource-provider/index.ts | 1 + .../nodejs-entrypoint.ts | 139 ++++++++++++ packages/@aws-cdk/core/lib/index.ts | 1 + .../mock-provider/index.ts | 10 + .../test.custom-resource-provider.ts | 168 +++++++++++++++ .../test.nodejs-entrypoint.ts | 198 ++++++++++++++++++ .../test/integration.custom-resources.readme | 5 + 12 files changed, 1061 insertions(+), 18 deletions(-) create mode 100644 packages/@aws-cdk/aws-cloudformation/test/core-custom-resource-provider-fixture/index.ts create mode 100644 packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.expected.json create mode 100644 packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.ts create mode 100644 packages/@aws-cdk/core/lib/custom-resource-provider/custom-resource-provider.ts create mode 100644 packages/@aws-cdk/core/lib/custom-resource-provider/index.ts create mode 100644 packages/@aws-cdk/core/lib/custom-resource-provider/nodejs-entrypoint.ts create mode 100644 packages/@aws-cdk/core/test/custom-resource-provider/mock-provider/index.ts create mode 100644 packages/@aws-cdk/core/test/custom-resource-provider/test.custom-resource-provider.ts create mode 100644 packages/@aws-cdk/core/test/custom-resource-provider/test.nodejs-entrypoint.ts create mode 100644 packages/@aws-cdk/core/test/integration.custom-resources.readme diff --git a/packages/@aws-cdk/aws-cloudformation/test/core-custom-resource-provider-fixture/index.ts b/packages/@aws-cdk/aws-cloudformation/test/core-custom-resource-provider-fixture/index.ts new file mode 100644 index 0000000000000..5a372f057593e --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/test/core-custom-resource-provider-fixture/index.ts @@ -0,0 +1,10 @@ +// tslint:disable: no-console + +export function handler(event: any) { + console.log('I am a custom resource'); + console.log(event); + return { + PhysicalResourceId: event.ResourceProperties.physicalResourceId, + Data: event.ResourceProperties.attributes, + }; +} \ No newline at end of file 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 new file mode 100644 index 0000000000000..5e3d50351b9d3 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.expected.json @@ -0,0 +1,137 @@ +{ + "Resources": { + "CustomReflectCustomResourceProviderRoleB4B29AEC": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ] + }, + "ManagedPolicyArns": [ + { + "Fn::Sub": "arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + } + ] + } + }, + "CustomReflectCustomResourceProviderHandler2E189D0B": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aS3Bucket1D703CB8" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aS3VersionKey01A97AE3" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aS3VersionKey01A97AE3" + } + ] + } + ] + } + ] + ] + } + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "CustomReflectCustomResourceProviderRoleB4B29AEC", + "Arn" + ] + }, + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "CustomReflectCustomResourceProviderRoleB4B29AEC" + ] + }, + "MyResource": { + "Type": "Custom::Reflect", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "CustomReflectCustomResourceProviderHandler2E189D0B", + "Arn" + ] + }, + "physicalResourceId": "MyPhysicalReflectBack", + "attributes": { + "Attribute1": "foo", + "Attribute2": 1234 + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + } + }, + "Parameters": { + "AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aS3Bucket1D703CB8": { + "Type": "String", + "Description": "S3 bucket for asset \"d46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7a\"" + }, + "AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aS3VersionKey01A97AE3": { + "Type": "String", + "Description": "S3 key for asset version \"d46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7a\"" + }, + "AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aArtifactHash16A571C9": { + "Type": "String", + "Description": "Artifact hash for asset \"d46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7a\"" + } + }, + "Outputs": { + "Ref": { + "Value": { + "Ref": "MyResource" + } + }, + "GetAttAttribute1": { + "Value": { + "Fn::GetAtt": [ + "MyResource", + "Attribute1" + ] + } + }, + "GetAttAttribute2": { + "Value": { + "Fn::GetAtt": [ + "MyResource", + "Attribute2" + ] + } + } + } +} \ No newline at end of file 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 new file mode 100644 index 0000000000000..2c219f757825d --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/test/integ.core-custom-resources.ts @@ -0,0 +1,42 @@ +/* + * Stack verification steps: + * - Deploy with `--no-clean` + * - Verify that the CloudFormation stack outputs have the following values: + * - Ref: "MyPhysicalReflectBack" + * - GetAtt.Attribute1: "foo" + * - GetAtt.Attribute2: 1234 + */ +import { App, CfnOutput, Construct, CustomResource, CustomResourceProvider, CustomResourceProviderRuntime, Stack, Token } from '@aws-cdk/core'; + +class TestStack extends Stack { + constructor(scope: Construct, id: string) { + super(scope, id); + + const resourceType = 'Custom::Reflect'; + + const serviceToken = CustomResourceProvider.getOrCreate(this, resourceType, { + codeDirectory: `${__dirname}/core-custom-resource-provider-fixture`, + runtime: CustomResourceProviderRuntime.NODEJS_12, + }); + + const cr = new CustomResource(this, 'MyResource', { + resourceType, + serviceToken, + properties: { + physicalResourceId: 'MyPhysicalReflectBack', + attributes: { + Attribute1: 'foo', + Attribute2: 1234, + }, + }, + }); + + new CfnOutput(this, 'Ref', { value: cr.ref }); + new CfnOutput(this, 'GetAtt.Attribute1', { value: Token.asString(cr.getAtt('Attribute1')) }); + new CfnOutput(this, 'GetAtt.Attribute2', { value: Token.asString(cr.getAtt('Attribute2')) }); + } +} + +const app = new App(); +new TestStack(app, 'custom-resource-test'); +app.synth(); diff --git a/packages/@aws-cdk/core/README.md b/packages/@aws-cdk/core/README.md index 7b7a9e89216ff..db0bc2dd54a5d 100644 --- a/packages/@aws-cdk/core/README.md +++ b/packages/@aws-cdk/core/README.md @@ -185,7 +185,7 @@ resources in the scope of `constructB`. If you want a single object to represent a set of constructs that are not necessarily in the same scope, you can use a `ConcreteDependable`. The following creates a single object that represents a dependency on two -construts, `constructB` and `constructC`: +constructs, `constructB` and `constructC`: ```ts // Declare the dependable object @@ -246,24 +246,45 @@ new CustomResource(this, 'MyMagicalResource', { ### Custom Resource Providers Custom resources are backed by a **custom resource provider** which can be -implemented in one of the following ways (ordered from low-level to high-level): - -* `@aws-cdk/aws-sns.Topic` -* `@aws-cdk/aws-lambda.Function` -* `@aws-cdk/custom-resources.Provider` - -**NOTE**: when defining resources for a custom resource provider, you will -likely want to define them as a *stack singleton* so that only a single instance -of the provider is created in your stack and which is used by all custom -resources of that type. - -The following is a pattern for defining stack singletons in the CDK: +implemented in one of the following ways. The following table compares the +various provider types (ordered from low-level to high-level): + +| Provider | Compute Type | Error Handling | Submit to CloudFormation | Max Timeout | Language | Footprint | +|----------------------------------------------------------------------|:------------:|:--------------:|:------------------------:|:---------------:|:--------:|:---------:| +| [sns.Topic](#amazon-sns-topic) | Self-managed | Manual | Manual | Unlimited | Any | Depends | +| [lambda.Function](#aws-lambda-function) | AWS Lambda | Manual | Manual | 15min | Any | Small | +| [core.CustomResourceProvider](#the-corecustomresourceprovider-class) | Lambda | Auto | Auto | 15min | Node.js | Small | +| [custom-resources.Provider](#the-custom-resource-provider-framework) | Lambda | Auto | Auto | Unlimited Async | Any | Large | + +Legend: + +- **Compute type**: which type of compute can is used to execute the handler. +- **Error Handling**: whether errors thrown by handler code are automatically + trapped and a FAILED response is submitted to CloudFormation. If this is + "Manual", developers must take care of trapping errors. Otherwise, events + could cause stacks to hang. +- **Submit to CloudFormation**: whether the framework takes care of submitting + SUCCESS/FAILED responses to CloudFormation through the event's response URL. +- **Max Timeout**: maximum allows/possible timeout. +- **Language**: which programming languages can be used to implement handlers. +- **Footprint**: how many resources are used by the provider framework itself. + +**A NOTE ABOUT SINGLETONS** + +When defining resources for a custom resource provider, you will likely want to +define them as a *stack singleton* so that only a single instance of the +provider is created in your stack and which is used by all custom resources of +that type. + +Here is a basic pattern for defining stack singletons in the CDK. The following +examples ensures that only a single SNS topic is defined: ```ts -const stack = Stack.of(this); -const uniqueid = 'GloballyUniqueIdForSingleton'; -return stack.node.tryFindChild(uniqueid) as MySingleton - ?? new MySingleton(stack, uniqueid); +function getOrCreate(scope: Construct): sns.Topic { + const stack = Stack.of(this); + const uniqueid = 'GloballyUniqueIdForSingleton'; + return stack.node.tryFindChild(uniqueid) as sns.Topic ?? new sns.Topic(stack, uniqueid); +} ``` #### Amazon SNS Topic @@ -305,6 +326,132 @@ new CustomResource(this, 'MyResource', { }); ``` +#### The `core.CustomResourceProvider` class + +The class [`@aws-cdk/core.CustomResourceProvider`] offers a basic low-level +framework designed to implement simple and slim custom resource providers. It +currently only supports Node.js-based user handlers, and it does not have +support for asynchronous waiting (handler cannot exceed the 15min lambda +timeout). + +[`@aws-cdk/core.CustomResourceProvider`]: https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_core.CustomResourceProvider.html + +The provider has a built-in singleton method which uses the resource type as a +stack-unique identifier and returns the service token: + +```ts +const serviceToken = CustomResourceProvider.getOrCreate(this, 'Custom::MyCustomResourceType', { + codeDirectory: `${__dirname}/my-handler`, + runtime: CustomResourceProviderRuntime.NODEJS_12, // currently the only supported runtime +}); + +new CustomResource(this, 'MyResource', { + resourceType: 'Custom::MyCustomResourceType', + serviceToken: serviceToken +}); +``` + +The directory (`my-handler` in the above example) must include an `index.js` file. It cannot import +external dependencies or files outside this directory. It must export an async +function named `handler`. This function accepts the CloudFormation resource +event object and returns an object with the following structure: + +```js +exports.handler = async function(event) { + const id = event.PhysicalResourceId; // only for "Update" and "Delete" + const props = event.ResourceProperties; + const oldProps = event.OldResourceProperties; // only for "Update"s + + switch (event.RequestType) { + case "Create": + // ... + + case "Update": + // ... + + // if an error is thrown, a FAILED response will be submitted to CFN + throw new Error('Failed!'); + + case "Delete": + // ... + } + + return { + // (optional) the value resolved from `resource.ref` + // defaults to "event.PhysicalResourceId" or "event.RequestId" + PhysicalResourceId: "REF", + + // (optional) calling `resource.getAtt("Att1")` on the custom resource in the CDK app + // will return the value "BAR". + Data: { + Att1: "BAR", + Att2: "BAZ" + }, + + // (optional) user-visible message + Reason: "User-visible message", + + // (optional) hides values from the console + NoEcho: true + }; +} +``` + +Here is an complete example of a custom resource that summarizes two numbers: + +`sum-handler/index.js`: + +```js +exports.handler = async e => { + return { + Data: { + Result: e.ResourceProperties.lhs + e.ResourceProperties.rhs + } + }; +}; +``` + +`sum.ts`: + +```ts +export interface SumProps { + readonly lhs: number; + readonly rhs: number; +} + +export class Sum extends Construct { + public readonly result: number; + + constructor(scope: Construct, id: string, props: SumProps) { + super(scope, id); + + const resourceType = 'Custom::Sum'; + const provider = CustomResourceProvider.getOrCreate(this, resourceType, { + codeDirectory: `${__dirname}/sum-handler`, + runtime: CustomResourceProviderRuntime.NODEJS_12, + }); + + const resource = new CustomResource(this, 'Resource', { + resourceType: resourceType, + serviceToken: provider.serviceToken, + properties: { + lhs: props.lhs, + rhs: props.rhs + } + }); + + this.result = Token.asNumber(resource.getAtt('Result')); + } +} +``` + +Usage will look like this: + +```ts +const sum = new Sum(this, 'MySum', { lhs: 40, rhs: 2 }); +new CfnOutput(this, 'Result', { value: sum.result }); +``` + #### The Custom Resource Provider Framework The [`@aws-cdk/custom-resource`] module includes an advanced framework for @@ -318,7 +465,7 @@ asynchronous mode, which means that users can provide an `isComplete` lambda function which is called periodically until the operation is complete. This allows implementing providers that can take up to two hours to stabilize. -Set `serviceToken` to `provider.serviceToken` to use this provider: +Set `serviceToken` to `provider.serviceToken` to use this type of provider: ```ts import { Provider } from 'custom-resources'; @@ -333,6 +480,8 @@ new CustomResource(this, 'MyResource', { }); ``` +See the [documentation](https://docs.aws.amazon.com/cdk/api/latest/docs/custom-resources-readme.html) for more details. + #### Amazon SNS Topic Every time a resource event occurs (CREATE/UPDATE/DELETE), an SNS notification 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 new file mode 100644 index 0000000000000..8b24d068235f9 --- /dev/null +++ b/packages/@aws-cdk/core/lib/custom-resource-provider/custom-resource-provider.ts @@ -0,0 +1,183 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { AssetStaging } from '../asset-staging'; +import { FileAssetPackaging } from '../assets'; +import { CfnResource } from '../cfn-resource'; +import { Construct } from '../construct-compat'; +import { Duration } from '../duration'; +import { Size } from '../size'; +import { Stack } from '../stack'; +import { Token } from '../token'; + +const ENTRYPOINT_FILENAME = '__entrypoint__'; +const ENTRYPOINT_NODEJS_SOURCE = path.join(__dirname, 'nodejs-entrypoint.js'); + +/** + * Initialization properties for `CustomResourceProvider`. + * + * @experimental + */ +export interface CustomResourceProviderProps { + /** + * A local file system directory with the provider's code. The code will be + * bundled into a zip asset and wired to the provider's AWS Lambda function. + */ + readonly codeDirectory: string; + + /** + * The AWS Lambda runtime and version to use for the provider. + */ + readonly runtime: CustomResourceProviderRuntime; + + /** + * A set of IAM policy statements to include in the inline policy of the + * provider's lambda function. + * + * @default - no additional inline policy + * + * @example + * + * policyStatements: [ { Effect: 'Allow', Action: 's3:PutObject*', Resource: '*' } ] + * + */ + readonly policyStatements?: any[]; + + /** + * AWS Lambda timeout for the provider. + * + * @default Duration.minutes(15) + */ + readonly timeout?: Duration; + + /** + * The amount of memory that your function has access to. Increasing the + * function's memory also increases its CPU allocation. + * + * @default Size.mebibytes(128) + */ + readonly memorySize?: Size; +} + +/** + * The lambda runtime to use for the resource provider. This also indicates + * which language is used for the handler. + * @experimental + */ +export enum CustomResourceProviderRuntime { + /** + * Node.js 12.x + */ + NODEJS_12 = 'nodejs12' +} + +/** + * An AWS-Lambda backed custom resource provider. + * + * @experimental + */ +export class CustomResourceProvider extends Construct { + /** + * Returns a stack-level singleton ARN (service token) for the custom resource + * provider. + * + * @param scope Construct scope + * @param uniqueid A globally unique id that will be used for the stack-level + * construct. + * @param props Provider properties which will only be applied when the + * provider is first created. + * @returns the service token of the custom resource provider, which should be + * used when defining a `CustomResource`. + */ + public static getOrCreate(scope: Construct, uniqueid: string, props: CustomResourceProviderProps) { + const id = `${uniqueid}CustomResourceProvider`; + const stack = Stack.of(scope); + const provider = stack.node.tryFindChild(id) as CustomResourceProvider + ?? new CustomResourceProvider(stack, id, props); + + return provider.serviceToken; + } + + /** + * The ARN of the provider's AWS Lambda function which should be used as the + * `serviceToken` when defining a custom resource. + * + * @example + * + * new CustomResource(this, 'MyCustomResource', { + * // ... + * serviceToken: provider.serviceToken // <--- here + * }) + * + */ + public readonly serviceToken: string; + + protected constructor(scope: Construct, id: string, props: CustomResourceProviderProps) { + super(scope, id); + + const stack = Stack.of(scope); + + // copy the entry point to the code directory + fs.copyFileSync(ENTRYPOINT_NODEJS_SOURCE, path.join(props.codeDirectory, `${ENTRYPOINT_FILENAME}.js`)); + + // verify we have an index file there + if (!fs.existsSync(path.join(props.codeDirectory, 'index.js'))) { + throw new Error(`cannot find ${props.codeDirectory}/index.js`); + } + + const staging = new AssetStaging(this, 'Staging', { + sourcePath: props.codeDirectory, + }); + + const asset = stack.addFileAsset({ + fileName: staging.stagedPath, + sourceHash: staging.sourceHash, + packaging: FileAssetPackaging.ZIP_DIRECTORY, + }); + + const policies = !props.policyStatements ? undefined : [ + { + PolicyName: 'Inline', + PolicyDocument: { + Version: '2012-10-17', + Statement: props.policyStatements, + }, + }, + ]; + + const role = new CfnResource(this, 'Role', { + type: 'AWS::IAM::Role', + properties: { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ { Action: 'sts:AssumeRole', Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' } } ], + }, + ManagedPolicyArns: [ + { 'Fn::Sub': 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole' }, + ], + Policies: policies, + }, + }); + + const timeout = props.timeout ?? Duration.minutes(15); + const memory = props.memorySize ?? Size.mebibytes(128); + + const handler = new CfnResource(this, 'Handler', { + type: 'AWS::Lambda::Function', + properties: { + Code: { + S3Bucket: asset.bucketName, + S3Key: asset.objectKey, + }, + Timeout: timeout.toSeconds(), + MemorySize: memory.toMebibytes(), + Handler: `${ENTRYPOINT_FILENAME}.handler`, + Role: role.getAtt('Arn'), + Runtime: 'nodejs12.x', + }, + }); + + handler.addDependsOn(role); + + this.serviceToken = Token.asString(handler.getAtt('Arn')); + } +} diff --git a/packages/@aws-cdk/core/lib/custom-resource-provider/index.ts b/packages/@aws-cdk/core/lib/custom-resource-provider/index.ts new file mode 100644 index 0000000000000..9ff36ec201b71 --- /dev/null +++ b/packages/@aws-cdk/core/lib/custom-resource-provider/index.ts @@ -0,0 +1 @@ +export * from './custom-resource-provider'; \ No newline at end of file diff --git a/packages/@aws-cdk/core/lib/custom-resource-provider/nodejs-entrypoint.ts b/packages/@aws-cdk/core/lib/custom-resource-provider/nodejs-entrypoint.ts new file mode 100644 index 0000000000000..c90bf96e684cf --- /dev/null +++ b/packages/@aws-cdk/core/lib/custom-resource-provider/nodejs-entrypoint.ts @@ -0,0 +1,139 @@ +import * as https from 'https'; +import * as url from 'url'; + +// for unit tests +export const external = { + sendHttpRequest: defaultSendHttpRequest, + log: defaultLog, + includeStackTraces: true, + userHandlerIndex: './index', +}; + +const CREATE_FAILED_PHYSICAL_ID_MARKER = 'AWSCDK::CustomResourceProviderFramework::CREATE_FAILED'; +const MISSING_PHYSICAL_ID_MARKER = 'AWSCDK::CustomResourceProviderFramework::MISSING_PHYSICAL_ID'; + +export type Response = AWSLambda.CloudFormationCustomResourceEvent & HandlerResponse; +export type Handler = (event: AWSLambda.CloudFormationCustomResourceEvent) => Promise; +export type HandlerResponse = undefined | { + Data?: any; + PhysicalResourceId?: string; + Reason?: string; + NoEcho?: boolean; +}; + +export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) { + external.log(JSON.stringify(event, undefined, 2)); + + // ignore DELETE event when the physical resource ID is the marker that + // indicates that this DELETE is a subsequent DELETE to a failed CREATE + // operation. + if (event.RequestType === 'Delete' && event.PhysicalResourceId === CREATE_FAILED_PHYSICAL_ID_MARKER) { + external.log('ignoring DELETE event caused by a failed CREATE event'); + await submitResponse('SUCCESS', event); + return; + } + + try { + // invoke the user handler. this is intentionally inside the try-catch to + // ensure that if there is an error it's reported as a failure to + // cloudformation (otherwise cfn waits). + // eslint-disable-next-line @typescript-eslint/no-require-imports + const userHandler: Handler = require(external.userHandlerIndex).handler; + const result = await userHandler(event); + + // validate user response and create the combined event + const responseEvent = renderResponse(event, result); + + // submit to cfn as success + await submitResponse('SUCCESS', responseEvent); + } catch (e) { + const resp: Response = { + ...event, + Reason: external.includeStackTraces ? e.stack : e.message, + }; + + if (!resp.PhysicalResourceId) { + // special case: if CREATE fails, which usually implies, we usually don't + // have a physical resource id. in this case, the subsequent DELETE + // operation does not have any meaning, and will likely fail as well. to + // address this, we use a marker so the provider framework can simply + // ignore the subsequent DELETE. + if (event.RequestType === 'Create') { + external.log('CREATE failed, responding with a marker physical resource id so that the subsequent DELETE will be ignored'); + resp.PhysicalResourceId = CREATE_FAILED_PHYSICAL_ID_MARKER; + } else { + // otherwise, if PhysicalResourceId is not specified, something is + // terribly wrong because all other events should have an ID. + external.log(`ERROR: Malformed event. "PhysicalResourceId" is required: ${JSON.stringify(event)}`); + } + } + + // this is an actual error, fail the activity altogether and exist. + await submitResponse('FAILED', resp); + } +} + +function renderResponse( + cfnRequest: AWSLambda.CloudFormationCustomResourceEvent & { PhysicalResourceId?: string }, + handlerResponse: void | HandlerResponse = { }): Response { + + // if physical ID is not returned, we have some defaults for you based + // on the request type. + const physicalResourceId = handlerResponse.PhysicalResourceId ?? cfnRequest.PhysicalResourceId ?? cfnRequest.RequestId; + + // if we are in DELETE and physical ID was changed, it's an error. + if (cfnRequest.RequestType === 'Delete' && physicalResourceId !== cfnRequest.PhysicalResourceId) { + throw new Error(`DELETE: cannot change the physical resource ID from "${cfnRequest.PhysicalResourceId}" to "${handlerResponse.PhysicalResourceId}" during deletion`); + } + + // merge request event and result event (result prevails). + return { + ...cfnRequest, + ...handlerResponse, + PhysicalResourceId: physicalResourceId, + }; +} + +async function submitResponse(status: 'SUCCESS' | 'FAILED', event: Response) { + const json: AWSLambda.CloudFormationCustomResourceResponse = { + Status: status, + Reason: event.Reason ?? status, + StackId: event.StackId, + RequestId: event.RequestId, + PhysicalResourceId: event.PhysicalResourceId || MISSING_PHYSICAL_ID_MARKER, + LogicalResourceId: event.LogicalResourceId, + NoEcho: event.NoEcho, + Data: event.Data, + }; + + external.log('submit response to cloudformation', json); + + const responseBody = JSON.stringify(json); + const parsedUrl = url.parse(event.ResponseURL); + const req = { + hostname: parsedUrl.hostname, + path: parsedUrl.path, + method: 'PUT', + headers: { 'content-type': '', 'content-length': responseBody.length }, + }; + + await external.sendHttpRequest(req, responseBody); +} + +async function defaultSendHttpRequest(options: https.RequestOptions, responseBody: string): Promise { + return new Promise((resolve, reject) => { + try { + const request = https.request(options, _ => resolve()); + request.on('error', reject); + request.write(responseBody); + request.end(); + } catch (e) { + reject(e); + } + }); +} + +function defaultLog(fmt: string, ...params: any[]) { + // tslint:disable-next-line:no-console + console.log(fmt, ...params); +} diff --git a/packages/@aws-cdk/core/lib/index.ts b/packages/@aws-cdk/core/lib/index.ts index 890b0fc215327..201de0947af84 100644 --- a/packages/@aws-cdk/core/lib/index.ts +++ b/packages/@aws-cdk/core/lib/index.ts @@ -48,6 +48,7 @@ export * from './fs'; export * from './custom-resource'; export * from './nested-stack'; +export * from './custom-resource-provider'; export * from './cfn-capabilities'; export * from './cloudformation.generated'; diff --git a/packages/@aws-cdk/core/test/custom-resource-provider/mock-provider/index.ts b/packages/@aws-cdk/core/test/custom-resource-provider/mock-provider/index.ts new file mode 100644 index 0000000000000..5a372f057593e --- /dev/null +++ b/packages/@aws-cdk/core/test/custom-resource-provider/mock-provider/index.ts @@ -0,0 +1,10 @@ +// tslint:disable: no-console + +export function handler(event: any) { + console.log('I am a custom resource'); + console.log(event); + return { + PhysicalResourceId: event.ResourceProperties.physicalResourceId, + Data: event.ResourceProperties.attributes, + }; +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/test/custom-resource-provider/test.custom-resource-provider.ts b/packages/@aws-cdk/core/test/custom-resource-provider/test.custom-resource-provider.ts new file mode 100644 index 0000000000000..99c8fb12d4e70 --- /dev/null +++ b/packages/@aws-cdk/core/test/custom-resource-provider/test.custom-resource-provider.ts @@ -0,0 +1,168 @@ +import * as fs from 'fs'; +import { Test } from 'nodeunit'; +import * as path from 'path'; +import { CustomResourceProvider, CustomResourceProviderRuntime, Duration, Size, Stack } from '../../lib'; +import { toCloudFormation } from '../util'; + +const TEST_HANDLER = `${__dirname}/mock-provider`; + +export = { + 'minimal configuration'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + CustomResourceProvider.getOrCreate(stack, 'Custom:MyResourceType', { + codeDirectory: TEST_HANDLER, + runtime: CustomResourceProviderRuntime.NODEJS_12, + }); + + // THEN + test.ok(fs.existsSync(path.join(TEST_HANDLER, '__entrypoint__.js')), 'expecting entrypoint to be copied to the handler directory'); + const cfn = toCloudFormation(stack); + test.deepEqual(cfn, { + Resources: { + CustomMyResourceTypeCustomResourceProviderRoleBD5E655F: { + Type: 'AWS::IAM::Role', + Properties: { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'lambda.amazonaws.com', + }, + }, + ], + }, + ManagedPolicyArns: [ + { + 'Fn::Sub': 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole', + }, + ], + }, + }, + CustomMyResourceTypeCustomResourceProviderHandler29FBDD2A: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: { + Ref: 'AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aS3Bucket1D703CB8', + }, + S3Key: { + 'Fn::Join': [ + '', + [ + { + 'Fn::Select': [ + 0, + { + 'Fn::Split': [ + '||', + { + Ref: 'AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aS3VersionKey01A97AE3', + }, + ], + }, + ], + }, + { + 'Fn::Select': [ + 1, + { + 'Fn::Split': [ + '||', + { + Ref: 'AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aS3VersionKey01A97AE3', + }, + ], + }, + ], + }, + ], + ], + }, + }, + Timeout: 900, + MemorySize: 128, + Handler: '__entrypoint__.handler', + Role: { + 'Fn::GetAtt': [ + 'CustomMyResourceTypeCustomResourceProviderRoleBD5E655F', + 'Arn', + ], + }, + Runtime: 'nodejs12.x', + }, + DependsOn: [ + 'CustomMyResourceTypeCustomResourceProviderRoleBD5E655F', + ], + }, + }, + Parameters: { + AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aS3Bucket1D703CB8: { + Type: 'String', + Description: 'S3 bucket for asset "d46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7a"', + }, + AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aS3VersionKey01A97AE3: { + Type: 'String', + Description: 'S3 key for asset version "d46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7a"', + }, + AssetParametersd46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7aArtifactHash16A571C9: { + Type: 'String', + Description: 'Artifact hash for asset "d46d1ebe2c1958c6352664721f77acb9c78131013956eb82d3d36cf503098e7a"', + }, + }, + }); + test.done(); + }, + + 'policyStatements can be used to add statements to the inline policy'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + CustomResourceProvider.getOrCreate(stack, 'Custom:MyResourceType', { + codeDirectory: TEST_HANDLER, + runtime: CustomResourceProviderRuntime.NODEJS_12, + policyStatements: [ + { statement1: 123 }, + { statement2: { foo: 111 } }, + ], + }); + + // THEN + const template = toCloudFormation(stack); + const role = template.Resources.CustomMyResourceTypeCustomResourceProviderRoleBD5E655F; + test.deepEqual(role.Properties.Policies, [{ + PolicyName: 'Inline', + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ statement1: 123 }, { statement2: { foo: 111 } }], + }, + }]); + test.done(); + }, + + 'memorySize and timeout'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + CustomResourceProvider.getOrCreate(stack, 'Custom:MyResourceType', { + codeDirectory: TEST_HANDLER, + runtime: CustomResourceProviderRuntime.NODEJS_12, + memorySize: Size.gibibytes(2), + timeout: Duration.minutes(5), + }); + + // THEN + const template = toCloudFormation(stack); + const lambda = template.Resources.CustomMyResourceTypeCustomResourceProviderHandler29FBDD2A; + test.deepEqual(lambda.Properties.MemorySize, 2048); + test.deepEqual(lambda.Properties.Timeout, 300); + test.done(); + }, +}; diff --git a/packages/@aws-cdk/core/test/custom-resource-provider/test.nodejs-entrypoint.ts b/packages/@aws-cdk/core/test/custom-resource-provider/test.nodejs-entrypoint.ts new file mode 100644 index 0000000000000..87935b7d93599 --- /dev/null +++ b/packages/@aws-cdk/core/test/custom-resource-provider/test.nodejs-entrypoint.ts @@ -0,0 +1,198 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as https from 'https'; +import { Test } from 'nodeunit'; +import * as os from 'os'; +import * as path from 'path'; +import * as url from 'url'; +import * as entrypoint from '../../lib/custom-resource-provider/nodejs-entrypoint'; + +export = { + 'handler return value is sent back to cloudformation as a success response': { + + async 'physical resource id (ref)'(test: Test) { + // GIVEN + const createEvent = makeEvent({ RequestType: 'Create' }); + + // WHEN + const response = await invokeHandler(createEvent, async _ => ({ PhysicalResourceId: 'returned-from-handler' })); + + // THEN + test.deepEqual(response.Status, 'SUCCESS'); + test.deepEqual(response.PhysicalResourceId, 'returned-from-handler'); + test.done(); + }, + + async 'data (attributes)'(test: Test) { + // GIVEN + const createEvent = makeEvent({ RequestType: 'Create' }); + + // WHEN + const response = await invokeHandler(createEvent, async _ => { + return { + Data: { + Attribute1: 'hello', + Attribute2: { + Foo: 1111, + }, + }, + }; + }); + + // THEN + test.deepEqual(response.Status, 'SUCCESS'); + test.deepEqual(response.PhysicalResourceId, '', 'physical id defaults to request id'); + test.deepEqual(response.Data, { + Attribute1: 'hello', + Attribute2: { + Foo: 1111, + }, + }); + test.done(); + }, + + async 'no echo'(test: Test) { + // GIVEN + const createEvent = makeEvent({ RequestType: 'Create' }); + + // WHEN + const response = await invokeHandler(createEvent, async _ => ({ NoEcho: true })); + + // THEN + test.deepEqual(response.Status, 'SUCCESS'); + test.deepEqual(response.NoEcho, true); + test.done(); + }, + + async 'reason'(test: Test) { + // GIVEN + const createEvent = makeEvent({ RequestType: 'Create' }); + + // WHEN + const response = await invokeHandler(createEvent, async _ => ({ Reason: 'hello, reason' })); + + // THEN + test.deepEqual(response.Status, 'SUCCESS'); + test.deepEqual(response.Reason, 'hello, reason'); + test.done(); + }, + }, + + async 'an error thrown by the handler is sent as a failure response to cloudformation'(test: Test) { + // GIVEN + const createEvent = makeEvent({ RequestType: 'Create' }); + + // WHEN + const response = await invokeHandler(createEvent, async _ => { + throw new Error('this is an error'); + }); + + // THEN + test.deepEqual(response, { + Status: 'FAILED', + Reason: 'this is an error', + StackId: '', + RequestId: '', + PhysicalResourceId: 'AWSCDK::CustomResourceProviderFramework::CREATE_FAILED', + LogicalResourceId: '', + }); + + test.done(); + }, + + async 'physical resource id cannot be changed in DELETE'(test: Test) { + // GIVEN + const event = makeEvent({ RequestType: 'Delete' }); + + // WHEN + const response = await invokeHandler(event, async _ => ({ + PhysicalResourceId: 'Changed', + })); + + // THEN + test.deepEqual(response, { + Status: 'FAILED', + Reason: 'DELETE: cannot change the physical resource ID from "undefined" to "Changed" during deletion', + StackId: '', + RequestId: '', + PhysicalResourceId: 'AWSCDK::CustomResourceProviderFramework::MISSING_PHYSICAL_ID', + LogicalResourceId: '', + }); + + test.done(); + }, + + async 'DELETE after CREATE is ignored with success'(test: Test) { + // GIVEN + const event = makeEvent({ + RequestType: 'Delete', + PhysicalResourceId: 'AWSCDK::CustomResourceProviderFramework::CREATE_FAILED', + }); + + // WHEN + const response = await invokeHandler(event, async _ => { + test.ok(false, 'handler should not be called'); + }); + + // THEN + test.deepEqual(response, { + Status: 'SUCCESS', + Reason: 'SUCCESS', + StackId: '', + RequestId: '', + PhysicalResourceId: 'AWSCDK::CustomResourceProviderFramework::CREATE_FAILED', + LogicalResourceId: '', + }); + + test.done(); + + }, +}; + +function makeEvent(req: Partial): AWSLambda.CloudFormationCustomResourceEvent { + return { + LogicalResourceId: '', + RequestId: '', + ResourceType: '', + ResponseURL: '', + ServiceToken: '', + StackId: '', + ResourceProperties: { + ServiceToken: '', + ...req.ResourceProperties, + }, + ...req, + } as any; +} + +async function invokeHandler(req: AWSLambda.CloudFormationCustomResourceEvent, userHandler: entrypoint.Handler) { + const parsedResponseUrl = url.parse(req.ResponseURL); + + // stage entry point and user handler. + const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk-custom-resource-provider-handler-test-')); + entrypoint.external.userHandlerIndex = path.join(workdir, 'index.js'); + fs.writeFileSync(entrypoint.external.userHandlerIndex, `exports.handler = ${userHandler.toString()};`); + + // do not include stack traces in failure responses so we can assert against them. + entrypoint.external.includeStackTraces = false; + + // disable logging + entrypoint.external.log = () => { + return; + }; + + let actualResponse; + entrypoint.external.sendHttpRequest = async (options: https.RequestOptions, responseBody: string): Promise => { + assert(options.hostname === parsedResponseUrl.hostname, 'request hostname expected to be based on response URL'); + assert(options.path === parsedResponseUrl.path, 'request path expected to be based on response URL'); + assert(options.method === 'PUT', 'request method is expected to be PUT'); + actualResponse = responseBody; + }; + + await entrypoint.handler(req); + if (!actualResponse) { + throw new Error('no response sent to cloudformation'); + } + + return JSON.parse(actualResponse) as AWSLambda.CloudFormationCustomResourceResponse; +} \ No newline at end of file diff --git a/packages/@aws-cdk/core/test/integration.custom-resources.readme b/packages/@aws-cdk/core/test/integration.custom-resources.readme new file mode 100644 index 0000000000000..bc1e807e01237 --- /dev/null +++ b/packages/@aws-cdk/core/test/integration.custom-resources.readme @@ -0,0 +1,5 @@ ++--------------------------------------------------------------------------+ +| Since cdk-integ depends on cdk which depends on @aws-cdk/core (as a "dev | +| dependency"), this integration test has been added to the package | +| @aws-cdk/aws-cloudformation under `test/integ.core-custom-resources.ts` | ++--------------------------------------------------------------------------+ From 6dd1a8dc1a05262ccfe8611658cd74bde345ee6a Mon Sep 17 00:00:00 2001 From: Penghao He Date: Tue, 5 May 2020 14:08:22 -0700 Subject: [PATCH 3/5] set blank taskDefinition and launchType when using external deployment controller. Fixes #6245. --- .../@aws-cdk/aws-ecs/lib/base/base-service.ts | 6 +- .../@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts | 4 +- .../aws-ecs/lib/fargate/fargate-service.ts | 4 +- .../aws-ecs/test/ec2/test.ec2-service.ts | 41 +++++++++++- .../test/fargate/test.fargate-service.ts | 62 ++++++++++++++++++- 5 files changed, 110 insertions(+), 7 deletions(-) diff --git a/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts index caf1cdaf93b86..b1c34e6fd0a6b 100644 --- a/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/base/base-service.ts @@ -347,7 +347,7 @@ export abstract class BaseService extends Resource propagateTags: props.propagateTags === PropagatedTagSource.NONE ? undefined : props.propagateTags, enableEcsManagedTags: props.enableECSManagedTags === undefined ? false : props.enableECSManagedTags, deploymentController: props.deploymentController, - launchType: props.launchType, + launchType: props.deploymentController?.type === DeploymentControllerType.EXTERNAL ? undefined : props.launchType, healthCheckGracePeriodSeconds: this.evaluateHealthGracePeriod(props.healthCheckGracePeriod), /* role: never specified, supplanted by Service Linked Role */ networkConfiguration: Lazy.anyValue({ produce: () => this.networkConfiguration }, { omitEmptyArray: true }), @@ -355,6 +355,10 @@ export abstract class BaseService extends Resource ...additionalProps, }); + if (props.deploymentController?.type === DeploymentControllerType.EXTERNAL) { + this.node.addWarning('taskDefinition and launchType are blanked out when using external deployment controller.'); + } + this.serviceArn = this.getResourceArnAttribute(this.resource.ref, { service: 'ecs', resource: 'service', 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 4d3f3de89a628..d845bb4a6a48b 100644 --- a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts @@ -1,6 +1,6 @@ import * as ec2 from '@aws-cdk/aws-ec2'; import { Construct, Lazy, Resource, Stack } from '@aws-cdk/core'; -import { BaseService, BaseServiceOptions, IBaseService, IService, LaunchType, PropagatedTagSource } from '../base/base-service'; +import { BaseService, BaseServiceOptions, DeploymentControllerType, IBaseService, IService, LaunchType, PropagatedTagSource } from '../base/base-service'; import { fromServiceAtrributes } from '../base/from-service-attributes'; import { NetworkMode, TaskDefinition } from '../base/task-definition'; import { ICluster } from '../cluster'; @@ -181,7 +181,7 @@ export class Ec2Service extends BaseService implements IEc2Service { }, { cluster: props.cluster.clusterName, - taskDefinition: props.taskDefinition.taskDefinitionArn, + taskDefinition: props.deploymentController?.type === DeploymentControllerType.EXTERNAL ? undefined : props.taskDefinition.taskDefinitionArn, placementConstraints: Lazy.anyValue({ produce: () => this.constraints }, { omitEmptyArray: true }), placementStrategies: Lazy.anyValue({ produce: () => this.strategies }, { omitEmptyArray: true }), schedulingStrategy: props.daemon ? 'DAEMON' : 'REPLICA', 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 cc1362f8ba270..855fdb9775d38 100644 --- a/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/fargate/fargate-service.ts @@ -1,6 +1,6 @@ import * as ec2 from '@aws-cdk/aws-ec2'; import * as cdk from '@aws-cdk/core'; -import { BaseService, BaseServiceOptions, IBaseService, IService, LaunchType, PropagatedTagSource } from '../base/base-service'; +import { BaseService, BaseServiceOptions, DeploymentControllerType, IBaseService, IService, LaunchType, PropagatedTagSource } from '../base/base-service'; import { fromServiceAtrributes } from '../base/from-service-attributes'; import { TaskDefinition } from '../base/task-definition'; import { ICluster } from '../cluster'; @@ -139,7 +139,7 @@ export class FargateService extends BaseService implements IFargateService { enableECSManagedTags: props.enableECSManagedTags, }, { cluster: props.cluster.clusterName, - taskDefinition: props.taskDefinition.taskDefinitionArn, + taskDefinition: props.deploymentController?.type === DeploymentControllerType.EXTERNAL ? undefined : props.taskDefinition.taskDefinitionArn, platformVersion: props.platformVersion, }, props.taskDefinition); diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts index c870f53ddea35..ca5e8f7bbc638 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts @@ -6,7 +6,7 @@ import * as cloudmap from '@aws-cdk/aws-servicediscovery'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; import * as ecs from '../../lib'; -import { LaunchType, PropagatedTagSource } from '../../lib/base/base-service'; +import { DeploymentControllerType, LaunchType, PropagatedTagSource } from '../../lib/base/base-service'; import { PlacementConstraint, PlacementStrategy } from '../../lib/placement'; export = { @@ -262,6 +262,45 @@ export = { test.done(); }, + 'ignore task definition and launch type if deployment controller is set to be EXTERNAL'(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'); + + taskDefinition.addContainer('web', { + image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), + memoryLimitMiB: 512, + }); + + const service = new ecs.Ec2Service(stack, 'Ec2Service', { + cluster, + taskDefinition, + deploymentController: { + type: DeploymentControllerType.EXTERNAL, + }, + }); + + // THEN + test.deepEqual(service.node.metadata[0].data, 'taskDefinition and launchType are blanked out when using external deployment controller.'); + expect(stack).to(haveResource('AWS::ECS::Service', { + Cluster: { + Ref: 'EcsCluster97242B84', + }, + DeploymentConfiguration: { + MaximumPercent: 200, + MinimumHealthyPercent: 50, + }, + DesiredCount: 1, + SchedulingStrategy: 'REPLICA', + EnableECSManagedTags: false, + })); + + test.done(); + }, + 'errors if daemon and desiredCount both specified'(test: Test) { // GIVEN const stack = new cdk.Stack(); diff --git a/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts b/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts index affc8dbcdbd50..9a652b61fe0d4 100644 --- a/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts +++ b/packages/@aws-cdk/aws-ecs/test/fargate/test.fargate-service.ts @@ -7,7 +7,7 @@ import * as cloudmap from '@aws-cdk/aws-servicediscovery'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; import * as ecs from '../../lib'; -import { LaunchType } from '../../lib/base/base-service'; +import { DeploymentControllerType, LaunchType } from '../../lib/base/base-service'; export = { 'When creating a Fargate Service': { @@ -301,6 +301,66 @@ export = { test.done(); }, + 'ignore task definition and launch type if deployment controller is set to be EXTERNAL'(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'); + + taskDefinition.addContainer('web', { + image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), + }); + + const service = new ecs.FargateService(stack, 'FargateService', { + cluster, + taskDefinition, + deploymentController: { + type: DeploymentControllerType.EXTERNAL, + }, + }); + + // THEN + test.deepEqual(service.node.metadata[0].data, 'taskDefinition and launchType are blanked out when using external deployment controller.'); + expect(stack).to(haveResource('AWS::ECS::Service', { + Cluster: { + Ref: 'EcsCluster97242B84', + }, + DeploymentConfiguration: { + MaximumPercent: 200, + MinimumHealthyPercent: 50, + }, + DeploymentController: { + Type: 'EXTERNAL', + }, + DesiredCount: 1, + EnableECSManagedTags: false, + NetworkConfiguration: { + AwsvpcConfiguration: { + AssignPublicIp: 'DISABLED', + SecurityGroups: [ + { + 'Fn::GetAtt': [ + 'FargateServiceSecurityGroup0A0E79CB', + 'GroupId', + ], + }, + ], + Subnets: [ + { + Ref: 'MyVpcPrivateSubnet1Subnet5057CF7E', + }, + { + Ref: 'MyVpcPrivateSubnet2Subnet0040C983', + }, + ], + }, + }, + })); + + test.done(); + }, + 'errors when no container specified on task definition'(test: Test) { // GIVEN const stack = new cdk.Stack(); From 19e3fd800af5a32dfb359f4be4717fbf3adb91df Mon Sep 17 00:00:00 2001 From: Penghao He Date: Tue, 5 May 2020 16:26:06 -0700 Subject: [PATCH 4/5] fix(ecs): update minHealthyPercent constrain for ec2service using daemon strategy (#7814) --- packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts | 4 ++-- packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) 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 d845bb4a6a48b..924d212075ae4 100644 --- a/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts +++ b/packages/@aws-cdk/aws-ecs/lib/ec2/ec2-service.ts @@ -154,8 +154,8 @@ export class Ec2Service extends BaseService implements IEc2Service { throw new Error('Maximum percent must be 100 for daemon mode.'); } - if (props.daemon && props.minHealthyPercent !== undefined && props.minHealthyPercent !== 0) { - throw new Error('Minimum healthy percent must be 0 for daemon mode.'); + if (props.minHealthyPercent !== undefined && props.maxHealthyPercent !== undefined && props.minHealthyPercent >= props.maxHealthyPercent) { + throw new Error('Minimum healthy percent must be less than maximum healthy percent.'); } if (!props.taskDefinition.isEc2Compatible) { diff --git a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts index ca5e8f7bbc638..1250066b2c09a 100644 --- a/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts +++ b/packages/@aws-cdk/aws-ecs/test/ec2/test.ec2-service.ts @@ -351,7 +351,7 @@ export = { test.done(); }, - 'errors if daemon and minimum not 0'(test: Test) { + 'errors if minimum not less than maximum'(test: Test) { // GIVEN const stack = new cdk.Stack(); const vpc = new ec2.Vpc(stack, 'MyVpc', {}); @@ -369,9 +369,10 @@ export = { cluster, taskDefinition, daemon: true, - minHealthyPercent: 50, + minHealthyPercent: 100, + maxHealthyPercent: 100, }); - }, /Minimum healthy percent must be 0 for daemon mode./); + }, /Minimum healthy percent must be less than maximum healthy percent./); test.done(); }, From 2923c4488a808d47d5ea42dbecbfa740e90d0d8b Mon Sep 17 00:00:00 2001 From: Shiv Lakshminarayan Date: Tue, 5 May 2020 23:31:37 -0700 Subject: [PATCH 5/5] chore: remove keywords for `cloudlib` from `package.json` --- packages/@aws-cdk/aws-codepipeline-actions/package.json | 4 +--- packages/@aws-cdk/aws-codepipeline/package.json | 4 +--- .../aws-elasticloadbalancingv2-targets/package.json | 6 +++--- packages/@aws-cdk/aws-events-targets/package.json | 4 +--- packages/@aws-cdk/aws-events/package.json | 6 +++--- packages/@aws-cdk/aws-iam/package.json | 4 +--- 6 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/@aws-cdk/aws-codepipeline-actions/package.json b/packages/@aws-cdk/aws-codepipeline-actions/package.json index a0f59a5e5ed50..70d8ff02b9ba4 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/package.json +++ b/packages/@aws-cdk/aws-codepipeline-actions/package.json @@ -50,10 +50,8 @@ }, "keywords": [ "aws", - "aws-clib", - "aws-cloudlib", "cdk", - "cloudlib", + "constructs", "codepipeline", "pipeline" ], diff --git a/packages/@aws-cdk/aws-codepipeline/package.json b/packages/@aws-cdk/aws-codepipeline/package.json index 0f574afc32256..50bae2c548457 100644 --- a/packages/@aws-cdk/aws-codepipeline/package.json +++ b/packages/@aws-cdk/aws-codepipeline/package.json @@ -55,10 +55,8 @@ }, "keywords": [ "aws", - "aws-clib", - "aws-cloudlib", "cdk", - "cloudlib", + "constructs", "codepipeline", "pipeline" ], diff --git a/packages/@aws-cdk/aws-elasticloadbalancingv2-targets/package.json b/packages/@aws-cdk/aws-elasticloadbalancingv2-targets/package.json index 41fc004f0d9b4..ff807a3b702cf 100644 --- a/packages/@aws-cdk/aws-elasticloadbalancingv2-targets/package.json +++ b/packages/@aws-cdk/aws-elasticloadbalancingv2-targets/package.json @@ -66,9 +66,9 @@ "keywords": [ "aws", "cdk", - "cloudlib", - "aws-cloudlib", - "aws-clib" + "constructs", + "elasticloadbalancing", + "elb" ], "author": { "name": "Amazon Web Services", diff --git a/packages/@aws-cdk/aws-events-targets/package.json b/packages/@aws-cdk/aws-events-targets/package.json index 80e4c90b84f75..f3e93d3b7e875 100644 --- a/packages/@aws-cdk/aws-events-targets/package.json +++ b/packages/@aws-cdk/aws-events-targets/package.json @@ -71,9 +71,7 @@ "keywords": [ "aws", "cdk", - "cloudlib", - "aws-cloudlib", - "aws-clib", + "constructs", "cloudwatch", "events" ], diff --git a/packages/@aws-cdk/aws-events/package.json b/packages/@aws-cdk/aws-events/package.json index 1cbd6a9fbfbfc..1316ef04bbb92 100644 --- a/packages/@aws-cdk/aws-events/package.json +++ b/packages/@aws-cdk/aws-events/package.json @@ -52,9 +52,9 @@ "keywords": [ "aws", "cdk", - "cloudlib", - "aws-cloudlib", - "aws-clib" + "constructs", + "cloudwatch", + "events" ], "author": { "name": "Amazon Web Services", diff --git a/packages/@aws-cdk/aws-iam/package.json b/packages/@aws-cdk/aws-iam/package.json index 70bb91dab947f..80df3c649ebbf 100644 --- a/packages/@aws-cdk/aws-iam/package.json +++ b/packages/@aws-cdk/aws-iam/package.json @@ -52,9 +52,7 @@ "keywords": [ "aws", "cdk", - "cloudlib", - "aws-cloudlib", - "aws-clib", + "constructs", "iam" ], "author": {