import ServerlessError from '../../../../../serverless-error.js' import resolveLambdaTarget from '../../../utils/resolve-lambda-target.js' import utils from '@serverlessinc/sf-core/src/utils.js' const { log, style } = utils const rateSyntax = '^rate\\((?:1 (?:minute|hour|day)|(?:1\\d+|[2-9]\\d*) (?:minute|hour|day)s)\\)$' const cronSyntax = '^cron\\(\\S+ \\S+ \\S+ \\S+ \\S+ \\S+\\)$' const scheduleSyntax = `${rateSyntax}|${cronSyntax}` const METHOD_SCHEDULER = 'scheduler' const METHOD_EVENT_BUS = 'eventBus' class AwsCompileScheduledEvents { constructor(serverless) { this.serverless = serverless this.provider = this.serverless.getProvider('aws') this.hooks = { 'package:compileEvents': async () => this.compileScheduledEvents(), } this.serverless.configSchemaHandler.defineFunctionEvent('aws', 'schedule', { anyOf: [ { type: 'string', pattern: scheduleSyntax }, { type: 'object', properties: { rate: { type: 'array', minItems: 1, items: { anyOf: [ { $ref: '#/definitions/awsCfFunction' }, { type: 'string', pattern: scheduleSyntax, }, ], }, }, enabled: { type: 'boolean' }, name: { type: 'string', minLength: 1, maxLength: 64, pattern: '[\\.\\-_A-Za-z0-9]+', }, description: { type: 'string', maxLength: 512 }, input: { anyOf: [ { type: 'string', maxLength: 8192 }, { type: 'object', oneOf: [ { properties: { body: { type: 'string', maxLength: 8192 }, }, required: ['body'], additionalProperties: false, }, { not: { required: ['body'], }, }, ], }, ], }, inputPath: { type: 'string', maxLength: 256 }, inputTransformer: { type: 'object', properties: { inputTemplate: { type: 'string', minLength: 1, maxLength: 8192, }, inputPathsMap: { type: 'object' }, }, required: ['inputTemplate'], additionalProperties: false, }, method: { type: 'string', enum: [METHOD_EVENT_BUS, METHOD_SCHEDULER], }, timezone: { type: 'string', pattern: '[\\w\\-\\/]+', }, }, required: ['rate'], additionalProperties: false, }, ], }) } compileScheduledEvents() { const schedulerStatement = { Effect: 'Allow', Action: ['lambda:InvokeFunction'], Resource: [], } const resources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources let hasSchedulerEvents = false this.serverless.service.getAllFunctions().forEach((functionName) => { const functionObj = this.serverless.service.getFunction(functionName) let scheduleNumberInFunction = 0 let functionHasSchedulerEvent = false if (functionObj.events) { functionObj.events.forEach((event) => { if (event.schedule) { let ScheduleExpressions let State let Input let InputPath let InputTransformer let Name let Description let method let roleArn let timezone if (typeof event.schedule === 'object') { ScheduleExpressions = event.schedule.rate State = 'ENABLED' if (event.schedule.enabled === false) { State = 'DISABLED' } Input = event.schedule.input InputPath = event.schedule.inputPath InputTransformer = event.schedule.inputTransformer Name = event.schedule.name timezone = event.schedule.timezone Description = event.schedule.description const functionLogicalId = this.provider.naming.getLambdaLogicalId(functionName) const functionResource = resources[functionLogicalId] roleArn = functionResource.Properties.Role method = event.schedule.method || METHOD_EVENT_BUS if (ScheduleExpressions.length > 1 && Name) { throw new ServerlessError( 'You cannot specify a name when defining more than one rate expression', 'SCHEDULE_NAME_NOT_ALLOWED_MULTIPLE_RATES', ) } if (Input && typeof Input === 'object') { if (typeof Input.body === 'string') { Input.body = JSON.parse(Input.body) } Input = JSON.stringify(Input) } if ( Input && typeof Input === 'string' && method !== METHOD_SCHEDULER ) { // escape quotes to favor JSON.parse Input = Input.replace(/"/g, '\\"') } if (InputTransformer) { if (method === METHOD_SCHEDULER) { throw new ServerlessError( 'Cannot setup "schedule" event: "inputTransformer" is not supported with "scheduler" mode', 'SCHEDULE_PARAMETER_NOT_SUPPORTED', ) } else { InputTransformer = this.formatInputTransformer(InputTransformer) } } if (InputPath && method === METHOD_SCHEDULER) { throw new ServerlessError( 'Cannot setup "schedule" event: "inputPath" is not supported with "scheduler" mode', 'SCHEDULE_PARAMETER_NOT_SUPPORTED', ) } if (timezone && method !== METHOD_SCHEDULER) { throw new ServerlessError( 'Cannot setup "schedule" event: "timezone" is only supported with "scheduler" mode', 'SCHEDULE_PARAMETER_NOT_SUPPORTED', ) } } else { ScheduleExpressions = [event.schedule] State = 'ENABLED' } const lambdaTarget = resolveLambdaTarget(functionName, functionObj) const lambdaTargetJson = JSON.stringify(lambdaTarget) const dependsOn = functionObj && functionObj.targetAlias ? functionObj.targetAlias.logicalId : undefined const scheduleId = this.provider.naming.getScheduleId(functionName) for (const ScheduleExpression of ScheduleExpressions) { scheduleNumberInFunction++ if (method === METHOD_SCHEDULER) { hasSchedulerEvents = true functionHasSchedulerEvent = true const scheduleLogicalId = this.provider.naming.getSchedulerScheduleLogicalId( functionName, scheduleNumberInFunction, ) resources[scheduleLogicalId] = { Type: 'AWS::Scheduler::Schedule', DependsOn: dependsOn, Properties: { ScheduleExpression, State, Target: { Arn: lambdaTarget, RoleArn: roleArn, Input, }, FlexibleTimeWindow: { Mode: 'OFF', }, Name, Description, ScheduleExpressionTimezone: timezone, }, } } else { const scheduleLogicalId = this.provider.naming.getScheduleLogicalId( functionName, scheduleNumberInFunction, ) const lambdaPermissionLogicalId = this.provider.naming.getLambdaSchedulePermissionLogicalId( functionName, scheduleNumberInFunction, ) let templateScheduleExpression if (typeof ScheduleExpression === 'string') { templateScheduleExpression = `"${ScheduleExpression}"` } else { templateScheduleExpression = JSON.stringify(ScheduleExpression) } const scheduleTemplate = ` { "Type": "AWS::Events::Rule", ${dependsOn ? `"DependsOn": "${dependsOn}",` : ''} "Properties": { "ScheduleExpression": ${templateScheduleExpression}, "State": "${State}", ${Name ? `"Name": "${Name}",` : ''} ${Description ? `"Description": "${Description}",` : ''} "Targets": [{ ${Input ? `"Input": "${Input}",` : ''} ${InputPath ? `"InputPath": "${InputPath}",` : ''} ${ InputTransformer ? `"InputTransformer": ${InputTransformer},` : '' } "Arn": ${lambdaTargetJson}, "Id": "${scheduleId}" }] } } ` const permissionTemplate = ` { "Type": "AWS::Lambda::Permission", ${dependsOn ? `"DependsOn": "${dependsOn}",` : ''} "Properties": { "FunctionName": ${lambdaTargetJson}, "Action": "lambda:InvokeFunction", "Principal": "events.amazonaws.com", "SourceArn": { "Fn::GetAtt": ["${scheduleLogicalId}", "Arn"] } } } ` const newScheduleObject = { [scheduleLogicalId]: JSON.parse(scheduleTemplate), } const newPermissionObject = { [lambdaPermissionLogicalId]: JSON.parse(permissionTemplate), } Object.assign(resources, newScheduleObject, newPermissionObject) } } } }) } if (functionHasSchedulerEvent) { const functionArnWithVars = 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}' + `:function:${functionObj.name}` schedulerStatement.Resource.push( { 'Fn::Sub': functionArnWithVars, }, { 'Fn::Sub': `${functionArnWithVars}:*`, }, ) } }) if (hasSchedulerEvents) { if (!resources.IamRoleLambdaExecution) { log.info( `Remember to add required EventBridge Scheduler permissions to your execution role. Documentation: ${style.link( 'https://docs.aws.amazon.com/scheduler/latest/UserGuide/setting-up.html#setting-up-execution-role', )}`, ) } else { const lambdaAssumeStatement = resources.IamRoleLambdaExecution.Properties.AssumeRolePolicyDocument.Statement.find( (statement) => statement.Principal.Service.includes('lambda.amazonaws.com'), ) if (lambdaAssumeStatement) { lambdaAssumeStatement.Principal.Service.push( 'scheduler.amazonaws.com', ) } const policyDocumentStatements = resources.IamRoleLambdaExecution.Properties.Policies[0].PolicyDocument .Statement policyDocumentStatements.push(schedulerStatement) } } } formatInputTransformer(inputTransformer) { const cfmOutput = { // InputTemplate is required InputTemplate: inputTransformer.inputTemplate, } // InputPathsMap is optional if (inputTransformer.inputPathsMap) { cfmOutput.InputPathsMap = inputTransformer.inputPathsMap } return JSON.stringify(cfmOutput) } } export default AwsCompileScheduledEvents