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..4ed41ce05ca0a --- /dev/null +++ b/packages/@aws-cdk/triggers/README.md @@ -0,0 +1,94 @@ +# Triggers + + +--- + +![cdk-constructs: Stable](https://img.shields.io/badge/cdk--constructs-stable-success.svg?style=for-the-badge) + +--- + + + +Triggers allows you to execute code during deployments. This can be used for a +variety of use cases such as: + +* 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 + +The `TriggerFunction` construct will define an AWS Lambda function which is +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'; + +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'), +}); +``` + +In the above example, the AWS Lambda function defined in `myLambdaFunction` will +be invoked when the stack is deployed. + +## Trigger Failures + +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. + +## Order of Execution + +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. + +In most cases, implicit ordering should be sufficient, but you can also use +`executeAfter` and `executeBefore` to control the order of execution. + +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: + +```ts +import { Construct, Node } from 'constructs'; +import * as triggers from '@aws-cdk/triggers'; + +declare const myTrigger: triggers.Trigger; +declare const hello: Construct; +declare const world: Construct; +declare const goodbye: Construct; + +myTrigger.executeAfter(hello, world); +myTrigger.executeBefore(goodbye); +``` + +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. + +## Re-execution of Triggers + +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/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..1e2bf5e2dab37 --- /dev/null +++ b/packages/@aws-cdk/triggers/lib/index.ts @@ -0,0 +1,2 @@ +export * from './trigger'; +export * from './trigger-function'; \ No newline at end of file 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..7975ac5b56bc0 --- /dev/null +++ b/packages/@aws-cdk/triggers/lib/lambda/index.ts @@ -0,0 +1,56 @@ +/* eslint-disable no-console */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import * as AWS from 'aws-sdk'; + +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') { + console.log('not calling trigger on DELETE'); + return; + } + + const handlerArn = event.ResourceProperties.HandlerArn; + if (!handlerArn) { + throw new Error('The "HandlerArn" property is required'); + } + + const invokeResponse = await invoke(handlerArn); + + if (invokeResponse.StatusCode !== 200) { + throw new Error(`Trigger handler 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 { + console.log(`Error payload: ${payload}`); + 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/trigger-function.ts b/packages/@aws-cdk/triggers/lib/trigger-function.ts new file mode 100644 index 0000000000000..6504c9f5d1334 --- /dev/null +++ b/packages/@aws-cdk/triggers/lib/trigger-function.ts @@ -0,0 +1,37 @@ +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', { + ...props, + handler: this, + }); + } + + 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..88299f2373294 --- /dev/null +++ b/packages/@aws-cdk/triggers/lib/trigger.ts @@ -0,0 +1,141 @@ +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 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; +} + +/** + * Props for `Trigger`. + */ +export interface TriggerProps extends TriggerOptions { + /** + * The AWS Lambda function of the handler to execute. + */ + readonly handler: lambda.Function; +} + +/** + * 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 handlerArn = this.determineHandlerArn(props); + const provider = CustomResourceProvider.getOrCreateProvider(this, 'AWSCDK.TriggerCustomResourceProvider', { + runtime: CustomResourceProviderRuntime.NODEJS_14_X, + codeDirectory: join(__dirname, 'lambda'), + policyStatements: [ + { + Effect: 'Allow', + Action: ['lambda:InvokeFunction'], + Resource: [handlerArn], + }, + ], + }); + + 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); + } + } + + private determineHandlerArn(props: TriggerProps) { + return props.handler.currentVersion.functionArn; + // const executeOnHandlerChange = props.executeOnHandlerChange ?? true; + // if (executeOnHandlerChange) { + // } + + // 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 new file mode 100644 index 0000000000000..a062d87d93ad6 --- /dev/null +++ b/packages/@aws-cdk/triggers/package.json @@ -0,0 +1,120 @@ +{ + "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" + }, + "awslint": { + "exclude": [ + "ref-via-interface:@aws-cdk/triggers.TriggerProps.handler" + ] + } +} 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..94a0cf390bc50 --- /dev/null +++ b/packages/@aws-cdk/triggers/test/integ.triggers.expected.json @@ -0,0 +1,203 @@ +{ + "Resources": { + "Topic198E71B3E": { + "Type": "AWS::SNS::Topic", + "DependsOn": [ + "MyFunctionTriggerDB129D7B" + ] + }, + "Topic269377B75": { + "Type": "AWS::SNS::Topic" + }, + "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 = function() { console.log(\"hi\"); };" + }, + "Role": { + "Fn::GetAtt": [ + "MyFunctionServiceRole3C357FF2", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs12.x" + }, + "DependsOn": [ + "MyFunctionServiceRole3C357FF2" + ] + }, + "MyFunctionCurrentVersion197490AF776ea8de2edf446759649703b18110a4": { + "Type": "AWS::Lambda::Version", + "Properties": { + "FunctionName": { + "Ref": "MyFunction3BAA72D1" + } + } + }, + "MyFunctionTriggerDB129D7B": { + "Type": "Custom::Trigger", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderHandler97BECD91", + "Arn" + ] + }, + "HandlerArn": { + "Ref": "MyFunctionCurrentVersion197490AF776ea8de2edf446759649703b18110a4" + } + }, + "DependsOn": [ + "Topic269377B75" + ], + "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": "MyFunctionCurrentVersion197490AF776ea8de2edf446759649703b18110a4" + } + ] + } + ] + } + } + ] + } + }, + "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..ad9a7e104438b --- /dev/null +++ b/packages/@aws-cdk/triggers/test/integ.triggers.ts @@ -0,0 +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 * as triggers from '../lib'; + +const app = new App(); +const stack = new Stack(app, 'MyStack'); + +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], +}); + +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 new file mode 100644 index 0000000000000..dd89046c6475a --- /dev/null +++ b/packages/@aws-cdk/triggers/test/triggers.test.ts @@ -0,0 +1,67 @@ +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', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + new triggers.TriggerFunction(stack, 'MyTrigger', { + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_12_X, + code: lambda.Code.fromInline('foo'), + }); + + // THEN + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', {}); + template.hasResourceProperties('Custom::Trigger', { + HandlerArn: { Ref: 'MyTriggerCurrentVersion8802742B707afb4f5c680fa04113c095ec4e8b5d' }, + }); +}); + +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 + 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); + 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); +});