From 6da86cf52c4f6ef4b69592ea1f5afb8fc4362624 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Thu, 17 Feb 2022 12:51:19 +0200 Subject: [PATCH 1/8] feat: triggers Triggers allows you to execute code during deployment of CDK apps. They are very useful to implement "self tests" or populate initial data. If a trigger fails, deployment fails. Usage: ```ts new Trigger(this, 'VerifySomething', { dependencies: [ scopes... ], handler: lambdaFunction }); ``` This is a productization of https://github.com/cdklabs/cdk-triggers. --- packages/@aws-cdk/triggers/.eslintrc.js | 3 + packages/@aws-cdk/triggers/.gitignore | 19 ++ packages/@aws-cdk/triggers/.npmignore | 28 +++ packages/@aws-cdk/triggers/LICENSE | 201 ++++++++++++++++++ packages/@aws-cdk/triggers/NOTICE | 2 + packages/@aws-cdk/triggers/README.md | 97 +++++++++ packages/@aws-cdk/triggers/jest.config.js | 2 + packages/@aws-cdk/triggers/lib/index.ts | 1 + .../@aws-cdk/triggers/lib/lambda/.gitignore | 1 + .../@aws-cdk/triggers/lib/lambda/index.ts | 48 +++++ packages/@aws-cdk/triggers/lib/triggers.ts | 65 ++++++ packages/@aws-cdk/triggers/package.json | 115 ++++++++++ .../test/__snapshots__/trigger.test.js.snap | 199 +++++++++++++++++ .../test/integ.triggers.expected.json | 191 +++++++++++++++++ .../@aws-cdk/triggers/test/integ.triggers.ts | 16 ++ .../@aws-cdk/triggers/test/triggers.test.ts | 38 ++++ 16 files changed, 1026 insertions(+) create mode 100644 packages/@aws-cdk/triggers/.eslintrc.js create mode 100644 packages/@aws-cdk/triggers/.gitignore create mode 100644 packages/@aws-cdk/triggers/.npmignore create mode 100644 packages/@aws-cdk/triggers/LICENSE create mode 100644 packages/@aws-cdk/triggers/NOTICE create mode 100644 packages/@aws-cdk/triggers/README.md create mode 100644 packages/@aws-cdk/triggers/jest.config.js create mode 100644 packages/@aws-cdk/triggers/lib/index.ts create mode 100644 packages/@aws-cdk/triggers/lib/lambda/.gitignore create mode 100644 packages/@aws-cdk/triggers/lib/lambda/index.ts create mode 100644 packages/@aws-cdk/triggers/lib/triggers.ts create mode 100644 packages/@aws-cdk/triggers/package.json create mode 100644 packages/@aws-cdk/triggers/test/__snapshots__/trigger.test.js.snap create mode 100644 packages/@aws-cdk/triggers/test/integ.triggers.expected.json create mode 100644 packages/@aws-cdk/triggers/test/integ.triggers.ts create mode 100644 packages/@aws-cdk/triggers/test/triggers.test.ts diff --git a/packages/@aws-cdk/triggers/.eslintrc.js b/packages/@aws-cdk/triggers/.eslintrc.js new file mode 100644 index 0000000000000..2658ee8727166 --- /dev/null +++ b/packages/@aws-cdk/triggers/.eslintrc.js @@ -0,0 +1,3 @@ +const baseConfig = require('@aws-cdk/cdk-build-tools/config/eslintrc'); +baseConfig.parserOptions.project = __dirname + '/tsconfig.json'; +module.exports = baseConfig; diff --git a/packages/@aws-cdk/triggers/.gitignore b/packages/@aws-cdk/triggers/.gitignore new file mode 100644 index 0000000000000..d8a8561d50885 --- /dev/null +++ b/packages/@aws-cdk/triggers/.gitignore @@ -0,0 +1,19 @@ +*.js +*.js.map +*.d.ts +tsconfig.json +node_modules +*.generated.ts +dist +.jsii + +.LAST_BUILD +.nyc_output +coverage +nyc.config.js +.LAST_PACKAGE +*.snk +!.eslintrc.js +!jest.config.js + +junit.xml \ No newline at end of file diff --git a/packages/@aws-cdk/triggers/.npmignore b/packages/@aws-cdk/triggers/.npmignore new file mode 100644 index 0000000000000..6077c04a3e8a3 --- /dev/null +++ b/packages/@aws-cdk/triggers/.npmignore @@ -0,0 +1,28 @@ +# Don't include original .ts files when doing `npm pack` +*.ts +!*.d.ts +coverage +.nyc_output +*.tgz + +dist +.LAST_PACKAGE +.LAST_BUILD +!*.js + +# Include .jsii +!.jsii + +*.snk + +*.tsbuildinfo + +tsconfig.json +.eslintrc.js +jest.config.js + +# exclude cdk artifacts +**/cdk.out +junit.xml +!*.lit.ts +test/ \ No newline at end of file diff --git a/packages/@aws-cdk/triggers/LICENSE b/packages/@aws-cdk/triggers/LICENSE new file mode 100644 index 0000000000000..82ad00bb02d0b --- /dev/null +++ b/packages/@aws-cdk/triggers/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/@aws-cdk/triggers/NOTICE b/packages/@aws-cdk/triggers/NOTICE new file mode 100644 index 0000000000000..1b7adbb891265 --- /dev/null +++ b/packages/@aws-cdk/triggers/NOTICE @@ -0,0 +1,2 @@ +AWS Cloud Development Kit (AWS CDK) +Copyright 2018-2022 Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/packages/@aws-cdk/triggers/README.md b/packages/@aws-cdk/triggers/README.md new file mode 100644 index 0000000000000..190d68a385030 --- /dev/null +++ b/packages/@aws-cdk/triggers/README.md @@ -0,0 +1,97 @@ +# Triggers + + +--- + +![cdk-constructs: Stable](https://img.shields.io/badge/cdk--constructs-stable-success.svg?style=for-the-badge) + +--- + + + + +Execute code as part of a CDK app deployment. + +## Usage + +You can trigger the execution of an AWS Lambda function during deployment after +a resource or groups of resources are provisioned. + +The library includes constructs that represent different triggers. The `BeforeCreate` and `AfterCreate` constructs can be used to trigger a handler before/after a set of resources have been created. + +```ts +import { Trigger } from '@aws-cdk/triggers'; + +new Trigger(this, 'MyTrigger', { + dependencies: [resource1, resource2, stack, ...], + handler: myLambdaFunction, +}); +``` + +Where `dependencies` is a list of __construct scopes__ which determine when +`handler` is invoked. Scopes can be either specific resources or composite +constructs (in which case all the resources in the construct will be used as a +group). The scope can also be a `Stack`, in which case the trigger will apply to +all the resources within the stack (same as any composite construct). All scopes +must roll up to the same stack. + +Let's look at an example. Say we want to publish a notification to an SNS topic +that says "hello, topic!" after the topic is created. + +```ts +// define a topic +const topic = new sns.Topic(this, 'MyTopic'); + +// define a lambda function which publishes a message to the topic +const publisher = new NodeJsFunction(this, 'PublishToTopic'); +publisher.addEnvironment('TOPIC_ARN', topic.topicArn); +publisher.addEnvironment('MESSAGE', 'Hello, topic!'); +topic.grantPublish(publisher); + +// trigger the lambda function after the topic is created +new triggers.Trigger(this, 'SayHello', { + handler: publisher +}); +``` + +NOTE: since `publisher` already takes an implicit dependency on `topic.topicArn` +(through its environment), we don't have to explicitly specify `dependencies`. + +## Additional Notes + +* If the trigger fails, deployment fails. This is a useful property that can be + leveraged to create triggers that "self-test" a stack. +* If the handler changes (configuration or code), the trigger gets re-executed + (trigger is bound to `lambda.currentVersion` which gets recreated when the + function changes). + +## Roadmap + +* Additional periodic execution after deployment (`repeatOnSchedule`). +* Async checks (`retryWithTimeout`) +* Execute shell command inside a Docker image + +## Use Cases + +Here are some examples of use cases for triggers: + +* __Intrinsic validations__: execute a check to verify that a resource or set of resources have been deployed correctly + * Test connections to external systems (e.g. security tokens are valid) + * Verify integration between resources is working as expected + * Execute as one-off and also periodically after deployment + * Wait for data to start flowing (e.g. wait for a metric) before deployment is successful +* __Data priming__: add data to resources after they are created + * CodeCommit repo + initial commit + * Database + test data for development +* Check prerequisites before deployment + * Account limits + * Availability of external services +* Connect to other accounts + +## Security + +See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. + +## License + +This project is licensed under the Apache-2.0 License. diff --git a/packages/@aws-cdk/triggers/jest.config.js b/packages/@aws-cdk/triggers/jest.config.js new file mode 100644 index 0000000000000..3a2fd93a1228a --- /dev/null +++ b/packages/@aws-cdk/triggers/jest.config.js @@ -0,0 +1,2 @@ +const baseConfig = require('@aws-cdk/cdk-build-tools/config/jest.config'); +module.exports = baseConfig; diff --git a/packages/@aws-cdk/triggers/lib/index.ts b/packages/@aws-cdk/triggers/lib/index.ts new file mode 100644 index 0000000000000..77532c116519d --- /dev/null +++ b/packages/@aws-cdk/triggers/lib/index.ts @@ -0,0 +1 @@ +export * from './triggers'; diff --git a/packages/@aws-cdk/triggers/lib/lambda/.gitignore b/packages/@aws-cdk/triggers/lib/lambda/.gitignore new file mode 100644 index 0000000000000..eef5e8dd7e177 --- /dev/null +++ b/packages/@aws-cdk/triggers/lib/lambda/.gitignore @@ -0,0 +1 @@ +__entrypoint__.js diff --git a/packages/@aws-cdk/triggers/lib/lambda/index.ts b/packages/@aws-cdk/triggers/lib/lambda/index.ts new file mode 100644 index 0000000000000..3e9eac66d1f6f --- /dev/null +++ b/packages/@aws-cdk/triggers/lib/lambda/index.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-console */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import * as AWS from 'aws-sdk'; + +exports.handler = async function(event: AWSLambda.CloudFormationCustomResourceEvent) { + console.log({ event }); + + if (event.RequestType === 'Delete') { + console.log('not calling trigger on DELETE'); + return; + } + + const handlerArn = event.ResourceProperties.HandlerArn; + if (!handlerArn) { + throw new Error('The "HandlerArn" property is required'); + } + + const lambda = new AWS.Lambda(); + const invokeRequest = { FunctionName: handlerArn }; + console.log({ invokeRequest }); + const invokeResponse = await lambda.invoke(invokeRequest).promise(); + console.log({ invokeResponse }); + + if (invokeResponse.StatusCode !== 200) { + throw new Error(`Invoke failed with status code ${invokeResponse.StatusCode}`); + } + + // if the lambda function throws an error, parse the error message and fail + if (invokeResponse.FunctionError) { + throw new Error(parseError(invokeResponse.Payload?.toString())); + } +}; + +/** + * Parse the error message from the lambda function. + */ +function parseError(payload: string | undefined): string { + if (!payload) { return 'unknown handler error'; } + try { + const error = JSON.parse(payload); + const concat = [error.errorMessage, error.trace].filter(x => x).join('\n'); + return concat.length > 0 ? concat : payload; + } catch (e) { + // fall back to just returning the payload + return payload; + } +} diff --git a/packages/@aws-cdk/triggers/lib/triggers.ts b/packages/@aws-cdk/triggers/lib/triggers.ts new file mode 100644 index 0000000000000..f690f4b2941a4 --- /dev/null +++ b/packages/@aws-cdk/triggers/lib/triggers.ts @@ -0,0 +1,65 @@ +import { join } from 'path'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { CustomResource, CustomResourceProvider, CustomResourceProviderRuntime } from '@aws-cdk/core'; + +import { Construct } from 'constructs'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Props for `Trigger`. + */ +export interface TriggerProps { + /** + * Resources to trigger on. Resources can come from any stack in the app. + * + * @default [] Run the trigger at any time during stack deployment. + */ + readonly dependencies?: Construct[]; + + /** + * The handler to execute once after all the resources are created. + * + * The trigger will be executed every time the handler changes (code or + * configuration). + */ + readonly handler: lambda.Function; +} + +/** + * Triggers an AWS Lambda function during deployment. + */ +export class Trigger extends CoreConstruct { + constructor(scope: Construct, id: string, props: TriggerProps) { + super(scope, id); + + const provider = CustomResourceProvider.getOrCreateProvider(this, 'AWSCDK.TriggerCustomResourceProvider', { + runtime: CustomResourceProviderRuntime.NODEJS_14_X, + codeDirectory: join(__dirname, 'lambda'), + policyStatements: [ + { + Effect: 'Allow', + Action: ['lambda:InvokeFunction'], + Resource: [props.handler.currentVersion.functionArn], + }, + ], + }); + + const resource = new CustomResource(this, 'Resource', { + resourceType: 'Custom::Trigger', + serviceToken: provider.serviceToken, + properties: { + // we use 'currentVersion' because a new version is created every time the + // handler changes (either code or configuration). this will have the effect + // that the trigger will be executed every time the handler is changed. + HandlerArn: props.handler.currentVersion.functionArn, + }, + }); + + // add a dependency between our resource and the resources we want to invoke + // after. + resource.node.addDependency(...props.dependencies ?? []); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/triggers/package.json b/packages/@aws-cdk/triggers/package.json new file mode 100644 index 0000000000000..9fc70ef81d639 --- /dev/null +++ b/packages/@aws-cdk/triggers/package.json @@ -0,0 +1,115 @@ +{ + "name": "@aws-cdk/triggers", + "version": "0.0.0", + "description": "Execute AWS Lambda functions during deployment", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "jsii": { + "outdir": "dist", + "targets": { + "java": { + "package": "software.amazon.awscdk.triggers", + "maven": { + "groupId": "software.amazon.awscdk", + "artifactId": "cdk-triggers" + } + }, + "dotnet": { + "namespace": "Amazon.CDK.Triggers", + "packageId": "Amazon.CDK.Triggers", + "iconUrl": "https://raw.githubusercontent.com/aws/aws-cdk/master/logo/default-256-dark.png" + }, + "python": { + "distName": "aws-cdk.triggers", + "module": "aws_cdk.triggers", + "classifiers": [ + "Framework :: AWS CDK", + "Framework :: AWS CDK :: 1" + ] + } + }, + "projectReferences": true, + "metadata": { + "jsii": { + "rosetta": { + "strict": true + } + } + } + }, + "repository": { + "type": "git", + "url": "https://github.com/aws/aws-cdk.git", + "directory": "packages/@aws-cdk/triggers" + }, + "scripts": { + "build": "cdk-build", + "watch": "cdk-watch", + "lint": "cdk-lint", + "test": "cdk-test", + "integ": "cdk-integ", + "pkglint": "pkglint -f", + "package": "cdk-package", + "awslint": "cdk-awslint", + "build+test": "yarn build && yarn test", + "build+test+package": "yarn build+test && yarn package", + "compat": "cdk-compat", + "rosetta:extract": "yarn --silent jsii-rosetta extract", + "build+extract": "yarn build && yarn rosetta:extract", + "build+test+extract": "yarn build+test && yarn rosetta:extract" + }, + "keywords": [ + "aws", + "cdk", + "example", + "construct", + "library", + "triggers" + ], + "author": { + "name": "Amazon Web Services", + "url": "https://aws.amazon.com", + "organization": true + }, + "license": "Apache-2.0", + "devDependencies": { + "@aws-cdk/assertions": "0.0.0", + "@aws-cdk/cdk-build-tools": "0.0.0", + "@aws-cdk/cdk-integ-tools": "0.0.0", + "@aws-cdk/aws-sns": "0.0.0", + "aws-sdk": "^2.848.0", + "@aws-cdk/pkglint": "0.0.0", + "@types/jest": "^27.4.0", + "jest": "^27.5.1" + }, + "dependencies": { + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/core": "0.0.0", + "constructs": "^3.3.69" + }, + "homepage": "https://github.com/aws/aws-cdk", + "peerDependencies": { + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/core": "0.0.0", + "constructs": "^3.3.69" + }, + "engines": { + "node": ">= 10.13.0 <13 || >=13.7.0" + }, + "stability": "stable", + "maturity": "stable", + "awscdkio": { + "announce": false + }, + "cdk-build": { + "env": { + "AWSLINT_BASE_CONSTRUCT": true + } + }, + "ubergen": { + "exclude": true + }, + "publishConfig": { + "tag": "latest" + } +} diff --git a/packages/@aws-cdk/triggers/test/__snapshots__/trigger.test.js.snap b/packages/@aws-cdk/triggers/test/__snapshots__/trigger.test.js.snap new file mode 100644 index 0000000000000..bf1cdc566cb84 --- /dev/null +++ b/packages/@aws-cdk/triggers/test/__snapshots__/trigger.test.js.snap @@ -0,0 +1,199 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`minimal usage 1`] = ` +Object { + "Parameters": Object { + "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffArtifactHash4518D68D": Object { + "Description": "Artifact hash for asset \\"9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ff\\"", + "Type": "String", + }, + "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3Bucket8B4BAF9C": Object { + "Description": "S3 bucket for asset \\"9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ff\\"", + "Type": "String", + }, + "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3VersionKey2B3BD417": Object { + "Description": "S3 key for asset version \\"9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ff\\"", + "Type": "String", + }, + }, + "Resources": Object { + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderHandler97BECD91": Object { + "DependsOn": Array [ + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A", + ], + "Properties": Object { + "Code": Object { + "S3Bucket": Object { + "Ref": "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3Bucket8B4BAF9C", + }, + "S3Key": Object { + "Fn::Join": Array [ + "", + Array [ + Object { + "Fn::Select": Array [ + 0, + Object { + "Fn::Split": Array [ + "||", + Object { + "Ref": "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3VersionKey2B3BD417", + }, + ], + }, + ], + }, + Object { + "Fn::Select": Array [ + 1, + Object { + "Fn::Split": Array [ + "||", + Object { + "Ref": "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3VersionKey2B3BD417", + }, + ], + }, + ], + }, + ], + ], + }, + }, + "Handler": "__entrypoint__.handler", + "MemorySize": 128, + "Role": Object { + "Fn::GetAtt": Array [ + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A", + "Arn", + ], + }, + "Runtime": "nodejs14.x", + "Timeout": 900, + }, + "Type": "AWS::Lambda::Function", + }, + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": Array [ + Object { + "Fn::Sub": "arn:\${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + }, + ], + "Policies": Array [ + Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "lambda:InvokeFunction", + ], + "Effect": "Allow", + "Resource": Array [ + "*", + ], + }, + ], + "Version": "2012-10-17", + }, + "PolicyName": "Inline", + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + "MyTopic86869434": Object { + "Type": "AWS::SNS::Topic", + }, + "MyTriggerB6CCCACE": Object { + "DeletionPolicy": "Delete", + "DependsOn": Array [ + "MyTopic86869434", + ], + "Properties": Object { + "HandlerArn": Object { + "Ref": "MyTriggerHandlerCurrentVersionC0B6BBD40f3abd954eb77fda7e548d681c7fa667", + }, + "ServiceToken": Object { + "Fn::GetAtt": Array [ + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderHandler97BECD91", + "Arn", + ], + }, + }, + "Type": "Custom::Trigger", + "UpdateReplacePolicy": "Delete", + }, + "MyTriggerHandlerCurrentVersionC0B6BBD40f3abd954eb77fda7e548d681c7fa667": Object { + "Properties": Object { + "FunctionName": Object { + "Ref": "MyTriggerHandlerD6B1FF23", + }, + }, + "Type": "AWS::Lambda::Version", + }, + "MyTriggerHandlerD6B1FF23": Object { + "DependsOn": Array [ + "MyTriggerHandlerServiceRoleFC0CFFAB", + ], + "Properties": Object { + "Code": Object { + "ZipFile": "zoo", + }, + "Handler": "index.handler", + "Role": Object { + "Fn::GetAtt": Array [ + "MyTriggerHandlerServiceRoleFC0CFFAB", + "Arn", + ], + }, + "Runtime": "nodejs12.x", + }, + "Type": "AWS::Lambda::Function", + }, + "MyTriggerHandlerServiceRoleFC0CFFAB": Object { + "Properties": Object { + "AssumeRolePolicyDocument": Object { + "Statement": Array [ + Object { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": Object { + "Service": "lambda.amazonaws.com", + }, + }, + ], + "Version": "2012-10-17", + }, + "ManagedPolicyArns": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", + ], + ], + }, + ], + }, + "Type": "AWS::IAM::Role", + }, + }, +} +`; diff --git a/packages/@aws-cdk/triggers/test/integ.triggers.expected.json b/packages/@aws-cdk/triggers/test/integ.triggers.expected.json new file mode 100644 index 0000000000000..5fd7b71d39385 --- /dev/null +++ b/packages/@aws-cdk/triggers/test/integ.triggers.expected.json @@ -0,0 +1,191 @@ +{ + "Resources": { + "MyFunctionServiceRole3C357FF2": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "MyFunction3BAA72D1": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = async function() { console.log(\"hi\"); };" + }, + "Role": { + "Fn::GetAtt": [ + "MyFunctionServiceRole3C357FF2", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "MyFunctionServiceRole3C357FF2" + ] + }, + "MyFunctionCurrentVersion197490AF463fe24fa0d2cb07a7b26515dfecc497": { + "Type": "AWS::Lambda::Version", + "Properties": { + "FunctionName": { + "Ref": "MyFunction3BAA72D1" + } + } + }, + "MyTriggerB6CCCACE": { + "Type": "Custom::Trigger", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderHandler97BECD91", + "Arn" + ] + }, + "HandlerArn": { + "Ref": "MyFunctionCurrentVersion197490AF463fe24fa0d2cb07a7b26515dfecc497" + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A": { + "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": [ + { + "PolicyName": "Inline", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction" + ], + "Resource": [ + { + "Ref": "MyFunctionCurrentVersion197490AF463fe24fa0d2cb07a7b26515dfecc497" + } + ] + } + ] + } + } + ] + } + }, + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderHandler97BECD91": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3Bucket8B4BAF9C" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3VersionKey2B3BD417" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3VersionKey2B3BD417" + } + ] + } + ] + } + ] + ] + } + }, + "Timeout": 900, + "MemorySize": 128, + "Handler": "__entrypoint__.handler", + "Role": { + "Fn::GetAtt": [ + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A", + "Arn" + ] + }, + "Runtime": "nodejs14.x" + }, + "DependsOn": [ + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A" + ] + } + }, + "Parameters": { + "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3Bucket8B4BAF9C": { + "Type": "String", + "Description": "S3 bucket for asset \"9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ff\"" + }, + "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3VersionKey2B3BD417": { + "Type": "String", + "Description": "S3 key for asset version \"9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ff\"" + }, + "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffArtifactHash4518D68D": { + "Type": "String", + "Description": "Artifact hash for asset \"9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ff\"" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/triggers/test/integ.triggers.ts b/packages/@aws-cdk/triggers/test/integ.triggers.ts new file mode 100644 index 0000000000000..ff7dc5edabe92 --- /dev/null +++ b/packages/@aws-cdk/triggers/test/integ.triggers.ts @@ -0,0 +1,16 @@ +import * as lambda from '@aws-cdk/aws-lambda'; +import { App, Stack } from '@aws-cdk/core'; +import { Trigger } from '../lib'; + +const app = new App(); +const stack = new Stack(app, 'MyStack'); + +const handler = new lambda.Function(stack, 'MyFunction', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: lambda.Code.fromInline('exports.handler = function() { console.log("hi"); };'), +}); + +new Trigger(stack, 'MyTrigger', { handler }); + +app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/triggers/test/triggers.test.ts b/packages/@aws-cdk/triggers/test/triggers.test.ts new file mode 100644 index 0000000000000..289556b4cf7ae --- /dev/null +++ b/packages/@aws-cdk/triggers/test/triggers.test.ts @@ -0,0 +1,38 @@ +import { Match, Template } from '@aws-cdk/assertions'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as sns from '@aws-cdk/aws-sns'; +import { Stack } from '@aws-cdk/core'; +import * as triggers from '../lib'; + +test('minimal usage', () => { + // GIVEN + const stack = new Stack(); + const triggeringResource = new sns.Topic(stack, 'MyTopic'); + const trigger = new lambda.Function(stack, 'MyTriggerHandler', { + runtime: lambda.Runtime.NODEJS_12_X, + code: lambda.Code.fromInline('zoo'), + handler: 'index.handler', + }); + + // WHEN + new triggers.Trigger(stack, 'MyTrigger', { + handler: trigger, + dependencies: [triggeringResource], + }); + + // THEN + const template = Template.fromStack(stack); + expect(template).toMatchSnapshot(); + + expect(template.hasResourceProperties('Custom::Trigger', + Match.objectLike({ + HandlerArn: { + Ref: 'MyTriggerHandlerCurrentVersionC0B6BBD40f3abd954eb77fda7e548d681c7fa667', + }, + }))); + + expect(template.hasResource('Custom::Trigger', + Match.objectLike({ + DependsOn: ['MyTopic86869434'], + }))); +}); From 05828045b734d050a1e9e640a5ffe7c6737f7208 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Sun, 20 Feb 2022 17:40:28 +0200 Subject: [PATCH 2/8] clean up API & update README and add tests --- packages/@aws-cdk/triggers/README.md | 122 +++++++++--------- packages/@aws-cdk/triggers/lib/index.ts | 3 +- .../@aws-cdk/triggers/lib/lambda/index.ts | 22 +++- .../@aws-cdk/triggers/lib/trigger-function.ts | 38 ++++++ packages/@aws-cdk/triggers/lib/trigger.ts | 116 +++++++++++++++++ packages/@aws-cdk/triggers/lib/triggers.ts | 65 ---------- ...ger.test.js.snap => triggers.test.js.snap} | 60 ++++----- .../test/integ.triggers.expected.json | 22 +++- .../@aws-cdk/triggers/test/integ.triggers.ts | 11 +- .../triggers/test/trigger-handler.test.ts | 86 ++++++++++++ .../@aws-cdk/triggers/test/triggers.test.ts | 69 ++++++---- 11 files changed, 417 insertions(+), 197 deletions(-) create mode 100644 packages/@aws-cdk/triggers/lib/trigger-function.ts create mode 100644 packages/@aws-cdk/triggers/lib/trigger.ts delete mode 100644 packages/@aws-cdk/triggers/lib/triggers.ts rename packages/@aws-cdk/triggers/test/__snapshots__/{trigger.test.js.snap => triggers.test.js.snap} (72%) create mode 100644 packages/@aws-cdk/triggers/test/trigger-handler.test.ts diff --git a/packages/@aws-cdk/triggers/README.md b/packages/@aws-cdk/triggers/README.md index 190d68a385030..54a99a51ab8df 100644 --- a/packages/@aws-cdk/triggers/README.md +++ b/packages/@aws-cdk/triggers/README.md @@ -9,89 +9,87 @@ +Triggers allows you to execute code during deployments. This can be used for a +variety of use cases such as: -Execute code as part of a CDK app deployment. +* Self tests: validate something after a resource/construct been provisioned +* Data priming: add initial data to resources after they are created +* Preconditions: check things such as account limits or external dependencies + before deployment. ## Usage -You can trigger the execution of an AWS Lambda function during deployment after -a resource or groups of resources are provisioned. - -The library includes constructs that represent different triggers. The `BeforeCreate` and `AfterCreate` constructs can be used to trigger a handler before/after a set of resources have been created. +The `Trigger` construct will trigger the execution of an AWS Lambda function +*during* deployment. ```ts -import { Trigger } from '@aws-cdk/triggers'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as triggers from '@aws-cdk/triggers'; -new Trigger(this, 'MyTrigger', { - dependencies: [resource1, resource2, stack, ...], - handler: myLambdaFunction, +new triggers.TriggerFunction(this, 'MyTrigger', { + runtime: lambda.Runtime.NODEJS_12_X, + handler: 'index.handler', + code: lambda.Code.fromAsset(__dirname + '/my-trigger'), }); ``` -Where `dependencies` is a list of __construct scopes__ which determine when -`handler` is invoked. Scopes can be either specific resources or composite -constructs (in which case all the resources in the construct will be used as a -group). The scope can also be a `Stack`, in which case the trigger will apply to -all the resources within the stack (same as any composite construct). All scopes -must roll up to the same stack. +In the above example, the AWS Lambda function defined in `myLambdaFunction` will +be invoked when the stack is deployed. -Let's look at an example. Say we want to publish a notification to an SNS topic -that says "hello, topic!" after the topic is created. +> `TriggerFunction` uses `Trigger` under the hood. The above example is +> equivalent to: +> +> ```ts +> new trigger.Trigger({ +> handlerVersion: lambdaFunction.currentVersion +> }); +> ``` -```ts -// define a topic -const topic = new sns.Topic(this, 'MyTopic'); - -// define a lambda function which publishes a message to the topic -const publisher = new NodeJsFunction(this, 'PublishToTopic'); -publisher.addEnvironment('TOPIC_ARN', topic.topicArn); -publisher.addEnvironment('MESSAGE', 'Hello, topic!'); -topic.grantPublish(publisher); - -// trigger the lambda function after the topic is created -new triggers.Trigger(this, 'SayHello', { - handler: publisher -}); -``` +## Trigger Failures -NOTE: since `publisher` already takes an implicit dependency on `topic.topicArn` -(through its environment), we don't have to explicitly specify `dependencies`. +If the trigger handler fails (e.g. an exception is raised), the CloudFormation +deployment will fail, as if a resource failed to provision. This makes it easy +to implement "self tests" via triggers by simply making a set of assertions on +some provisioned infrastructure. -## Additional Notes +## Order of Execution -* If the trigger fails, deployment fails. This is a useful property that can be - leveraged to create triggers that "self-test" a stack. -* If the handler changes (configuration or code), the trigger gets re-executed - (trigger is bound to `lambda.currentVersion` which gets recreated when the - function changes). +By default, a trigger will be executed by CloudFormation after the associated +handler is provisioned. This means that if the handler takes an implicit +dependency on other resources (e.g. via environment variables), those resources +will be provisioned *before* the trigger is executed. -## Roadmap +In most cases, implicit ordering should be sufficient, but you can also use +`executeAfter` and `executeBefore` to control the order of execution. -* Additional periodic execution after deployment (`repeatOnSchedule`). -* Async checks (`retryWithTimeout`) -* Execute shell command inside a Docker image +The following example defines the following order: `(hello, world) => myTrigger => goodbye`. +The resources under `hello` and `world` will be provisioned in +parallel, and then the trigger `myTrigger` will be executed. Only then the +resources under `goodbye` will be provisioned: -## Use Cases +```ts +import { Construct, Node } from 'constructs'; +import * as triggers from '@aws-cdk/triggers'; -Here are some examples of use cases for triggers: +declare const myTrigger: triggers.Trigger; +declare const hello: Construct; +declare const world: Construct; +declare const goodbye: Construct; -* __Intrinsic validations__: execute a check to verify that a resource or set of resources have been deployed correctly - * Test connections to external systems (e.g. security tokens are valid) - * Verify integration between resources is working as expected - * Execute as one-off and also periodically after deployment - * Wait for data to start flowing (e.g. wait for a metric) before deployment is successful -* __Data priming__: add data to resources after they are created - * CodeCommit repo + initial commit - * Database + test data for development -* Check prerequisites before deployment - * Account limits - * Availability of external services -* Connect to other accounts +myTrigger.executeAfter(hello, world); +myTrigger.executeBefore(goodbye); +``` -## Security +Note that `hello` and `world` are construct *scopes*. This means that they can +be specific resources (such as an `s3.Bucket` object) or groups of resources +composed together into constructs. -See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. +## Re-execution of Triggers -## License +The trigger handler gets executed only once upon first deployment. Subsequent +deployments ***will not*** execute the trigger as long as the handler did not +change. The trigger ***will*** get re-executed if the code of the AWS Lambda +function, environment variables or other configuration changes. -This project is licensed under the Apache-2.0 License. +> Under the hood, the trigger resource is bound to the `lambda.currentVersion` + resource which is recreated automatically when the function changes. diff --git a/packages/@aws-cdk/triggers/lib/index.ts b/packages/@aws-cdk/triggers/lib/index.ts index 77532c116519d..1e2bf5e2dab37 100644 --- a/packages/@aws-cdk/triggers/lib/index.ts +++ b/packages/@aws-cdk/triggers/lib/index.ts @@ -1 +1,2 @@ -export * from './triggers'; +export * from './trigger'; +export * from './trigger-function'; \ No newline at end of file diff --git a/packages/@aws-cdk/triggers/lib/lambda/index.ts b/packages/@aws-cdk/triggers/lib/lambda/index.ts index 3e9eac66d1f6f..7975ac5b56bc0 100644 --- a/packages/@aws-cdk/triggers/lib/lambda/index.ts +++ b/packages/@aws-cdk/triggers/lib/lambda/index.ts @@ -3,7 +3,18 @@ // eslint-disable-next-line import/no-extraneous-dependencies import * as AWS from 'aws-sdk'; -exports.handler = async function(event: AWSLambda.CloudFormationCustomResourceEvent) { +export type InvokeFunction = (functionName: string) => Promise; + +export const invoke: InvokeFunction = async functionName => { + const lambda = new AWS.Lambda(); + const invokeRequest = { FunctionName: functionName }; + console.log({ invokeRequest }); + const invokeResponse = await lambda.invoke(invokeRequest).promise(); + console.log({ invokeResponse }); + return invokeResponse; +}; + +export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent) { console.log({ event }); if (event.RequestType === 'Delete') { @@ -16,14 +27,10 @@ exports.handler = async function(event: AWSLambda.CloudFormationCustomResourceEv throw new Error('The "HandlerArn" property is required'); } - const lambda = new AWS.Lambda(); - const invokeRequest = { FunctionName: handlerArn }; - console.log({ invokeRequest }); - const invokeResponse = await lambda.invoke(invokeRequest).promise(); - console.log({ invokeResponse }); + const invokeResponse = await invoke(handlerArn); if (invokeResponse.StatusCode !== 200) { - throw new Error(`Invoke failed with status code ${invokeResponse.StatusCode}`); + throw new Error(`Trigger handler failed with status code ${invokeResponse.StatusCode}`); } // if the lambda function throws an error, parse the error message and fail @@ -36,6 +43,7 @@ exports.handler = async function(event: AWSLambda.CloudFormationCustomResourceEv * Parse the error message from the lambda function. */ function parseError(payload: string | undefined): string { + console.log(`Error payload: ${payload}`); if (!payload) { return 'unknown handler error'; } try { const error = JSON.parse(payload); diff --git a/packages/@aws-cdk/triggers/lib/trigger-function.ts b/packages/@aws-cdk/triggers/lib/trigger-function.ts new file mode 100644 index 0000000000000..e9d5130fd1962 --- /dev/null +++ b/packages/@aws-cdk/triggers/lib/trigger-function.ts @@ -0,0 +1,38 @@ +import * as lambda from '@aws-cdk/aws-lambda'; +import { Construct } from 'constructs'; +import { ITrigger, Trigger, TriggerOptions } from '.'; + +/** + * Props for `InvokeFunction`. + */ +export interface TriggerFunctionProps extends lambda.FunctionProps, TriggerOptions { +} + +/** + * Invokes an AWS Lambda function during deployment. + */ +export class TriggerFunction extends lambda.Function implements ITrigger { + + /** + * The underlying trigger resource. + */ + public readonly trigger: Trigger; + + constructor(scope: Construct, id: string, props: TriggerFunctionProps) { + super(scope, id, props); + + this.trigger = new Trigger(this, 'Trigger', { + handlerVersion: this.currentVersion, + executeAfter: props.executeAfter, + executeBefore: props.executeBefore, + }); + } + + public executeAfter(...scopes: Construct[]): void { + this.trigger.executeAfter(...scopes); + } + + public executeBefore(...scopes: Construct[]): void { + this.trigger.executeBefore(...scopes); + } +} diff --git a/packages/@aws-cdk/triggers/lib/trigger.ts b/packages/@aws-cdk/triggers/lib/trigger.ts new file mode 100644 index 0000000000000..ccf07e4c719fb --- /dev/null +++ b/packages/@aws-cdk/triggers/lib/trigger.ts @@ -0,0 +1,116 @@ +import { join } from 'path'; +import * as lambda from '@aws-cdk/aws-lambda'; +import { CustomResource, CustomResourceProvider, CustomResourceProviderRuntime, IConstruct } from '@aws-cdk/core'; + +import { Construct, Node } from 'constructs'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct as CoreConstruct } from '@aws-cdk/core'; + +/** + * Interface for triggers. + */ +export interface ITrigger extends IConstruct { + /** + * Adds trigger dependencies. Execute this trigger only after these construct + * scopes have been provisioned. + * + * @param scopes A list of construct scopes which this trigger will depend on. + */ + executeAfter(...scopes: Construct[]): void; + + /** + * Adds this trigger as a dependency on other constructs. This means that this + * trigger will get executed *before* the given construct(s). + * + * @param scopes A list of construct scopes which will take a dependency on + * this trigger. + */ + executeBefore(...scopes: Construct[]): void; +} + +/** + * Options for `Trigger`. + */ +export interface TriggerOptions { + /** + * Adds trigger dependencies. Execute this trigger only after these construct + * scopes have been provisioned. + * + * You can also use `trigger.executeAfter()` to add additional dependencies. + * + * @default [] + */ + readonly executeAfter?: Construct[]; + + /** + * Adds this trigger as a dependency on other constructs. This means that this + * trigger will get executed *before* the given construct(s). + * + * You can also use `trigger.executeBefore()` to add additional dependencies. + * + * @default [] + */ + readonly executeBefore?: Construct[]; +} + +/** + * Props for `Trigger`. + */ +export interface TriggerProps extends TriggerOptions { + /** + * The AWS Lambda version of the handler to execute. + * + * The trigger will be executed every time the version changes (code or + * configuration). + */ + readonly handlerVersion: lambda.IVersion; +} + +/** + * Triggers an AWS Lambda function during deployment. + */ +export class Trigger extends CoreConstruct implements ITrigger { + constructor(scope: Construct, id: string, props: TriggerProps) { + super(scope, id); + + const provider = CustomResourceProvider.getOrCreateProvider(this, 'AWSCDK.TriggerCustomResourceProvider', { + runtime: CustomResourceProviderRuntime.NODEJS_14_X, + codeDirectory: join(__dirname, 'lambda'), + policyStatements: [ + { + Effect: 'Allow', + Action: ['lambda:InvokeFunction'], + Resource: [props.handlerVersion.functionArn], + }, + ], + }); + + // we use 'currentVersion' because a new version is created every time the + // handler changes (either code or configuration). this will have the effect + // that the trigger will be executed every time the handler is changed. + const handlerArn = props.handlerVersion.functionArn; + + new CustomResource(this, 'Default', { + resourceType: 'Custom::Trigger', + serviceToken: provider.serviceToken, + properties: { + HandlerArn: handlerArn, + }, + }); + + this.executeAfter(...props.executeAfter ?? []); + this.executeBefore(...props.executeBefore ?? []); + } + + public executeAfter(...scopes: Construct[]): void { + Node.of(this).addDependency(...scopes); + } + + public executeBefore(...scopes: Construct[]): void { + for (const s of scopes) { + Node.of(s).addDependency(this); + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/triggers/lib/triggers.ts b/packages/@aws-cdk/triggers/lib/triggers.ts deleted file mode 100644 index f690f4b2941a4..0000000000000 --- a/packages/@aws-cdk/triggers/lib/triggers.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { join } from 'path'; -import * as lambda from '@aws-cdk/aws-lambda'; -import { CustomResource, CustomResourceProvider, CustomResourceProviderRuntime } from '@aws-cdk/core'; - -import { Construct } from 'constructs'; - -// keep this import separate from other imports to reduce chance for merge conflicts with v2-main -// eslint-disable-next-line no-duplicate-imports, import/order -import { Construct as CoreConstruct } from '@aws-cdk/core'; - -/** - * Props for `Trigger`. - */ -export interface TriggerProps { - /** - * Resources to trigger on. Resources can come from any stack in the app. - * - * @default [] Run the trigger at any time during stack deployment. - */ - readonly dependencies?: Construct[]; - - /** - * The handler to execute once after all the resources are created. - * - * The trigger will be executed every time the handler changes (code or - * configuration). - */ - readonly handler: lambda.Function; -} - -/** - * Triggers an AWS Lambda function during deployment. - */ -export class Trigger extends CoreConstruct { - constructor(scope: Construct, id: string, props: TriggerProps) { - super(scope, id); - - const provider = CustomResourceProvider.getOrCreateProvider(this, 'AWSCDK.TriggerCustomResourceProvider', { - runtime: CustomResourceProviderRuntime.NODEJS_14_X, - codeDirectory: join(__dirname, 'lambda'), - policyStatements: [ - { - Effect: 'Allow', - Action: ['lambda:InvokeFunction'], - Resource: [props.handler.currentVersion.functionArn], - }, - ], - }); - - const resource = new CustomResource(this, 'Resource', { - resourceType: 'Custom::Trigger', - serviceToken: provider.serviceToken, - properties: { - // we use 'currentVersion' because a new version is created every time the - // handler changes (either code or configuration). this will have the effect - // that the trigger will be executed every time the handler is changed. - HandlerArn: props.handler.currentVersion.functionArn, - }, - }); - - // add a dependency between our resource and the resources we want to invoke - // after. - resource.node.addDependency(...props.dependencies ?? []); - } -} \ No newline at end of file diff --git a/packages/@aws-cdk/triggers/test/__snapshots__/trigger.test.js.snap b/packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap similarity index 72% rename from packages/@aws-cdk/triggers/test/__snapshots__/trigger.test.js.snap rename to packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap index bf1cdc566cb84..10e02431c42b8 100644 --- a/packages/@aws-cdk/triggers/test/__snapshots__/trigger.test.js.snap +++ b/packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap @@ -1,18 +1,18 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`minimal usage 1`] = ` +exports[`minimal 1`] = ` Object { "Parameters": Object { - "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffArtifactHash4518D68D": Object { - "Description": "Artifact hash for asset \\"9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ff\\"", + "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bArtifactHashCB43419D": Object { + "Description": "Artifact hash for asset \\"dfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114b\\"", "Type": "String", }, - "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3Bucket8B4BAF9C": Object { - "Description": "S3 bucket for asset \\"9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ff\\"", + "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bS3Bucket56092956": Object { + "Description": "S3 bucket for asset \\"dfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114b\\"", "Type": "String", }, - "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3VersionKey2B3BD417": Object { - "Description": "S3 key for asset version \\"9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ff\\"", + "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bS3VersionKeyC2A8D98C": Object { + "Description": "S3 key for asset version \\"dfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114b\\"", "Type": "String", }, }, @@ -24,7 +24,7 @@ Object { "Properties": Object { "Code": Object { "S3Bucket": Object { - "Ref": "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3Bucket8B4BAF9C", + "Ref": "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bS3Bucket56092956", }, "S3Key": Object { "Fn::Join": Array [ @@ -37,7 +37,7 @@ Object { "Fn::Split": Array [ "||", Object { - "Ref": "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3VersionKey2B3BD417", + "Ref": "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bS3VersionKeyC2A8D98C", }, ], }, @@ -50,7 +50,7 @@ Object { "Fn::Split": Array [ "||", Object { - "Ref": "AssetParameters9a94767d68ec7d462ebafb65903f259f527cae0775d02a4eb2db7ac720bc61ffS3VersionKey2B3BD417", + "Ref": "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bS3VersionKeyC2A8D98C", }, ], }, @@ -102,7 +102,9 @@ Object { ], "Effect": "Allow", "Resource": Array [ - "*", + Object { + "Ref": "MyTriggerCurrentVersion8802742B707afb4f5c680fa04113c095ec4e8b5d", + }, ], }, ], @@ -114,17 +116,11 @@ Object { }, "Type": "AWS::IAM::Role", }, - "MyTopic86869434": Object { - "Type": "AWS::SNS::Topic", - }, - "MyTriggerB6CCCACE": Object { + "MyTrigger5A0C728D": Object { "DeletionPolicy": "Delete", - "DependsOn": Array [ - "MyTopic86869434", - ], "Properties": Object { "HandlerArn": Object { - "Ref": "MyTriggerHandlerCurrentVersionC0B6BBD40f3abd954eb77fda7e548d681c7fa667", + "Ref": "MyTriggerCurrentVersion8802742B707afb4f5c680fa04113c095ec4e8b5d", }, "ServiceToken": Object { "Fn::GetAtt": Array [ @@ -136,26 +132,18 @@ Object { "Type": "Custom::Trigger", "UpdateReplacePolicy": "Delete", }, - "MyTriggerHandlerCurrentVersionC0B6BBD40f3abd954eb77fda7e548d681c7fa667": Object { - "Properties": Object { - "FunctionName": Object { - "Ref": "MyTriggerHandlerD6B1FF23", - }, - }, - "Type": "AWS::Lambda::Version", - }, - "MyTriggerHandlerD6B1FF23": Object { + "MyTriggerB6CCCACE": Object { "DependsOn": Array [ - "MyTriggerHandlerServiceRoleFC0CFFAB", + "MyTriggerServiceRole1F3F0AED", ], "Properties": Object { "Code": Object { - "ZipFile": "zoo", + "ZipFile": "foo", }, "Handler": "index.handler", "Role": Object { "Fn::GetAtt": Array [ - "MyTriggerHandlerServiceRoleFC0CFFAB", + "MyTriggerServiceRole1F3F0AED", "Arn", ], }, @@ -163,7 +151,15 @@ Object { }, "Type": "AWS::Lambda::Function", }, - "MyTriggerHandlerServiceRoleFC0CFFAB": Object { + "MyTriggerCurrentVersion8802742B707afb4f5c680fa04113c095ec4e8b5d": Object { + "Properties": Object { + "FunctionName": Object { + "Ref": "MyTriggerB6CCCACE", + }, + }, + "Type": "AWS::Lambda::Version", + }, + "MyTriggerServiceRole1F3F0AED": Object { "Properties": Object { "AssumeRolePolicyDocument": Object { "Statement": Array [ diff --git a/packages/@aws-cdk/triggers/test/integ.triggers.expected.json b/packages/@aws-cdk/triggers/test/integ.triggers.expected.json index 5fd7b71d39385..94a0cf390bc50 100644 --- a/packages/@aws-cdk/triggers/test/integ.triggers.expected.json +++ b/packages/@aws-cdk/triggers/test/integ.triggers.expected.json @@ -1,5 +1,14 @@ { "Resources": { + "Topic198E71B3E": { + "Type": "AWS::SNS::Topic", + "DependsOn": [ + "MyFunctionTriggerDB129D7B" + ] + }, + "Topic269377B75": { + "Type": "AWS::SNS::Topic" + }, "MyFunctionServiceRole3C357FF2": { "Type": "AWS::IAM::Role", "Properties": { @@ -35,7 +44,7 @@ "Type": "AWS::Lambda::Function", "Properties": { "Code": { - "ZipFile": "exports.handler = async function() { console.log(\"hi\"); };" + "ZipFile": "exports.handler = function() { console.log(\"hi\"); };" }, "Role": { "Fn::GetAtt": [ @@ -50,7 +59,7 @@ "MyFunctionServiceRole3C357FF2" ] }, - "MyFunctionCurrentVersion197490AF463fe24fa0d2cb07a7b26515dfecc497": { + "MyFunctionCurrentVersion197490AF776ea8de2edf446759649703b18110a4": { "Type": "AWS::Lambda::Version", "Properties": { "FunctionName": { @@ -58,7 +67,7 @@ } } }, - "MyTriggerB6CCCACE": { + "MyFunctionTriggerDB129D7B": { "Type": "Custom::Trigger", "Properties": { "ServiceToken": { @@ -68,9 +77,12 @@ ] }, "HandlerArn": { - "Ref": "MyFunctionCurrentVersion197490AF463fe24fa0d2cb07a7b26515dfecc497" + "Ref": "MyFunctionCurrentVersion197490AF776ea8de2edf446759649703b18110a4" } }, + "DependsOn": [ + "Topic269377B75" + ], "UpdateReplacePolicy": "Delete", "DeletionPolicy": "Delete" }, @@ -107,7 +119,7 @@ ], "Resource": [ { - "Ref": "MyFunctionCurrentVersion197490AF463fe24fa0d2cb07a7b26515dfecc497" + "Ref": "MyFunctionCurrentVersion197490AF776ea8de2edf446759649703b18110a4" } ] } diff --git a/packages/@aws-cdk/triggers/test/integ.triggers.ts b/packages/@aws-cdk/triggers/test/integ.triggers.ts index ff7dc5edabe92..ad9a7e104438b 100644 --- a/packages/@aws-cdk/triggers/test/integ.triggers.ts +++ b/packages/@aws-cdk/triggers/test/integ.triggers.ts @@ -1,16 +1,21 @@ import * as lambda from '@aws-cdk/aws-lambda'; +import * as sns from '@aws-cdk/aws-sns'; import { App, Stack } from '@aws-cdk/core'; -import { Trigger } from '../lib'; +import * as triggers from '../lib'; const app = new App(); const stack = new Stack(app, 'MyStack'); -const handler = new lambda.Function(stack, 'MyFunction', { +const topic1 = new sns.Topic(stack, 'Topic1'); +const topic2 = new sns.Topic(stack, 'Topic2'); + +const trigger = new triggers.TriggerFunction(stack, 'MyFunction', { runtime: lambda.Runtime.NODEJS_12_X, handler: 'index.handler', code: lambda.Code.fromInline('exports.handler = function() { console.log("hi"); };'), + executeBefore: [topic1], }); -new Trigger(stack, 'MyTrigger', { handler }); +trigger.executeAfter(topic2); app.synth(); \ No newline at end of file diff --git a/packages/@aws-cdk/triggers/test/trigger-handler.test.ts b/packages/@aws-cdk/triggers/test/trigger-handler.test.ts new file mode 100644 index 0000000000000..ab2081c86c572 --- /dev/null +++ b/packages/@aws-cdk/triggers/test/trigger-handler.test.ts @@ -0,0 +1,86 @@ +import * as lambda from '../lib/lambda'; + +afterEach(() => { + jest.resetAllMocks(); +}); + +const handlerArn = 'arn:aws:lambda:us-east-1:123456789012:function:MyTrigger'; +const mockRequest = { + LogicalResourceId: 'MyTrigger', + StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/MyStack/12345678-1234-1234-1234-123456789012', + ResponseURL: 'https://cloudformation-custom-resource-response-MyTrigger/', + ResourceProperties: { + ServiceToken: 'arn:aws:lambda:us-east-1:123456789012:function:MyFunction', + HandlerArn: handlerArn, + }, + RequestId: 'MyRequestId', + ResourceType: 'Custom::Trigger', + ServiceToken: 'arn:aws:lambda:us-east-1:123456789012:function:MyFunction', +}; + +test('Create', async () => { + const invokeMock = jest.spyOn(lambda, 'invoke').mockResolvedValue({ + StatusCode: 200, + }); + + await lambda.handler({ RequestType: 'Create', ...mockRequest }); + + expect(invokeMock).toBeCalledTimes(1); + expect(invokeMock).toBeCalledWith(handlerArn); +}); + +test('Update', async () => { + const invokeMock = jest.spyOn(lambda, 'invoke').mockResolvedValue({ + StatusCode: 200, + }); + + await lambda.handler({ RequestType: 'Update', PhysicalResourceId: 'PRID', OldResourceProperties: {}, ...mockRequest }); + + expect(invokeMock).toBeCalledTimes(1); + expect(invokeMock).toBeCalledWith(handlerArn); +}); + +test('Delete - handler not called', async () => { + const invokeMock = jest.spyOn(lambda, 'invoke'); + await lambda.handler({ RequestType: 'Delete', PhysicalResourceId: 'PRID', ...mockRequest }); + expect(invokeMock).not.toBeCalled(); +}); + +test('non-200 status code throws an error', async () => { + const invokeMock = jest.spyOn(lambda, 'invoke').mockResolvedValue({ + StatusCode: 500, + }); + + await expect(lambda.handler({ RequestType: 'Create', ...mockRequest })) + .rejects + .toMatchObject({ message: 'Trigger handler failed with status code 500' }); + + expect(invokeMock).toBeCalledTimes(1); + expect(invokeMock).toBeCalledWith(handlerArn); +}); + +describe('function error', () => { + + const makeTest = (payload: string | undefined, expectedError: string) => { + return async () => { + const invokeMock = jest.spyOn(lambda, 'invoke').mockResolvedValue({ + StatusCode: 200, + FunctionError: 'Unhandled', + Payload: payload, + }); + + await expect(lambda.handler({ RequestType: 'Create', ...mockRequest })) + .rejects + .toMatchObject({ message: expectedError }); + + expect(invokeMock).toBeCalledTimes(1); + expect(invokeMock).toBeCalledWith(handlerArn); + }; + }; + + test('undefined payload', makeTest(undefined, 'unknown handler error')); + test('empty payload', makeTest('', 'unknown handler error')); + test('invalid JSON payload', makeTest('{', '{')); + test('valid JSON payload', makeTest('{"errorMessage": "my error"}', 'my error')); + test('with stack trace', makeTest('{"errorMessage": "my error", "trace": "my stack trace"}', 'my error\nmy stack trace')); +}); diff --git a/packages/@aws-cdk/triggers/test/triggers.test.ts b/packages/@aws-cdk/triggers/test/triggers.test.ts index 289556b4cf7ae..569af77c82380 100644 --- a/packages/@aws-cdk/triggers/test/triggers.test.ts +++ b/packages/@aws-cdk/triggers/test/triggers.test.ts @@ -1,38 +1,63 @@ -import { Match, Template } from '@aws-cdk/assertions'; +import { Template } from '@aws-cdk/assertions'; import * as lambda from '@aws-cdk/aws-lambda'; import * as sns from '@aws-cdk/aws-sns'; import { Stack } from '@aws-cdk/core'; import * as triggers from '../lib'; -test('minimal usage', () => { +test('minimal', () => { // GIVEN const stack = new Stack(); - const triggeringResource = new sns.Topic(stack, 'MyTopic'); - const trigger = new lambda.Function(stack, 'MyTriggerHandler', { - runtime: lambda.Runtime.NODEJS_12_X, - code: lambda.Code.fromInline('zoo'), + + // WHEN + new triggers.TriggerFunction(stack, 'MyTrigger', { handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_12_X, + code: lambda.Code.fromInline('foo'), }); + // THEN + expect(Template.fromStack(stack)).toMatchSnapshot(); +}); + +test('before/after', () => { + // GIVEN + const stack = new Stack(); + const topic1 = new sns.Topic(stack, 'Topic1'); + const topic2 = new sns.Topic(stack, 'Topic2'); + const topic3 = new sns.Topic(stack, 'Topic3'); + const topic4 = new sns.Topic(stack, 'Topic4'); + // WHEN - new triggers.Trigger(stack, 'MyTrigger', { - handler: trigger, - dependencies: [triggeringResource], + const myTrigger = new triggers.TriggerFunction(stack, 'MyTrigger', { + runtime: lambda.Runtime.NODEJS_12_X, + code: lambda.Code.fromInline('zoo'), + handler: 'index.handler', + + // through props + executeAfter: [topic1], + executeBefore: [topic3], }); + // through methods + myTrigger.executeBefore(topic4); + myTrigger.executeAfter(topic2); + // THEN + const triggerId = 'MyTrigger5A0C728D'; + const topic1Id = 'Topic198E71B3E'; + const topic2Id = 'Topic269377B75'; + const topic3Id = 'Topic3DEAE47A7'; + const topic4Id = 'Topic4F5C0CEE2'; + const template = Template.fromStack(stack); - expect(template).toMatchSnapshot(); - - expect(template.hasResourceProperties('Custom::Trigger', - Match.objectLike({ - HandlerArn: { - Ref: 'MyTriggerHandlerCurrentVersionC0B6BBD40f3abd954eb77fda7e548d681c7fa667', - }, - }))); - - expect(template.hasResource('Custom::Trigger', - Match.objectLike({ - DependsOn: ['MyTopic86869434'], - }))); + const resources = template.toJSON().Resources; + + const dependsOn = (sourceId: string, targetId: string) => { + expect(resources[sourceId].DependsOn).toContain(targetId); + }; + + dependsOn(triggerId, topic1Id); + dependsOn(triggerId, topic2Id); + dependsOn(topic3Id, triggerId); + dependsOn(topic4Id, triggerId); }); From de4a8a996db003f4082ff9d737566424798a6c2b Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Sun, 20 Feb 2022 17:42:05 +0200 Subject: [PATCH 3/8] update snapshots --- .../test/__snapshots__/triggers.test.js.snap | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap b/packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap index 10e02431c42b8..d5cacf405d693 100644 --- a/packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap +++ b/packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap @@ -3,16 +3,16 @@ exports[`minimal 1`] = ` Object { "Parameters": Object { - "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bArtifactHashCB43419D": Object { - "Description": "Artifact hash for asset \\"dfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114b\\"", + "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aArtifactHash5F581B6B": Object { + "Description": "Artifact hash for asset \\"2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021a\\"", "Type": "String", }, - "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bS3Bucket56092956": Object { - "Description": "S3 bucket for asset \\"dfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114b\\"", + "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aS3BucketD06FCCA6": Object { + "Description": "S3 bucket for asset \\"2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021a\\"", "Type": "String", }, - "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bS3VersionKeyC2A8D98C": Object { - "Description": "S3 key for asset version \\"dfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114b\\"", + "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aS3VersionKey096A7311": Object { + "Description": "S3 key for asset version \\"2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021a\\"", "Type": "String", }, }, @@ -24,7 +24,7 @@ Object { "Properties": Object { "Code": Object { "S3Bucket": Object { - "Ref": "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bS3Bucket56092956", + "Ref": "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aS3BucketD06FCCA6", }, "S3Key": Object { "Fn::Join": Array [ @@ -37,7 +37,7 @@ Object { "Fn::Split": Array [ "||", Object { - "Ref": "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bS3VersionKeyC2A8D98C", + "Ref": "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aS3VersionKey096A7311", }, ], }, @@ -50,7 +50,7 @@ Object { "Fn::Split": Array [ "||", Object { - "Ref": "AssetParametersdfcfcce34a32aa185d5321dd6830ad781ba3b8fbe040a0b6343085f5d149114bS3VersionKeyC2A8D98C", + "Ref": "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aS3VersionKey096A7311", }, ], }, From a44e1d8b916efc7e8da60e8777f1e43a38ddbb35 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Sun, 20 Feb 2022 17:44:59 +0200 Subject: [PATCH 4/8] update readme --- packages/@aws-cdk/triggers/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@aws-cdk/triggers/README.md b/packages/@aws-cdk/triggers/README.md index 54a99a51ab8df..0255a62bb2b37 100644 --- a/packages/@aws-cdk/triggers/README.md +++ b/packages/@aws-cdk/triggers/README.md @@ -19,8 +19,8 @@ variety of use cases such as: ## Usage -The `Trigger` construct will trigger the execution of an AWS Lambda function -*during* deployment. +The `TriggerFunction` construct will define an AWS Lambda function which is +triggered *during* deployment: ```ts import * as lambda from '@aws-cdk/aws-lambda'; From 21cab97e5734271ee1f60632e96b10f0671f16cc Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Sun, 20 Feb 2022 18:02:49 +0200 Subject: [PATCH 5/8] remove fragile test --- packages/@aws-cdk/triggers/README.md | 16 +- .../test/__snapshots__/triggers.test.js.snap | 195 ------------------ .../@aws-cdk/triggers/test/triggers.test.ts | 6 +- 3 files changed, 18 insertions(+), 199 deletions(-) delete mode 100644 packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap diff --git a/packages/@aws-cdk/triggers/README.md b/packages/@aws-cdk/triggers/README.md index 0255a62bb2b37..6736165bbeb70 100644 --- a/packages/@aws-cdk/triggers/README.md +++ b/packages/@aws-cdk/triggers/README.md @@ -25,8 +25,11 @@ triggered *during* deployment: ```ts import * as lambda from '@aws-cdk/aws-lambda'; import * as triggers from '@aws-cdk/triggers'; +import { Stack } from '@aws-cdk/core'; -new triggers.TriggerFunction(this, 'MyTrigger', { +declare const stack: Stack; + +new triggers.TriggerFunction(stack, 'MyTrigger', { runtime: lambda.Runtime.NODEJS_12_X, handler: 'index.handler', code: lambda.Code.fromAsset(__dirname + '/my-trigger'), @@ -40,8 +43,15 @@ be invoked when the stack is deployed. > equivalent to: > > ```ts -> new trigger.Trigger({ -> handlerVersion: lambdaFunction.currentVersion +> import * as triggers from '@aws-cdk/triggers'; +> import * as lambda from '@aws-cdk/aws-lambda'; +> import { Stack } from '@aws-cdk/core'; +> +> declare const stack: Stack; +> declare const lambdaFunction: lambda.Function; +> +> new triggers.Trigger(stack, 'MyTrigger', { +> handlerVersion: lambdaFunction.currentVersion > }); > ``` diff --git a/packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap b/packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap deleted file mode 100644 index d5cacf405d693..0000000000000 --- a/packages/@aws-cdk/triggers/test/__snapshots__/triggers.test.js.snap +++ /dev/null @@ -1,195 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`minimal 1`] = ` -Object { - "Parameters": Object { - "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aArtifactHash5F581B6B": Object { - "Description": "Artifact hash for asset \\"2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021a\\"", - "Type": "String", - }, - "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aS3BucketD06FCCA6": Object { - "Description": "S3 bucket for asset \\"2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021a\\"", - "Type": "String", - }, - "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aS3VersionKey096A7311": Object { - "Description": "S3 key for asset version \\"2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021a\\"", - "Type": "String", - }, - }, - "Resources": Object { - "AWSCDKTriggerCustomResourceProviderCustomResourceProviderHandler97BECD91": Object { - "DependsOn": Array [ - "AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A", - ], - "Properties": Object { - "Code": Object { - "S3Bucket": Object { - "Ref": "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aS3BucketD06FCCA6", - }, - "S3Key": Object { - "Fn::Join": Array [ - "", - Array [ - Object { - "Fn::Select": Array [ - 0, - Object { - "Fn::Split": Array [ - "||", - Object { - "Ref": "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aS3VersionKey096A7311", - }, - ], - }, - ], - }, - Object { - "Fn::Select": Array [ - 1, - Object { - "Fn::Split": Array [ - "||", - Object { - "Ref": "AssetParameters2c42061ddceb234b56276636e22d41e1651d112e8086384492e236481b34021aS3VersionKey096A7311", - }, - ], - }, - ], - }, - ], - ], - }, - }, - "Handler": "__entrypoint__.handler", - "MemorySize": 128, - "Role": Object { - "Fn::GetAtt": Array [ - "AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A", - "Arn", - ], - }, - "Runtime": "nodejs14.x", - "Timeout": 900, - }, - "Type": "AWS::Lambda::Function", - }, - "AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A": Object { - "Properties": Object { - "AssumeRolePolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": Object { - "Service": "lambda.amazonaws.com", - }, - }, - ], - "Version": "2012-10-17", - }, - "ManagedPolicyArns": Array [ - Object { - "Fn::Sub": "arn:\${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", - }, - ], - "Policies": Array [ - Object { - "PolicyDocument": Object { - "Statement": Array [ - Object { - "Action": Array [ - "lambda:InvokeFunction", - ], - "Effect": "Allow", - "Resource": Array [ - Object { - "Ref": "MyTriggerCurrentVersion8802742B707afb4f5c680fa04113c095ec4e8b5d", - }, - ], - }, - ], - "Version": "2012-10-17", - }, - "PolicyName": "Inline", - }, - ], - }, - "Type": "AWS::IAM::Role", - }, - "MyTrigger5A0C728D": Object { - "DeletionPolicy": "Delete", - "Properties": Object { - "HandlerArn": Object { - "Ref": "MyTriggerCurrentVersion8802742B707afb4f5c680fa04113c095ec4e8b5d", - }, - "ServiceToken": Object { - "Fn::GetAtt": Array [ - "AWSCDKTriggerCustomResourceProviderCustomResourceProviderHandler97BECD91", - "Arn", - ], - }, - }, - "Type": "Custom::Trigger", - "UpdateReplacePolicy": "Delete", - }, - "MyTriggerB6CCCACE": Object { - "DependsOn": Array [ - "MyTriggerServiceRole1F3F0AED", - ], - "Properties": Object { - "Code": Object { - "ZipFile": "foo", - }, - "Handler": "index.handler", - "Role": Object { - "Fn::GetAtt": Array [ - "MyTriggerServiceRole1F3F0AED", - "Arn", - ], - }, - "Runtime": "nodejs12.x", - }, - "Type": "AWS::Lambda::Function", - }, - "MyTriggerCurrentVersion8802742B707afb4f5c680fa04113c095ec4e8b5d": Object { - "Properties": Object { - "FunctionName": Object { - "Ref": "MyTriggerB6CCCACE", - }, - }, - "Type": "AWS::Lambda::Version", - }, - "MyTriggerServiceRole1F3F0AED": Object { - "Properties": Object { - "AssumeRolePolicyDocument": Object { - "Statement": Array [ - Object { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": Object { - "Service": "lambda.amazonaws.com", - }, - }, - ], - "Version": "2012-10-17", - }, - "ManagedPolicyArns": Array [ - Object { - "Fn::Join": Array [ - "", - Array [ - "arn:", - Object { - "Ref": "AWS::Partition", - }, - ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole", - ], - ], - }, - ], - }, - "Type": "AWS::IAM::Role", - }, - }, -} -`; diff --git a/packages/@aws-cdk/triggers/test/triggers.test.ts b/packages/@aws-cdk/triggers/test/triggers.test.ts index 569af77c82380..dd89046c6475a 100644 --- a/packages/@aws-cdk/triggers/test/triggers.test.ts +++ b/packages/@aws-cdk/triggers/test/triggers.test.ts @@ -16,7 +16,11 @@ test('minimal', () => { }); // THEN - expect(Template.fromStack(stack)).toMatchSnapshot(); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', {}); + template.hasResourceProperties('Custom::Trigger', { + HandlerArn: { Ref: 'MyTriggerCurrentVersion8802742B707afb4f5c680fa04113c095ec4e8b5d' }, + }); }); test('before/after', () => { From acc2b123901dbf813b212372eb0aa9c7c1caa37b Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Mon, 21 Feb 2022 12:48:57 +0200 Subject: [PATCH 6/8] Add `executeOnHandlerChange` --- packages/@aws-cdk/triggers/README.md | 19 ++++--- .../@aws-cdk/triggers/lib/trigger-function.ts | 5 +- packages/@aws-cdk/triggers/lib/trigger.ts | 49 ++++++++++++++----- packages/@aws-cdk/triggers/package.json | 5 ++ 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/packages/@aws-cdk/triggers/README.md b/packages/@aws-cdk/triggers/README.md index 6736165bbeb70..5969a8cb58184 100644 --- a/packages/@aws-cdk/triggers/README.md +++ b/packages/@aws-cdk/triggers/README.md @@ -96,10 +96,15 @@ composed together into constructs. ## Re-execution of Triggers -The trigger handler gets executed only once upon first deployment. Subsequent -deployments ***will not*** execute the trigger as long as the handler did not -change. The trigger ***will*** get re-executed if the code of the AWS Lambda -function, environment variables or other configuration changes. - -> Under the hood, the trigger resource is bound to the `lambda.currentVersion` - resource which is recreated automatically when the function changes. +By default, `executeOnHandlerChange` is enabled. This implies that the trigger +is re-executed every time the handler function code or configuration changes. If +this option is disabled, the trigger will be executed only once upon first +deployment. + +In the future we will consider adding support for additional re-execution modes: + +* `executeOnEveryDeployment: boolean` - re-executes every time the stack is + deployed (add random "salt" during synthesis). +* `executeOnResourceChange: Construct[]` - re-executes when one of the resources + under the specified scopes has changed (add the hash the CloudFormation + resource specs). diff --git a/packages/@aws-cdk/triggers/lib/trigger-function.ts b/packages/@aws-cdk/triggers/lib/trigger-function.ts index e9d5130fd1962..6504c9f5d1334 100644 --- a/packages/@aws-cdk/triggers/lib/trigger-function.ts +++ b/packages/@aws-cdk/triggers/lib/trigger-function.ts @@ -22,9 +22,8 @@ export class TriggerFunction extends lambda.Function implements ITrigger { super(scope, id, props); this.trigger = new Trigger(this, 'Trigger', { - handlerVersion: this.currentVersion, - executeAfter: props.executeAfter, - executeBefore: props.executeBefore, + ...props, + handler: this, }); } diff --git a/packages/@aws-cdk/triggers/lib/trigger.ts b/packages/@aws-cdk/triggers/lib/trigger.ts index ccf07e4c719fb..71bff1ebd48c9 100644 --- a/packages/@aws-cdk/triggers/lib/trigger.ts +++ b/packages/@aws-cdk/triggers/lib/trigger.ts @@ -48,11 +48,22 @@ export interface TriggerOptions { * Adds this trigger as a dependency on other constructs. This means that this * trigger will get executed *before* the given construct(s). * - * You can also use `trigger.executeBefore()` to add additional dependencies. + * You can also use `trigger.executeBefore()` to add additional dependants. * * @default [] */ readonly executeBefore?: Construct[]; + + /** + * Re-executes the trigger every time the handler changes. + * + * This implies that the trigger is associated with the `currentVersion` of + * the handler, which gets recreated every time the handler or its + * configuration is updated. + * + * @default true + */ + readonly executeOnHandlerChange?: boolean; } /** @@ -60,12 +71,9 @@ export interface TriggerOptions { */ export interface TriggerProps extends TriggerOptions { /** - * The AWS Lambda version of the handler to execute. - * - * The trigger will be executed every time the version changes (code or - * configuration). + * The AWS Lambda function of the handler to execute. */ - readonly handlerVersion: lambda.IVersion; + readonly handler: lambda.Function; } /** @@ -82,15 +90,13 @@ export class Trigger extends CoreConstruct implements ITrigger { { Effect: 'Allow', Action: ['lambda:InvokeFunction'], - Resource: [props.handlerVersion.functionArn], + Resource: [props.handler.functionArn], }, ], }); - // we use 'currentVersion' because a new version is created every time the - // handler changes (either code or configuration). this will have the effect - // that the trigger will be executed every time the handler is changed. - const handlerArn = props.handlerVersion.functionArn; + + const handlerArn = this.determineHandlerArn(props); new CustomResource(this, 'Default', { resourceType: 'Custom::Trigger', @@ -113,4 +119,25 @@ export class Trigger extends CoreConstruct implements ITrigger { Node.of(s).addDependency(this); } } + + private determineHandlerArn(props: TriggerProps) { + const executeOnHandlerChange = props.executeOnHandlerChange ?? true; + if (executeOnHandlerChange) { + return props.handler.currentVersion.functionArn; + } + + return props.handler.functionArn; + } +} + +/** + * Determines + */ +export enum TriggerInvalidation { + /** + * The trigger will be executed every time the handler (or its configuration) + * changes. This is implemented by associated the trigger with the `currentVersion` + * of the AWS Lambda function, which gets recreated every time the handler changes. + */ + HANDLER_CHANGE = 'WHEN_FUNCTION_CHANGES', } \ No newline at end of file diff --git a/packages/@aws-cdk/triggers/package.json b/packages/@aws-cdk/triggers/package.json index 9fc70ef81d639..a062d87d93ad6 100644 --- a/packages/@aws-cdk/triggers/package.json +++ b/packages/@aws-cdk/triggers/package.json @@ -111,5 +111,10 @@ }, "publishConfig": { "tag": "latest" + }, + "awslint": { + "exclude": [ + "ref-via-interface:@aws-cdk/triggers.TriggerProps.handler" + ] } } From e7ae06ac4cb9cbcc8ae2e44fabe15ee4c2306ab7 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Mon, 21 Feb 2022 12:52:09 +0200 Subject: [PATCH 7/8] remove unneeded comment --- packages/@aws-cdk/triggers/README.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/@aws-cdk/triggers/README.md b/packages/@aws-cdk/triggers/README.md index 5969a8cb58184..4ed41ce05ca0a 100644 --- a/packages/@aws-cdk/triggers/README.md +++ b/packages/@aws-cdk/triggers/README.md @@ -39,22 +39,6 @@ new triggers.TriggerFunction(stack, 'MyTrigger', { In the above example, the AWS Lambda function defined in `myLambdaFunction` will be invoked when the stack is deployed. -> `TriggerFunction` uses `Trigger` under the hood. The above example is -> equivalent to: -> -> ```ts -> import * as triggers from '@aws-cdk/triggers'; -> import * as lambda from '@aws-cdk/aws-lambda'; -> import { Stack } from '@aws-cdk/core'; -> -> declare const stack: Stack; -> declare const lambdaFunction: lambda.Function; -> -> new triggers.Trigger(stack, 'MyTrigger', { -> handlerVersion: lambdaFunction.currentVersion -> }); -> ``` - ## Trigger Failures If the trigger handler fails (e.g. an exception is raised), the CloudFormation From 57d071c5aaaaf148929c51f91743f80b96135258 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Mon, 21 Feb 2022 13:45:53 +0200 Subject: [PATCH 8/8] use handlerArn in policy --- packages/@aws-cdk/triggers/lib/trigger.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/@aws-cdk/triggers/lib/trigger.ts b/packages/@aws-cdk/triggers/lib/trigger.ts index 71bff1ebd48c9..88299f2373294 100644 --- a/packages/@aws-cdk/triggers/lib/trigger.ts +++ b/packages/@aws-cdk/triggers/lib/trigger.ts @@ -83,6 +83,7 @@ export class Trigger extends CoreConstruct implements ITrigger { constructor(scope: Construct, id: string, props: TriggerProps) { super(scope, id); + const handlerArn = this.determineHandlerArn(props); const provider = CustomResourceProvider.getOrCreateProvider(this, 'AWSCDK.TriggerCustomResourceProvider', { runtime: CustomResourceProviderRuntime.NODEJS_14_X, codeDirectory: join(__dirname, 'lambda'), @@ -90,14 +91,11 @@ export class Trigger extends CoreConstruct implements ITrigger { { Effect: 'Allow', Action: ['lambda:InvokeFunction'], - Resource: [props.handler.functionArn], + Resource: [handlerArn], }, ], }); - - const handlerArn = this.determineHandlerArn(props); - new CustomResource(this, 'Default', { resourceType: 'Custom::Trigger', serviceToken: provider.serviceToken, @@ -121,12 +119,12 @@ export class Trigger extends CoreConstruct implements ITrigger { } private determineHandlerArn(props: TriggerProps) { - const executeOnHandlerChange = props.executeOnHandlerChange ?? true; - if (executeOnHandlerChange) { - return props.handler.currentVersion.functionArn; - } + return props.handler.currentVersion.functionArn; + // const executeOnHandlerChange = props.executeOnHandlerChange ?? true; + // if (executeOnHandlerChange) { + // } - return props.handler.functionArn; + // return props.handler.functionArn; } }