diff --git a/packages/@aws-cdk/triggers/README.md b/packages/@aws-cdk/triggers/README.md index 2c86e946c1941..d859fe1430f34 100644 --- a/packages/@aws-cdk/triggers/README.md +++ b/packages/@aws-cdk/triggers/README.md @@ -39,6 +39,33 @@ new triggers.TriggerFunction(stack, 'MyTrigger', { In the above example, the AWS Lambda function defined in `myLambdaFunction` will be invoked when the stack is deployed. +It is also possible to trigger a predefined Lambda function by using the `Trigger` construct: + +```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; + +const func = new lambda.Function(stack, 'MyFunction', { + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_14_X, + code: lambda.Code.fromInline('foo'), +}); + +new triggers.Trigger(stack, 'MyTrigger', { + handler: func, + timeout: Duration.minutes(10), + invocationType: triggers.InvocationType.EVENT, +}); +``` + +Addition properties can be used to fine-tune the behaviour of the trigger. +The `timeout` property can be used to determine how long the invocation of the function should take. +The `invocationType` property can be used to change the invocation type of the function. +This might be useful in scenarios where a fire-and-forget strategy for invoking the function is sufficient. + ## Trigger Failures If the trigger handler fails (e.g. an exception is raised), the CloudFormation diff --git a/packages/@aws-cdk/triggers/lib/lambda/index.ts b/packages/@aws-cdk/triggers/lib/lambda/index.ts index 8fb4db66a3cc3..6f3bd38bcc42e 100644 --- a/packages/@aws-cdk/triggers/lib/lambda/index.ts +++ b/packages/@aws-cdk/triggers/lib/lambda/index.ts @@ -3,11 +3,16 @@ // eslint-disable-next-line import/no-extraneous-dependencies import * as AWS from 'aws-sdk'; -export type InvokeFunction = (functionName: string) => Promise; +export type InvokeFunction = (functionName: string, invocationType: string, timeout: number) => Promise; -export const invoke: InvokeFunction = async (functionName) => { - const lambda = new AWS.Lambda(); - const invokeRequest = { FunctionName: functionName }; +export const invoke: InvokeFunction = async (functionName, invocationType, timeout) => { + const lambda = new AWS.Lambda({ + httpOptions: { + timeout, + }, + }); + + const invokeRequest = { FunctionName: functionName, InvocationType: invocationType }; console.log({ invokeRequest }); // IAM policy changes can take some time to fully propagate @@ -51,7 +56,10 @@ export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent throw new Error('The "HandlerArn" property is required'); } - const invokeResponse = await invoke(handlerArn); + const invocationType = event.ResourceProperties.InvocationType; + const timeout = event.ResourceProperties.Timeout; + + const invokeResponse = await invoke(handlerArn, invocationType, timeout); if (invokeResponse.StatusCode !== 200) { throw new Error(`Trigger handler failed with status code ${invokeResponse.StatusCode}`); @@ -68,7 +76,9 @@ export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent */ function parseError(payload: string | undefined): string { console.log(`Error payload: ${payload}`); - if (!payload) { return 'unknown handler error'; } + if (!payload) { + return 'unknown handler error'; + } try { const error = JSON.parse(payload); const concat = [error.errorMessage, error.trace].filter(x => x).join('\n'); diff --git a/packages/@aws-cdk/triggers/lib/trigger.ts b/packages/@aws-cdk/triggers/lib/trigger.ts index 6febb482966cf..2a74a0a6fd874 100644 --- a/packages/@aws-cdk/triggers/lib/trigger.ts +++ b/packages/@aws-cdk/triggers/lib/trigger.ts @@ -2,6 +2,7 @@ import { join } from 'path'; import * as lambda from '@aws-cdk/aws-lambda'; import { CustomResource, CustomResourceProvider, CustomResourceProviderRuntime } from '@aws-cdk/core'; import { Construct, IConstruct, Node } from 'constructs'; +import { Duration } from '../../core'; /** * Interface for triggers. @@ -61,6 +62,28 @@ export interface TriggerOptions { readonly executeOnHandlerChange?: boolean; } +/** + * The invocation type to apply to a trigger. This determines whether the trigger function should await the result of the to be triggered function or not. + */ +export enum InvocationType { + /** + * Invoke the function synchronously. Keep the connection open until the function returns a response or times out. + * The API response includes the function response and additional data. + */ + EVENT = 'Event', + + /** + * Invoke the function asynchronously. Send events that fail multiple times to the function's dead-letter queue (if one is configured). + * The API response only includes a status code. + */ + REQUEST_RESPONSE = 'RequestResponse', + + /** + * Validate parameter values and verify that the user or role has permission to invoke the function. + */ + DRY_RUN = 'DryRun' +} + /** * Props for `Trigger`. */ @@ -69,6 +92,20 @@ export interface TriggerProps extends TriggerOptions { * The AWS Lambda function of the handler to execute. */ readonly handler: lambda.Function; + + /** + * The invocation type to invoke the Lambda function with. + * + * @default RequestResponse + */ + readonly invocationType?: InvocationType; + + /** + * The timeout of the invocation call of the Lambda function to be triggered. + * + * @default Duration.minutes(2) + */ + readonly timeout?: Duration; } /** @@ -95,6 +132,8 @@ export class Trigger extends Construct implements ITrigger { serviceToken: provider.serviceToken, properties: { HandlerArn: handlerArn, + InvocationType: props.invocationType ?? 'RequestResponse', + Timeout: props.timeout?.toMilliseconds() ?? Duration.minutes(2).toMilliseconds(), }, }); diff --git a/packages/@aws-cdk/triggers/test/integ.triggers.js.snapshot/MyStack.template.json b/packages/@aws-cdk/triggers/test/integ.triggers.js.snapshot/MyStack.template.json index fde860ed9ad4b..31625a36f0bee 100644 --- a/packages/@aws-cdk/triggers/test/integ.triggers.js.snapshot/MyStack.template.json +++ b/packages/@aws-cdk/triggers/test/integ.triggers.js.snapshot/MyStack.template.json @@ -3,13 +3,13 @@ "Topic198E71B3E": { "Type": "AWS::SNS::Topic", "DependsOn": [ - "MyFunctionTriggerDB129D7B" + "MyTriggerFunctionTrigger5424E7A7" ] }, "Topic269377B75": { "Type": "AWS::SNS::Topic" }, - "MyFunctionServiceRole3C357FF2": { + "MyTriggerFunctionServiceRole1BB78C29": { "Type": "AWS::IAM::Role", "Properties": { "AssumeRolePolicyDocument": { @@ -40,7 +40,7 @@ ] } }, - "MyFunction3BAA72D1": { + "MyTriggerFunction056842F6": { "Type": "AWS::Lambda::Function", "Properties": { "Code": { @@ -48,18 +48,18 @@ }, "Role": { "Fn::GetAtt": [ - "MyFunctionServiceRole3C357FF2", + "MyTriggerFunctionServiceRole1BB78C29", "Arn" ] }, "Handler": "index.handler", - "Runtime": "nodejs14.x" + "Runtime": "nodejs16.x" }, "DependsOn": [ - "MyFunctionServiceRole3C357FF2" + "MyTriggerFunctionServiceRole1BB78C29" ] }, - "MyFunctionTriggerDB129D7B": { + "MyTriggerFunctionTrigger5424E7A7": { "Type": "Custom::Trigger", "Properties": { "ServiceToken": { @@ -69,8 +69,10 @@ ] }, "HandlerArn": { - "Ref": "MyFunctionCurrentVersion197490AF2cb2bc11080c1ef11d3b49c1f1603957" - } + "Ref": "MyTriggerFunctionCurrentVersion61957CE160cd5b4c06c4d00191dc10a647ea0777" + }, + "InvocationType": "RequestResponse", + "Timeout": 120000 }, "DependsOn": [ "Topic269377B75" @@ -78,11 +80,11 @@ "UpdateReplacePolicy": "Delete", "DeletionPolicy": "Delete" }, - "MyFunctionCurrentVersion197490AF2cb2bc11080c1ef11d3b49c1f1603957": { + "MyTriggerFunctionCurrentVersion61957CE160cd5b4c06c4d00191dc10a647ea0777": { "Type": "AWS::Lambda::Version", "Properties": { "FunctionName": { - "Ref": "MyFunction3BAA72D1" + "Ref": "MyTriggerFunction056842F6" } } }, @@ -124,7 +126,29 @@ [ { "Fn::GetAtt": [ - "MyFunction3BAA72D1", + "MyTriggerFunction056842F6", + "Arn" + ] + }, + ":*" + ] + ] + } + ] + }, + { + "Effect": "Allow", + "Action": [ + "lambda:InvokeFunction" + ], + "Resource": [ + { + "Fn::Join": [ + "", + [ + { + "Fn::GetAtt": [ + "MyLambdaFunction67CCA873", "Arn" ] }, @@ -186,6 +210,87 @@ "AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A" ] }, + "MyLambdaFunctionServiceRole313A4D46": { + "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" + ] + ] + } + ] + } + }, + "MyLambdaFunction67CCA873": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "exports.handler = function() { await setTimeout(3*60*1000, \"hi\"); };" + }, + "Role": { + "Fn::GetAtt": [ + "MyLambdaFunctionServiceRole313A4D46", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs16.x", + "Timeout": 900 + }, + "DependsOn": [ + "MyLambdaFunctionServiceRole313A4D46" + ] + }, + "MyLambdaFunctionCurrentVersion4FAB80ECdc4d4e257bb2b44c9c4b9231f0d16f4c": { + "Type": "AWS::Lambda::Version", + "Properties": { + "FunctionName": { + "Ref": "MyLambdaFunction67CCA873" + } + } + }, + "MyTrigger": { + "Type": "Custom::Trigger", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWSCDKTriggerCustomResourceProviderCustomResourceProviderHandler97BECD91", + "Arn" + ] + }, + "HandlerArn": { + "Ref": "MyLambdaFunctionCurrentVersion4FAB80ECdc4d4e257bb2b44c9c4b9231f0d16f4c" + }, + "InvocationType": "Event", + "Timeout": 60000 + }, + "DependsOn": [ + "Topic198E71B3E", + "Topic269377B75" + ], + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, "MySecondFunctionServiceRole5B930841": { "Type": "AWS::IAM::Role", "Properties": { @@ -247,7 +352,9 @@ }, "HandlerArn": { "Ref": "MySecondFunctionCurrentVersion7D497B5D173a4bb1f758991022ea97d651403362" - } + }, + "InvocationType": "RequestResponse", + "Timeout": 120000 }, "UpdateReplacePolicy": "Delete", "DeletionPolicy": "Delete" diff --git a/packages/@aws-cdk/triggers/test/integ.triggers.ts b/packages/@aws-cdk/triggers/test/integ.triggers.ts index 9e24277509c58..de5023461ed2e 100644 --- a/packages/@aws-cdk/triggers/test/integ.triggers.ts +++ b/packages/@aws-cdk/triggers/test/integ.triggers.ts @@ -1,7 +1,8 @@ import * as lambda from '@aws-cdk/aws-lambda'; import * as sns from '@aws-cdk/aws-sns'; -import { App, Stack } from '@aws-cdk/core'; +import { App, Duration, Stack } from '@aws-cdk/core'; import * as triggers from '../lib'; +import { InvocationType } from '../lib'; const app = new App(); const stack = new Stack(app, 'MyStack'); @@ -9,13 +10,28 @@ 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_14_X, +const triggerFunction = new triggers.TriggerFunction(stack, 'MyTriggerFunction', { + runtime: lambda.Runtime.NODEJS_16_X, handler: 'index.handler', code: lambda.Code.fromInline('exports.handler = function() { console.log("hi"); };'), executeBefore: [topic1], }); +const func = new lambda.Function(stack, 'MyLambdaFunction', { + runtime: lambda.Runtime.NODEJS_16_X, + handler: 'index.handler', + timeout: Duration.minutes(15), + code: lambda.Code.fromInline('exports.handler = function() { await setTimeout(3*60*1000, "hi"); };'), +}); + +const trigger = new triggers.Trigger(stack, 'MyTrigger', { + handler: func, + invocationType: InvocationType.EVENT, + timeout: Duration.minutes(1), + executeAfter: [topic1], +}); + +triggerFunction.executeAfter(topic2); trigger.executeAfter(topic2); new triggers.TriggerFunction(stack, 'MySecondFunction', { diff --git a/packages/@aws-cdk/triggers/test/trigger-handler.test.ts b/packages/@aws-cdk/triggers/test/trigger-handler.test.ts index 18eea6a881ceb..09a4d77a657b5 100644 --- a/packages/@aws-cdk/triggers/test/trigger-handler.test.ts +++ b/packages/@aws-cdk/triggers/test/trigger-handler.test.ts @@ -29,6 +29,8 @@ const mockRequest = { ResourceProperties: { ServiceToken: 'arn:aws:lambda:us-east-1:123456789012:function:MyFunction', HandlerArn: handlerArn, + Timeout: 600, + InvocationType: 'Event', }, RequestId: 'MyRequestId', ResourceType: 'Custom::Trigger', @@ -39,14 +41,14 @@ test('Create', async () => { await lambda.handler({ RequestType: 'Create', ...mockRequest }); expect(invokeMock).toBeCalledTimes(1); - expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn }); + expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn, InvocationType: 'Event' }); }); test('Update', async () => { await lambda.handler({ RequestType: 'Update', PhysicalResourceId: 'PRID', OldResourceProperties: {}, ...mockRequest }); expect(invokeMock).toBeCalledTimes(1); - expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn }); + expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn, InvocationType: 'Event' }); }); test('Delete - handler not called', async () => { @@ -64,7 +66,7 @@ test('non-200 status code throws an error', async () => { .toMatchObject({ message: 'Trigger handler failed with status code 500' }); expect(invokeMock).toBeCalledTimes(1); - expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn }); + expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn, InvocationType: 'Event' }); }); test('retry with access denied exception', async () => { @@ -81,7 +83,7 @@ test('retry with access denied exception', async () => { await response; expect(invokeMock).toBeCalledTimes(2); - expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn }); + expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn, InvocationType: 'Event' }); }); test('throws an error for other exceptions', async () => { @@ -94,7 +96,7 @@ test('throws an error for other exceptions', async () => { .toThrow(); expect(invokeMock).toBeCalledTimes(1); - expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn }); + expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn, InvocationType: 'Event' }); }); describe('function error', () => { @@ -111,7 +113,7 @@ describe('function error', () => { .toMatchObject({ message: expectedError }); expect(invokeMock).toBeCalledTimes(1); - expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn }); + expect(invokeMock).toBeCalledWith({ FunctionName: handlerArn, InvocationType: 'Event' }); }; }; diff --git a/packages/@aws-cdk/triggers/test/triggers.test.ts b/packages/@aws-cdk/triggers/test/triggers.test.ts index cc4edb610bade..8c3b4d05a29e9 100644 --- a/packages/@aws-cdk/triggers/test/triggers.test.ts +++ b/packages/@aws-cdk/triggers/test/triggers.test.ts @@ -1,10 +1,11 @@ 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 { Duration, Stack } from '@aws-cdk/core'; import * as triggers from '../lib'; +import { InvocationType } from '../lib'; -test('minimal', () => { +test('minimal trigger function', () => { // GIVEN const stack = new Stack(); @@ -89,3 +90,51 @@ test('multiple functions', () => { const triggerIamRole = roles.AWSCDKTriggerCustomResourceProviderCustomResourceProviderRoleE18FAF0A; expect(triggerIamRole.Properties.Policies[0].PolicyDocument.Statement.length).toBe(2); }); + +test('minimal trigger', () => { + // GIVEN + const stack = new Stack(); + const func = new lambda.Function(stack, 'MyFunction', { + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_14_X, + code: lambda.Code.fromInline('foo'), + }); + + // WHEN + new triggers.Trigger(stack, 'MyTrigger', { + handler: func, + }); + + // THEN + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', {}); + template.hasResourceProperties('Custom::Trigger', { + HandlerArn: { Ref: 'MyFunctionCurrentVersion197490AF2e4e06d52af2bb609d8c23243d665966' }, + }); +}); + +test('trigger with optional properties', () => { + // GIVEN + const stack = new Stack(); + const func = new lambda.Function(stack, 'MyFunction', { + handler: 'index.handler', + runtime: lambda.Runtime.NODEJS_14_X, + code: lambda.Code.fromInline('foo'), + }); + + // WHEN + new triggers.Trigger(stack, 'MyTrigger', { + handler: func, + timeout: Duration.minutes(10), + invocationType: InvocationType.EVENT, + }); + + // THEN + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', {}); + template.hasResourceProperties('Custom::Trigger', { + HandlerArn: { Ref: 'MyFunctionCurrentVersion197490AF2e4e06d52af2bb609d8c23243d665966' }, + Timeout: 600000, + InvocationType: 'Event', + }); +});