diff --git a/docs/deprecations.md b/docs/deprecations.md index f7fb77f00..345a1018c 100644 --- a/docs/deprecations.md +++ b/docs/deprecations.md @@ -17,6 +17,16 @@ disabledDeprecations: - '*' # To disable all deprecation messages ``` +
 
+ +## AWS EventBridge lambda event triggers + +Deprecation code: `AWS_EVENT_BRIDGE_CUSTOM_RESOURCE` + +Starting with v3.0.0 AWS EventBridge lambda event triggers and all associated EventBridge resources will be deployed using native CloudFormation resources instead of a custom resource that used a lambda to deploy them via the AWS SDK/API. + +Adapt to this behavior now by setting `provider.eventBridge.useCloudFormation: true`. +
 
## New variables resolver diff --git a/lib/plugins/aws/lib/naming.js b/lib/plugins/aws/lib/naming.js index dac3b5ad4..a33d89b0f 100644 --- a/lib/plugins/aws/lib/naming.js +++ b/lib/plugins/aws/lib/naming.js @@ -127,7 +127,7 @@ module.exports = { // Lambda getNormalizedFunctionName(functionName) { - return this.normalizeName(functionName.replace(/-/g, 'Dash').replace(/_/g, 'Underscore')); + return this.getNormalizedResourceName(functionName); }, extractLambdaNameFromArn(functionArn) { return functionArn.substring(functionArn.lastIndexOf(':') + 1); @@ -567,6 +567,18 @@ module.exports = { getCustomResourceEventBridgeResourceLogicalId(functionName, idx) { return `${this.getNormalizedFunctionName(functionName)}CustomEventBridge${idx}`; }, + getNormalizedResourceName(resourceName) { + return this.normalizeName(resourceName.replace(/-/g, 'Dash').replace(/_/g, 'Underscore')); + }, + getEventBridgeEventBusLogicalId(eventBusName) { + return `${this.getNormalizedResourceName(eventBusName)}EventBridgeEventBus`; + }, + getEventBridgeRuleLogicalId(ruleName) { + return `${this.normalizeNameToAlphaNumericOnly(ruleName)}EventBridgeRule`; + }, + getEventBridgeLambdaPermissionLogicalId(functionName, idx) { + return `${this.getNormalizedFunctionName(functionName)}EventBridgeLambdaPermission${idx}`; + }, // API Gateway Account Logs Write Role getCustomResourceApiGatewayAccountCloudWatchRoleHandlerFunctionName() { diff --git a/lib/plugins/aws/package/compile/events/eventBridge/index.js b/lib/plugins/aws/package/compile/events/eventBridge/index.js index 6aa5e4cba..132a1cc73 100644 --- a/lib/plugins/aws/package/compile/events/eventBridge/index.js +++ b/lib/plugins/aws/package/compile/events/eventBridge/index.js @@ -1,9 +1,9 @@ 'use strict'; const _ = require('lodash'); -const crypto = require('crypto'); const { addCustomResourceToService } = require('../../../../customResources'); const ServerlessError = require('../../../../../../serverless-error'); +const { makeAndHashRuleName, makeEventBusTargetId, makeRuleName } = require('./utils'); class AwsCompileEventBridgeEvents { constructor(serverless, options) { @@ -12,13 +12,28 @@ class AwsCompileEventBridgeEvents { this.provider = this.serverless.getProvider('aws'); this.hooks = { + 'initialize': () => { + if (!_.get(this.serverless.service.provider, 'eventBridge.useCloudFormation')) { + const hasFunctionsWithEventBridgeTrigger = Object.values( + this.serverless.service.functions + ).some(({ events }) => events.some(({ eventBridge }) => eventBridge)); + if (hasFunctionsWithEventBridgeTrigger) { + this.serverless._logDeprecation( + 'AWS_EVENT_BRIDGE_CUSTOM_RESOURCE', + 'AWS EventBridge resources are not being created using native CloudFormation, this is now possible and the use of custom resources is deprecated. Set `eventBridge.useCloudFormation: true` as a provider property to use this now.' + ); + } + } + }, 'package:compileEvents': this.compileEventBridgeEvents.bind(this), }; this.serverless.configSchemaHandler.defineFunctionEvent('aws', 'eventBridge', { type: 'object', properties: { - eventBus: { type: 'string', minLength: 1 }, + eventBus: { + anyOf: [{ type: 'string', minLength: 1 }, { $ref: '#/definitions/awsArn' }], + }, schedule: { pattern: '^(?:cron|rate)\\(.+\\)$' }, pattern: { type: 'object', @@ -59,6 +74,8 @@ class AwsCompileEventBridgeEvents { const { provider } = service; const { compiledCloudFormationTemplate } = provider; const iamRoleStatements = []; + const { eventBridge: options } = provider; + const shouldUseCloudFormation = options ? options.useCloudFormation : false; let hasEventBusesIamRoleStatement = false; let anyFuncUsesEventBridge = false; @@ -71,30 +88,16 @@ class AwsCompileEventBridgeEvents { if (event.eventBridge) { idx++; anyFuncUsesEventBridge = true; - const EventBus = event.eventBridge.eventBus; const Schedule = event.eventBridge.schedule; const Pattern = event.eventBridge.pattern; const Input = event.eventBridge.input; const InputPath = event.eventBridge.inputPath; let InputTransformer = event.eventBridge.inputTransformer; - const RuleNameSuffix = `rule-${idx}`; - let RuleName = `${FunctionName}-${RuleNameSuffix}`; - if (RuleName.length > 64) { - // Rule names cannot be longer than 64. - // Temporary solution until we have https://github.com/serverless/serverless/issues/6598 - RuleName = `${RuleName.slice(0, 31 - RuleNameSuffix.length)}${crypto - .createHash('md5') - .update(RuleName) - .digest('hex')}-${RuleNameSuffix}`; - } - - const eventFunctionLogicalId = this.provider.naming.getLambdaLogicalId(functionName); - const customResourceFunctionLogicalId = this.provider.naming.getCustomResourceEventBridgeHandlerFunctionLogicalId(); - const customEventBridgeResourceLogicalId = this.provider.naming.getCustomResourceEventBridgeResourceLogicalId( - functionName, - idx - ); + const RuleName = makeAndHashRuleName({ + functionName: FunctionName, + index: idx, + }); if ([Input, InputPath, InputTransformer].filter(Boolean).length > 1) { throw new ServerlessError( @@ -112,115 +115,330 @@ class AwsCompileEventBridgeEvents { ); } - const customEventBridge = { - [customEventBridgeResourceLogicalId]: { - Type: 'Custom::EventBridge', - Version: 1.0, - DependsOn: [eventFunctionLogicalId, customResourceFunctionLogicalId], - Properties: { - ServiceToken: { - 'Fn::GetAtt': [customResourceFunctionLogicalId, 'Arn'], - }, - FunctionName, - EventBridgeConfig: { - RuleName, - EventBus, - Schedule, - Pattern, - Input, - InputPath, - InputTransformer, - }, - }, - }, - }; + const eventBusName = EventBus; + // Custom resources will be deprecated in next major release + if (!shouldUseCloudFormation) { + const results = this.compileWithCustomResource({ + eventBusName, + EventBus, + compiledCloudFormationTemplate, + functionName, + RuleName, + Input, + InputPath, + InputTransformer, + Pattern, + Schedule, + FunctionName, + idx, + hasEventBusesIamRoleStatement, + iamRoleStatements, + }); - _.merge(compiledCloudFormationTemplate.Resources, customEventBridge); - - if (EventBus) { - let eventBusName = EventBus; - if (EventBus.startsWith('arn')) { - eventBusName = EventBus.slice(EventBus.indexOf('/') + 1); - } - - if (!hasEventBusesIamRoleStatement && eventBusName !== 'default') { - const eventBusResources = { - 'Fn::Join': [ - ':', - [ - 'arn', - { Ref: 'AWS::Partition' }, - 'events', - { Ref: 'AWS::Region' }, - { Ref: 'AWS::AccountId' }, - 'event-bus/*', - ], - ], - }; - iamRoleStatements.push({ - Effect: 'Allow', - Resource: eventBusResources, - Action: ['events:CreateEventBus', 'events:DeleteEventBus'], - }); - hasEventBusesIamRoleStatement = true; - } + results.iamRoleStatements.forEach((statement) => iamRoleStatements.push(statement)); + hasEventBusesIamRoleStatement = results.hasEventBusesIamRoleStatement; + } else { + this.compileWithCloudFormation({ + eventBusName, + EventBus, + compiledCloudFormationTemplate, + functionName, + RuleName, + Input, + InputPath, + InputTransformer, + Pattern, + Schedule, + FunctionName, + idx, + hasEventBusesIamRoleStatement, + iamRoleStatements, + }); } } }); } }); - if (anyFuncUsesEventBridge) { - const ruleResources = { - 'Fn::Join': [ - ':', - [ - 'arn', - { Ref: 'AWS::Partition' }, - 'events', - { Ref: 'AWS::Region' }, - { Ref: 'AWS::AccountId' }, - 'rule/*', - ], - ], - }; - iamRoleStatements.push({ - Effect: 'Allow', - Resource: ruleResources, - Action: [ - 'events:PutRule', - 'events:RemoveTargets', - 'events:PutTargets', - 'events:DeleteRule', - ], - }); - const functionResources = { - 'Fn::Join': [ - ':', - [ - 'arn', - { Ref: 'AWS::Partition' }, - 'lambda', - { Ref: 'AWS::Region' }, - { Ref: 'AWS::AccountId' }, - 'function', - '*', - ], - ], - }; - iamRoleStatements.push({ - Effect: 'Allow', - Resource: functionResources, - Action: ['lambda:AddPermission', 'lambda:RemovePermission'], - }); + // These permissions are for the custom resource lambda + if (!shouldUseCloudFormation && anyFuncUsesEventBridge) { + return this._addCustomResourceToService({ iamRoleStatements }); } + return null; + } + + compileWithCustomResource({ + eventBusName, + EventBus, + compiledCloudFormationTemplate, + functionName, + RuleName, + Input, + InputPath, + InputTransformer, + Pattern, + Schedule, + FunctionName, + idx, + hasEventBusesIamRoleStatement, + }) { + if (_.isObject(eventBusName)) { + throw new ServerlessError( + 'Referencing event bus with CloudFormation intrinsic functions is not supported for EventBrigde integration backed by Custom Resources. Please use `provider.eventBridge.useCloudFormation` setting to use native CloudFormation support for EventBridge.', + 'ERROR_INVALID_REFERENCE_TO_EVENT_BUS_CUSTOM_RESOURCE' + ); + } + + const iamRoleStatements = []; + + if (typeof eventBusName === 'string' && eventBusName.startsWith('arn')) { + eventBusName = EventBus.slice(EventBus.indexOf('/') + 1); + } + + const eventFunctionLogicalId = this.provider.naming.getLambdaLogicalId(functionName); + const customResourceFunctionLogicalId = this.provider.naming.getCustomResourceEventBridgeHandlerFunctionLogicalId(); + const customEventBridgeResourceLogicalId = this.provider.naming.getCustomResourceEventBridgeResourceLogicalId( + functionName, + idx + ); + + const customEventBridge = { + Type: 'Custom::EventBridge', + Version: 1.0, + DependsOn: [eventFunctionLogicalId, customResourceFunctionLogicalId], + Properties: { + ServiceToken: { + 'Fn::GetAtt': [customResourceFunctionLogicalId, 'Arn'], + }, + FunctionName, + EventBridgeConfig: { + RuleName, + EventBus, + Schedule, + Pattern, + Input, + InputPath, + InputTransformer, + }, + }, + }; + + compiledCloudFormationTemplate.Resources[ + customEventBridgeResourceLogicalId + ] = customEventBridge; + + if (!hasEventBusesIamRoleStatement && eventBusName && eventBusName !== 'default') { + iamRoleStatements.push({ + Effect: 'Allow', + Resource: { + 'Fn::Join': [ + ':', + [ + 'arn', + { Ref: 'AWS::Partition' }, + 'events', + { Ref: 'AWS::Region' }, + { Ref: 'AWS::AccountId' }, + 'event-bus/*', + ], + ], + }, + Action: ['events:CreateEventBus', 'events:DeleteEventBus'], + }); + hasEventBusesIamRoleStatement = true; + } + return { + iamRoleStatements, + hasEventBusesIamRoleStatement, + }; + } + + compileWithCloudFormation({ + eventBusName: _eventBusName, + EventBus, + compiledCloudFormationTemplate, + functionName, + RuleName, + Input, + InputPath, + InputTransformer, + Pattern, + Schedule, + FunctionName, + idx, + }) { + let eventBusResource; + let eventBusExists = false; + let eventBusName = _eventBusName; + + // It suggests that the object already exists and is being imported + if (_.isObject(eventBusName)) { + eventBusExists = true; + } + + // Does the resource already exist? ARN string - assume it is valid - CF will validate ultimately + if (typeof eventBusName === 'string' && eventBusName.startsWith('arn')) { + eventBusExists = true; + eventBusName = EventBus.slice(EventBus.indexOf('/') + 1); + } + + const shouldCreateEventBus = !eventBusExists && eventBusName && eventBusName !== 'default'; + if (shouldCreateEventBus) { + // Create EventBus Resource + eventBusResource = { + Type: 'AWS::Events::EventBus', + Properties: { + Name: eventBusName, + }, + }; + + compiledCloudFormationTemplate.Resources[ + this.provider.naming.getEventBridgeEventBusLogicalId(eventBusName) + ] = eventBusResource; + } + + const targetBase = { + Arn: { + 'Fn::GetAtt': [this.provider.naming.getLambdaLogicalId(functionName), 'Arn'], + }, + Id: makeEventBusTargetId(RuleName), + }; + + const target = this.addInputConfigToTarget({ + target: targetBase, + Input, + InputPath, + InputTransformer, + }); + + // Create a rule + const eventRuleResource = { + Type: 'AWS::Events::Rule', + Properties: { + // default event bus is used when EventBusName is not set + EventBusName: eventBusName === 'default' ? undefined : eventBusName, + EventPattern: JSON.stringify(Pattern), + Name: RuleName, + ScheduleExpression: Schedule, + State: 'ENABLED', + Targets: [target], + }, + }; + // If this stack is creating the event bus the rule must depend on it to ensure stack can be removed + if (shouldCreateEventBus) { + eventRuleResource.DependsOn = this.provider.naming.getEventBridgeEventBusLogicalId( + eventBusName + ); + } + + const ruleNameLogicalIdStub = makeRuleName({ + functionName: FunctionName, + index: idx, + }); + + compiledCloudFormationTemplate.Resources[ + this.provider.naming.getEventBridgeRuleLogicalId(ruleNameLogicalIdStub) + ] = eventRuleResource; + + const ruleNameArnPath = eventBusName ? [eventBusName, RuleName] : [RuleName]; + const lambdaPermissionResource = { + Type: 'AWS::Lambda::Permission', + Properties: { + Action: 'lambda:InvokeFunction', + FunctionName: { + Ref: this.provider.naming.getLambdaLogicalId(functionName), + }, + Principal: 'events.amazonaws.com', + SourceArn: { + 'Fn::Join': [ + ':', + [ + 'arn', + { Ref: 'AWS::Partition' }, + 'events', + { Ref: 'AWS::Region' }, + { Ref: 'AWS::AccountId' }, + { + 'Fn::Join': ['/', ['rule', ...ruleNameArnPath]], + }, + ], + ], + }, + }, + }; + + compiledCloudFormationTemplate.Resources[ + this.provider.naming.getEventBridgeLambdaPermissionLogicalId(functionName, idx) + ] = lambdaPermissionResource; + } + + _addCustomResourceToService({ iamRoleStatements: _iamRoleStatements }) { + const iamRoleStatements = _iamRoleStatements; + const ruleResources = { + 'Fn::Join': [ + ':', + [ + 'arn', + { Ref: 'AWS::Partition' }, + 'events', + { Ref: 'AWS::Region' }, + { Ref: 'AWS::AccountId' }, + 'rule/*', + ], + ], + }; + iamRoleStatements.push({ + Effect: 'Allow', + Resource: ruleResources, + Action: ['events:PutRule', 'events:RemoveTargets', 'events:PutTargets', 'events:DeleteRule'], + }); + const functionResources = { + 'Fn::Join': [ + ':', + [ + 'arn', + { Ref: 'AWS::Partition' }, + 'lambda', + { Ref: 'AWS::Region' }, + { Ref: 'AWS::AccountId' }, + 'function', + '*', + ], + ], + }; + iamRoleStatements.push({ + Effect: 'Allow', + Resource: functionResources, + Action: ['lambda:AddPermission', 'lambda:RemovePermission'], + }); if (iamRoleStatements.length) { return addCustomResourceToService(this.provider, 'eventBridge', iamRoleStatements); } - return null; } + + addInputConfigToTarget({ target, Input, InputPath, InputTransformer }) { + if (Input) { + target = Object.assign(target, { + Input: JSON.stringify(Input), + }); + return target; + } + if (InputPath) { + target = Object.assign(target, { + InputPath, + }); + return target; + } + if (InputTransformer) { + target = Object.assign(target, { + InputTransformer, + }); + return target; + } + return target; + } } module.exports = AwsCompileEventBridgeEvents; diff --git a/lib/plugins/aws/package/compile/events/eventBridge/utils.js b/lib/plugins/aws/package/compile/events/eventBridge/utils.js new file mode 100644 index 000000000..83007bccb --- /dev/null +++ b/lib/plugins/aws/package/compile/events/eventBridge/utils.js @@ -0,0 +1,40 @@ +'use strict'; + +const crypto = require('crypto'); + +const makeAndHashRuleName = ({ functionName, index }) => { + const name = makeRuleName({ functionName, index }); + if (name.length > 64) { + // Rule names cannot be longer than 64. + // Temporary solution until we have https://github.com/serverless/serverless/issues/6598 + return hashName(name, makeRuleNameSuffix(index)); + } + return name; +}; + +const makeRuleName = ({ functionName, index }) => `${functionName}-${makeRuleNameSuffix(index)}`; + +const makeRuleNameSuffix = (index) => `rule-${index}`; + +const makeEventBusTargetId = (ruleName) => { + const suffix = 'target'; + let targetId = `${ruleName}-${suffix}`; + if (targetId.length > 64) { + // Target ids cannot be longer than 64. + targetId = hashName(targetId, suffix); + } + return targetId; +}; + +const hashName = (name, suffix) => + `${name.slice(0, 31 - suffix.length)}${crypto + .createHash('md5') + .update(name) + .digest('hex')}-${suffix}`; + +module.exports = { + makeAndHashRuleName, + makeRuleName, + hashName, + makeEventBusTargetId, +}; diff --git a/lib/plugins/aws/provider.js b/lib/plugins/aws/provider.js index da44b0b7a..e699f5f1c 100644 --- a/lib/plugins/aws/provider.js +++ b/lib/plugins/aws/provider.js @@ -730,6 +730,13 @@ class AwsProvider { anyOf: ['REGIONAL', 'EDGE', 'PRIVATE'].map(caseInsensitive), }, environment: { $ref: '#/definitions/awsLambdaEnvironment' }, + eventBridge: { + type: 'object', + properties: { + useCloudFormation: { const: true }, + }, + additionalProperties: false, + }, httpApi: { type: 'object', properties: { diff --git a/test/fixtures/eventBridge/core.js b/test/fixtures/eventBridge/core.js index f34605e6a..6bab92bc6 100644 --- a/test/fixtures/eventBridge/core.js +++ b/test/fixtures/eventBridge/core.js @@ -28,4 +28,9 @@ function eventBusArn(event, context, callback) { return callback(null, event); } -module.exports = { eventBusDefault, eventBusDefaultArn, eventBusCustom, eventBusArn }; +module.exports = { + eventBusDefault, + eventBusDefaultArn, + eventBusCustom, + eventBusArn, +}; diff --git a/test/integration/eventBridge.test.js b/test/integration/eventBridge.test.js index b5de7c617..3aa6fea30 100644 --- a/test/integration/eventBridge.test.js +++ b/test/integration/eventBridge.test.js @@ -14,89 +14,132 @@ const { const { deployService, removeService, getMarkers } = require('../utils/integration'); -describe('AWS - Event Bridge Integration Test', function () { - this.timeout(1000 * 60 * 10); // Involves time-taking deploys - let serviceName; - let stackName; - let servicePath; - let namedEventBusName; - let arnEventBusName; - let arnEventBusArn; - const eventSource = 'serverless.test'; - const stage = 'dev'; - const putEventEntries = [ - { - Source: eventSource, - DetailType: 'ServerlessDetailType', - Detail: '{"Key1":"Value1"}', - }, - ]; - - before(async () => { - const serviceData = await fixtures.setup('eventBridge'); - ({ servicePath } = serviceData); - serviceName = serviceData.serviceConfig.service; - - namedEventBusName = `${serviceName}-named-event-bus`; - arnEventBusName = `${serviceName}-arn-event-bus`; - - // get default event bus ARN - const defaultEventBusArn = (await describeEventBus('default')).Arn; - - stackName = `${serviceName}-${stage}`; - // create an external Event Bus - // NOTE: deployment can only be done once the Event Bus is created - arnEventBusArn = (await createEventBus(arnEventBusName)).EventBusArn; - // update the YAML file with the arn - await serviceData.updateConfig({ - functions: { - eventBusDefaultArn: { - events: [ - { - eventBridge: { - eventBus: defaultEventBusArn, - pattern: { source: ['serverless.test'] }, - }, - }, - ], - }, - eventBusArn: { - events: [ - { - eventBridge: { - eventBus: arnEventBusArn, - pattern: { source: ['serverless.test'] }, - }, - }, - ], - }, +describe('AWS - Event Bridge Integration Test', () => { + describe('Using deprecated CustomResource deployment pattern', function () { + this.timeout(1000 * 60 * 100); // Involves time-taking deploys + let serviceName; + let stackName; + let servicePath; + let namedEventBusName; + let arnEventBusName; + let arnEventBusArn; + const eventSource = 'serverless.test'; + const stage = 'dev'; + const putEventEntries = [ + { + Source: eventSource, + DetailType: 'ServerlessDetailType', + Detail: '{"Key1":"Value1"}', }, + ]; + + before(async () => { + const serviceData = await fixtures.setup('eventBridge'); + ({ servicePath } = serviceData); + serviceName = serviceData.serviceConfig.service; + + namedEventBusName = `${serviceName}-named-event-bus`; + arnEventBusName = `${serviceName}-arn-event-bus`; + + // get default event bus ARN + const defaultEventBusArn = (await describeEventBus('default')).Arn; + + stackName = `${serviceName}-${stage}`; + // create an external Event Bus + // NOTE: deployment can only be done once the Event Bus is created + arnEventBusArn = (await createEventBus(arnEventBusName)).EventBusArn; + // update the YAML file with the arn + await serviceData.updateConfig({ + functions: { + eventBusDefaultArn: { + events: [ + { + eventBridge: { + eventBus: defaultEventBusArn, + pattern: { source: [eventSource] }, + }, + }, + ], + }, + eventBusArn: { + events: [ + { + eventBridge: { + eventBus: arnEventBusArn, + pattern: { source: [eventSource] }, + }, + }, + ], + }, + }, + }); + // deploy the service + return deployService(servicePath); }); - // deploy the service - return deployService(servicePath); - }); - after(async () => { - log.notice('Removing service...'); - await removeService(servicePath); - log.notice(`Deleting Event Bus "${arnEventBusName}"...`); - return deleteEventBus(arnEventBusName); - }); + after(async () => { + log.notice('Removing service...'); + await removeService(servicePath); + log.notice(`Deleting Event Bus "${arnEventBusName}"...`); + return deleteEventBus(arnEventBusName); + }); - describe('Default Event Bus', () => { - it('should invoke function when an event is sent to the event bus', () => { - const functionName = 'eventBusDefault'; - const markers = getMarkers(functionName); + describe('Default Event Bus', () => { + it('should invoke function when an event is sent to the event bus', async () => { + const functionName = 'eventBusDefault'; + const markers = getMarkers(functionName); - return confirmCloudWatchLogs( - `/aws/lambda/${stackName}-${functionName}`, - () => putEvents('default', putEventEntries), - { - checkIsComplete: (events) => - events.find((event) => event.message.includes(markers.start)) && - events.find((event) => event.message.includes(markers.end)), - } - ).then((events) => { + const events = await confirmCloudWatchLogs( + `/aws/lambda/${stackName}-${functionName}`, + () => putEvents('default', putEventEntries), + { + checkIsComplete: (data) => + data.find((event) => event.message.includes(markers.start)) && + data.find((event) => event.message.includes(markers.end)), + } + ); + const logs = events.map((event) => event.message).join('\n'); + expect(logs).to.include(`"source":"${eventSource}"`); + expect(logs).to.include(`"detail-type":"${putEventEntries[0].DetailType}"`); + expect(logs).to.include(`"detail":${putEventEntries[0].Detail}`); + }); + }); + + describe('Custom Event Bus', () => { + it('should invoke function when an event is sent to the event bus', async () => { + const functionName = 'eventBusCustom'; + const markers = getMarkers(functionName); + + const events = await confirmCloudWatchLogs( + `/aws/lambda/${stackName}-${functionName}`, + () => putEvents(namedEventBusName, putEventEntries), + { + checkIsComplete: (data) => + data.find((event) => event.message.includes(markers.start)) && + data.find((event) => event.message.includes(markers.end)), + } + ); + const logs = events.map((event) => event.message).join('\n'); + expect(logs).to.include(`"source":"${eventSource}"`); + expect(logs).to.include(`"detail-type":"${putEventEntries[0].DetailType}"`); + expect(logs).to.include(`"detail":${putEventEntries[0].Detail}`); + }); + }); + + describe('Arn Event Bus', () => { + it('should invoke function when an event is sent to the event bus', async () => { + const functionName = 'eventBusArn'; + const markers = getMarkers(functionName); + + const events = await confirmCloudWatchLogs( + `/aws/lambda/${stackName}-${functionName}`, + () => putEvents(arnEventBusName, putEventEntries), + { + checkIsComplete: (data) => + data.find((event) => event.message.includes(markers.start)) && + data.find((event) => event.message.includes(markers.end)), + } + ); const logs = events.map((event) => event.message).join('\n'); expect(logs).to.include(`"source":"${eventSource}"`); expect(logs).to.include(`"detail-type":"${putEventEntries[0].DetailType}"`); @@ -105,42 +148,135 @@ describe('AWS - Event Bridge Integration Test', function () { }); }); - describe('Custom Event Bus', () => { - it('should invoke function when an event is sent to the event bus', () => { - const functionName = 'eventBusCustom'; - const markers = getMarkers(functionName); + describe('Using native CloudFormation deployment pattern', function () { + this.timeout(1000 * 60 * 10); // Involves time-taking deploys + let serviceName; + let stackName; + let servicePath; + let namedEventBusName; + let arnEventBusName; + let arnEventBusArn; + const eventSource = 'serverless.test'; + const stage = 'dev'; + const putEventEntries = [ + { + Source: eventSource, + DetailType: 'ServerlessDetailType', + Detail: '{"Key1":"Value1"}', + }, + ]; - return confirmCloudWatchLogs( - `/aws/lambda/${stackName}-${functionName}`, - () => putEvents(namedEventBusName, putEventEntries), - { - checkIsComplete: (events) => - events.find((event) => event.message.includes(markers.start)) && - events.find((event) => event.message.includes(markers.end)), - } - ).then((events) => { + before(async () => { + const serviceData = await fixtures.setup('eventBridge'); + ({ servicePath } = serviceData); + serviceName = serviceData.serviceConfig.service; + + namedEventBusName = `${serviceName}-named-event-bus`; + arnEventBusName = `${serviceName}-arn-event-bus`; + + // get default event bus ARN + const defaultEventBusArn = (await describeEventBus('default')).Arn; + + stackName = `${serviceName}-${stage}`; + // create an external Event Bus + // NOTE: deployment can only be done once the Event Bus is created + arnEventBusArn = (await createEventBus(arnEventBusName)).EventBusArn; + await serviceData.updateConfig({ + provider: { + eventBridge: { + useCloudFormation: true, + }, + }, + functions: { + eventBusDefaultArn: { + events: [ + { + eventBridge: { + eventBus: defaultEventBusArn, + pattern: { source: [eventSource] }, + }, + }, + ], + }, + eventBusArn: { + events: [ + { + eventBridge: { + eventBus: arnEventBusArn, + pattern: { source: [eventSource] }, + }, + }, + ], + }, + }, + }); + return deployService(servicePath); + }); + + after(async () => { + log.notice('Removing service...'); + await removeService(servicePath); + log.notice(`Deleting Event Bus "${arnEventBusName}"...`); + return deleteEventBus(arnEventBusName); + }); + + describe('Default Event Bus', () => { + it('should invoke function when an event is sent to the event bus', async () => { + const functionName = 'eventBusDefault'; + const markers = getMarkers(functionName); + + const events = await confirmCloudWatchLogs( + `/aws/lambda/${stackName}-${functionName}`, + () => putEvents('default', putEventEntries), + { + checkIsComplete: (data) => + data.find((event) => event.message.includes(markers.start)) && + data.find((event) => event.message.includes(markers.end)), + } + ); const logs = events.map((event) => event.message).join('\n'); expect(logs).to.include(`"source":"${eventSource}"`); expect(logs).to.include(`"detail-type":"${putEventEntries[0].DetailType}"`); expect(logs).to.include(`"detail":${putEventEntries[0].Detail}`); }); }); - }); - describe('Arn Event Bus', () => { - it('should invoke function when an event is sent to the event bus', () => { - const functionName = 'eventBusArn'; - const markers = getMarkers(functionName); + describe('Custom Event Bus', () => { + it('should invoke function when an event is sent to the event bus', async () => { + const functionName = 'eventBusCustom'; + const markers = getMarkers(functionName); + + const events = await confirmCloudWatchLogs( + `/aws/lambda/${stackName}-${functionName}`, + () => putEvents(namedEventBusName, putEventEntries), + { + checkIsComplete: (data) => + data.find((event) => event.message.includes(markers.start)) && + data.find((event) => event.message.includes(markers.end)), + } + ); + const logs = events.map((event) => event.message).join('\n'); + expect(logs).to.include(`"source":"${eventSource}"`); + expect(logs).to.include(`"detail-type":"${putEventEntries[0].DetailType}"`); + expect(logs).to.include(`"detail":${putEventEntries[0].Detail}`); + }); + }); + + describe('Arn Event Bus', () => { + it('should invoke function when an event is sent to the event bus', async () => { + const functionName = 'eventBusArn'; + const markers = getMarkers(functionName); + + const events = await confirmCloudWatchLogs( + `/aws/lambda/${stackName}-${functionName}`, + () => putEvents(arnEventBusName, putEventEntries), + { + checkIsComplete: (data) => + data.find((event) => event.message.includes(markers.start)) && + data.find((event) => event.message.includes(markers.end)), + } + ); - return confirmCloudWatchLogs( - `/aws/lambda/${stackName}-${functionName}`, - () => putEvents(arnEventBusName, putEventEntries), - { - checkIsComplete: (events) => - events.find((event) => event.message.includes(markers.start)) && - events.find((event) => event.message.includes(markers.end)), - } - ).then((events) => { const logs = events.map((event) => event.message).join('\n'); expect(logs).to.include(`"source":"${eventSource}"`); expect(logs).to.include(`"detail-type":"${putEventEntries[0].DetailType}"`); diff --git a/test/unit/lib/plugins/aws/lib/naming.test.js b/test/unit/lib/plugins/aws/lib/naming.test.js index 62de01b85..caf102eee 100644 --- a/test/unit/lib/plugins/aws/lib/naming.test.js +++ b/test/unit/lib/plugins/aws/lib/naming.test.js @@ -983,4 +983,28 @@ describe('#naming()', () => { ); }); }); + + describe('#getEventBridgeEventBusLogicalId()', () => { + it('should normalize the event bus name and append correct suffix', () => { + expect(sdk.naming.getEventBridgeEventBusLogicalId('ExampleEventBusName')).to.equal( + 'ExampleEventBusNameEventBridgeEventBus' + ); + }); + }); + + describe('#getEventBridgeRuleLogicalId()', () => { + it('should normalize the rule name and append correct suffix', () => { + expect(sdk.naming.getEventBridgeRuleLogicalId('exampleRuleName')).to.equal( + 'ExampleRuleNameEventBridgeRule' + ); + }); + }); + + describe('#getEventBridgeLambdaPermissionLogicalId()', () => { + it('should normalize the name and append correct suffix with index', () => { + expect(sdk.naming.getEventBridgeLambdaPermissionLogicalId('exampleFunction', 1)).to.equal( + 'ExampleFunctionEventBridgeLambdaPermission1' + ); + }); + }); }); diff --git a/test/unit/lib/plugins/aws/package/compile/events/eventBridge/index.test.js b/test/unit/lib/plugins/aws/package/compile/events/eventBridge/index.test.js index 6d0bb7507..792c68e74 100644 --- a/test/unit/lib/plugins/aws/package/compile/events/eventBridge/index.test.js +++ b/test/unit/lib/plugins/aws/package/compile/events/eventBridge/index.test.js @@ -5,7 +5,10 @@ const chai = require('chai'); const runServerless = require('../../../../../../../../utils/run-serverless'); -const { expect } = chai; +chai.use(require('chai-as-promised')); +chai.use(require('sinon-chai')); + +const expect = chai.expect; const NAME_OVER_64_CHARS = 'oneVeryLongAndVeryStrangeAndVeryComplicatedFunctionNameOver64Chars'; @@ -128,104 +131,362 @@ const serverlessConfigurationExtension = { }; describe('EventBridgeEvents', () => { - let cfResources; - let naming; + describe('using custom resources deployment pattern', () => { + let cfResources; + let naming; - before(() => - runServerless({ - fixture: 'function', - configExt: serverlessConfigurationExtension, - cliArgs: ['package'], - }).then(({ cfTemplate, awsNaming }) => { - ({ Resources: cfResources } = cfTemplate); + before(async () => { + const { cfTemplate, awsNaming } = await runServerless({ + fixture: 'function', + configExt: serverlessConfigurationExtension, + cliArgs: ['package'], + }); + cfResources = cfTemplate.Resources; naming = awsNaming; - }) - ); + }); - /** - * - * @param {String} id - */ - function getEventBridgeConfigById(resourceLogicalId) { - const eventBridgeId = naming.getCustomResourceEventBridgeResourceLogicalId( - resourceLogicalId, - 1 - ); - return cfResources[eventBridgeId].Properties.EventBridgeConfig; - } + function getEventBridgeConfigById(resourceLogicalId) { + const eventBridgeId = naming.getCustomResourceEventBridgeResourceLogicalId( + resourceLogicalId, + 1 + ); + return cfResources[eventBridgeId].Properties.EventBridgeConfig; + } - it('should create the correct policy Statement', () => { - const roleId = naming.getCustomResourcesRoleLogicalId('default', '12345'); + it('should create the correct policy Statement', () => { + const roleId = naming.getCustomResourcesRoleLogicalId('default', '12345'); - const [firstStatement, secondStatement, thirdStatment] = cfResources[ - roleId - ].Properties.Policies[0].PolicyDocument.Statement; - expect(firstStatement.Effect).to.be.eq('Allow'); - expect(firstStatement.Resource['Fn::Join'][1]).to.deep.include('arn'); - expect(firstStatement.Resource['Fn::Join'][1]).to.deep.include('events'); - expect(firstStatement.Resource['Fn::Join'][1]).to.deep.include('event-bus/*'); - expect(firstStatement.Action).to.be.deep.eq(['events:CreateEventBus', 'events:DeleteEventBus']); + const [firstStatement, secondStatement, thirdStatment] = cfResources[ + roleId + ].Properties.Policies[0].PolicyDocument.Statement; + expect(firstStatement.Effect).to.be.eq('Allow'); + expect(firstStatement.Resource['Fn::Join'][1]).to.deep.include('arn'); + expect(firstStatement.Resource['Fn::Join'][1]).to.deep.include('events'); + expect(firstStatement.Resource['Fn::Join'][1]).to.deep.include('event-bus/*'); + expect(firstStatement.Action).to.be.deep.eq([ + 'events:CreateEventBus', + 'events:DeleteEventBus', + ]); - expect(secondStatement.Effect).to.be.eq('Allow'); - expect(secondStatement.Resource['Fn::Join'][1]).to.deep.include('events'); - expect(secondStatement.Resource['Fn::Join'][1]).to.deep.include('rule/*'); - expect(secondStatement.Action).to.be.deep.eq([ - 'events:PutRule', - 'events:RemoveTargets', - 'events:PutTargets', - 'events:DeleteRule', - ]); + expect(secondStatement.Effect).to.be.eq('Allow'); + expect(secondStatement.Resource['Fn::Join'][1]).to.deep.include('events'); + expect(secondStatement.Resource['Fn::Join'][1]).to.deep.include('rule/*'); + expect(secondStatement.Action).to.be.deep.eq([ + 'events:PutRule', + 'events:RemoveTargets', + 'events:PutTargets', + 'events:DeleteRule', + ]); - expect(thirdStatment.Effect).to.be.eq('Allow'); - expect(thirdStatment.Resource['Fn::Join'][1]).to.deep.include('function'); - expect(thirdStatment.Resource['Fn::Join'][1]).to.deep.include('lambda'); - expect(thirdStatment.Action).to.be.deep.eq(['lambda:AddPermission', 'lambda:RemovePermission']); - }); - it('should create the necessary resource', () => { - const eventBridgeConfig = getEventBridgeConfigById('default'); - expect(eventBridgeConfig.RuleName).to.include('dev-default-rule-1'); - }); + expect(thirdStatment.Effect).to.be.eq('Allow'); + expect(thirdStatment.Resource['Fn::Join'][1]).to.deep.include('function'); + expect(thirdStatment.Resource['Fn::Join'][1]).to.deep.include('lambda'); + expect(thirdStatment.Action).to.be.deep.eq([ + 'lambda:AddPermission', + 'lambda:RemovePermission', + ]); + }); + it('should create the necessary resource', () => { + const eventBridgeConfig = getEventBridgeConfigById('default'); + expect(eventBridgeConfig.RuleName).to.include('dev-default-rule-1'); + }); - it("should ensure rule name doesn't exceed 64 chars", () => { - const eventBridgeConfig = getEventBridgeConfigById(NAME_OVER_64_CHARS); - expect(eventBridgeConfig.RuleName.endsWith('rule-1')).to.be.true; - expect(eventBridgeConfig.RuleName).lengthOf.lte(64); - }); + it("should ensure rule name doesn't exceed 64 chars", () => { + const eventBridgeConfig = getEventBridgeConfigById(NAME_OVER_64_CHARS); + expect(eventBridgeConfig.RuleName.endsWith('rule-1')).to.be.true; + expect(eventBridgeConfig.RuleName).lengthOf.lte(64); + }); - it('should support input configuration', () => { - const eventBridgeConfig = getEventBridgeConfigById('configureInput'); - expect(eventBridgeConfig.Input.key1).be.eq('value1'); - expect(eventBridgeConfig.Input.key2).be.deep.eq({ - nested: 'value2', + it('should support input configuration', () => { + const eventBridgeConfig = getEventBridgeConfigById('configureInput'); + expect(eventBridgeConfig.Input.key1).be.eq('value1'); + expect(eventBridgeConfig.Input.key2).be.deep.eq({ + nested: 'value2', + }); + }); + + it('should support arn at eventBus', () => { + const eventBridgeConfig = getEventBridgeConfigById('configureInput'); + expect(eventBridgeConfig.EventBus).be.eq( + 'arn:aws:events:us-east-1:12345:event-bus/some-event-bus' + ); + }); + it('should support inputPath configuration', () => { + const eventBridgeConfig = getEventBridgeConfigById('inputPathConfiguration'); + expect(eventBridgeConfig.InputPath).be.eq('$.stageVariables'); + }); + + it('should support inputTransformer configuration', () => { + const eventBridgeConfig = getEventBridgeConfigById('inputTransformer'); + const { + InputTemplate, + InputPathsMap: { eventTime }, + } = eventBridgeConfig.InputTransformer; + expect(InputTemplate).be.eq('{"time": , "key1": "value1"}'); + expect(eventTime).be.eq('$.time'); + }); + + it('should register created and delete event bus permissions for non default event bus', () => { + const roleId = naming.getCustomResourcesRoleLogicalId('customSaas', '12345'); + const [firstStatement] = cfResources[roleId].Properties.Policies[0].PolicyDocument.Statement; + expect(firstStatement.Action[0]).to.be.eq('events:CreateEventBus'); + expect(firstStatement.Action[1]).to.be.eq('events:DeleteEventBus'); + expect(firstStatement.Effect).to.be.eq('Allow'); + }); + + it('should fail when trying to reference event bus via CF intrinsic function', async () => { + await expect( + runServerless({ + fixture: 'function', + configExt: { + functions: { + foo: { + events: [ + { + eventBridge: { + eventBus: { Ref: 'ImportedEventBus' }, + schedule: 'rate(10 minutes)', + }, + }, + ], + }, + }, + }, + cliArgs: ['package'], + }) + ).to.be.eventually.rejected.and.have.property( + 'code', + 'ERROR_INVALID_REFERENCE_TO_EVENT_BUS_CUSTOM_RESOURCE' + ); }); }); - it('should support arn at eventBus', () => { - const eventBridgeConfig = getEventBridgeConfigById('configureInput'); - expect(eventBridgeConfig.EventBus).be.eq( - 'arn:aws:events:us-east-1:12345:event-bus/some-event-bus' - ); - }); - it('should support inputPath configuration', () => { - const eventBridgeConfig = getEventBridgeConfigById('inputPathConfiguration'); - expect(eventBridgeConfig.InputPath).be.eq('$.stageVariables'); - }); + describe('using native CloudFormation', () => { + describe('when event bus is created as a part of the stack', () => { + let cfResources; + let naming; + let eventBusLogicalId; + let ruleResource; + let ruleTarget; + let inputPathRuleTarget; + let inputTransformerRuleTarget; + const schedule = 'rate(10 minutes)'; + const eventBusName = 'nondefault'; + const pattern = { + source: ['aws.cloudformation'], + }; + const input = { + key1: 'value1', + key2: { + nested: 'value2', + }, + }; + const inputPath = '$.stageVariables'; + const inputTransformer = { + inputTemplate: '{"time": , "key1": "value1"}', + inputPathsMap: { + eventTime: '$.time', + }, + }; - it('should support inputTransformer configuration', () => { - const eventBridgeConfig = getEventBridgeConfigById('inputTransformer'); - const { - InputTemplate, - InputPathsMap: { eventTime }, - } = eventBridgeConfig.InputTransformer; - expect(InputTemplate).be.eq('{"time": , "key1": "value1"}'); - expect(eventTime).be.eq('$.time'); - }); + before(async () => { + const { cfTemplate, awsNaming } = await runServerless({ + fixture: 'function', + configExt: { + provider: { + eventBridge: { + useCloudFormation: true, + }, + }, + functions: { + foo: { + events: [ + { + eventBridge: { + eventBus: eventBusName, + schedule, + pattern, + input, + }, + }, + { + eventBridge: { + eventBus: eventBusName, + schedule, + pattern, + inputPath, + }, + }, + { + eventBridge: { + eventBus: eventBusName, + schedule, + pattern, + inputTransformer, + }, + }, + ], + }, + }, + }, + cliArgs: ['package'], + }); + cfResources = cfTemplate.Resources; + naming = awsNaming; + eventBusLogicalId = naming.getEventBridgeEventBusLogicalId(eventBusName); + ruleResource = Object.values(cfResources).find( + (resource) => + resource.Type === 'AWS::Events::Rule' && resource.Properties.Name.endsWith('1') + ); + ruleTarget = ruleResource.Properties.Targets[0]; + const inputPathRuleResource = Object.values(cfResources).find( + (resource) => + resource.Type === 'AWS::Events::Rule' && resource.Properties.Name.endsWith('2') + ); + inputPathRuleTarget = inputPathRuleResource.Properties.Targets[0]; + const inputTransformerRuleResource = Object.values(cfResources).find( + (resource) => + resource.Type === 'AWS::Events::Rule' && resource.Properties.Name.endsWith('3') + ); + inputTransformerRuleTarget = inputTransformerRuleResource.Properties.Targets[0]; + }); - it('should register created and delete event bus permissions for non default event bus', () => { - const roleId = naming.getCustomResourcesRoleLogicalId('customSaas', '12345'); - const [firstStatement] = cfResources[roleId].Properties.Policies[0].PolicyDocument.Statement; - expect(firstStatement.Action[0]).to.be.eq('events:CreateEventBus'); - expect(firstStatement.Action[1]).to.be.eq('events:DeleteEventBus'); - expect(firstStatement.Effect).to.be.eq('Allow'); + it('should create an EventBus resource', () => { + expect(cfResources[eventBusLogicalId].Properties).to.deep.equal({ Name: eventBusName }); + }); + + it('should correctly set ScheduleExpression on a created rule', () => { + expect(ruleResource.Properties.ScheduleExpression).to.equal('rate(10 minutes)'); + }); + + it('should correctly set EventPattern on a created rule', () => { + expect(ruleResource.Properties.EventPattern).to.deep.equal(JSON.stringify(pattern)); + }); + + it('should correctly set Input on the target for the created rule', () => { + expect(ruleTarget.Input).to.deep.equal(JSON.stringify(input)); + }); + + it('should correctly set InputPath on the target for the created rule', () => { + expect(inputPathRuleTarget.InputPath).to.deep.equal(inputPath); + }); + + it('should correctly set InputTransformer on the target for the created rule', () => { + expect(inputTransformerRuleTarget.InputTransformer.InputPathsMap).to.deep.equal( + inputTransformer.inputPathsMap + ); + expect(inputTransformerRuleTarget.InputTransformer.InputTemplate).to.deep.equal( + inputTransformer.inputTemplate + ); + }); + + it('should create a rule that depends on created EventBus', () => { + expect(ruleResource.DependsOn).to.equal(eventBusLogicalId); + }); + + it('should create a rule that references correct function in target', () => { + expect(ruleTarget.Arn['Fn::GetAtt'][0]).to.equal(naming.getLambdaLogicalId('foo')); + }); + + it('should create a lambda permission resource that correctly references event bus in SourceArn', () => { + const lambdaPermissionResource = + cfResources[naming.getEventBridgeLambdaPermissionLogicalId('foo', 1)]; + + expect( + lambdaPermissionResource.Properties.SourceArn['Fn::Join'][1][5]['Fn::Join'][1][1] + ).to.deep.equal(eventBusName); + }); + }); + + describe('when it references already existing EventBus or uses default one', () => { + let cfResources; + let naming; + + before(async () => { + const { cfTemplate, awsNaming } = await runServerless({ + fixture: 'function', + cliArgs: ['package'], + configExt: { + provider: { + eventBridge: { + useCloudFormation: true, + }, + }, + functions: { + foo: { + events: [ + { + eventBridge: { + schedule: 'rate(10 minutes)', + eventBus: 'arn:xxxxx', + }, + }, + { + eventBridge: { + schedule: 'rate(10 minutes)', + eventBus: { Ref: 'ImportedEventBus' }, + }, + }, + { + eventBridge: { + schedule: 'rate(10 minutes)', + eventBus: 'default', + }, + }, + { + eventBridge: { + schedule: 'rate(10 minutes)', + }, + }, + ], + }, + }, + }, + }); + cfResources = cfTemplate.Resources; + naming = awsNaming; + }); + + it('should not create an EventBus if it is provided or default', async () => { + expect(Object.values(cfResources).some((value) => value.Type === 'AWS::Events::EventBus')) + .to.be.false; + }); + + it('should create a lambda permission resource that correctly references arn event bus in SourceArn', () => { + const lambdaPermissionResource = + cfResources[naming.getEventBridgeLambdaPermissionLogicalId('foo', 1)]; + + expect( + lambdaPermissionResource.Properties.SourceArn['Fn::Join'][1][5]['Fn::Join'][1][1] + ).to.deep.equal('arn:xxxxx'); + }); + + it('should create a lambda permission resource that correctly references CF event bus in SourceArn', () => { + const lambdaPermissionResource = + cfResources[naming.getEventBridgeLambdaPermissionLogicalId('foo', 2)]; + + expect( + lambdaPermissionResource.Properties.SourceArn['Fn::Join'][1][5]['Fn::Join'][1][1] + ).to.deep.equal({ Ref: 'ImportedEventBus' }); + }); + + it('should create a lambda permission resource that correctly references explicit default event bus in SourceArn', () => { + const lambdaPermissionResource = + cfResources[naming.getEventBridgeLambdaPermissionLogicalId('foo', 3)]; + + expect( + lambdaPermissionResource.Properties.SourceArn['Fn::Join'][1][5]['Fn::Join'][1][1] + ).to.equal('default'); + }); + + it('should create a lambda permission resource that correctly references implicit default event bus in SourceArn', () => { + const lambdaPermissionResource = + cfResources[naming.getEventBridgeLambdaPermissionLogicalId('foo', 4)]; + + expect( + lambdaPermissionResource.Properties.SourceArn['Fn::Join'][1][5]['Fn::Join'][1] + ).not.to.include('default'); + }); + }); }); });