feat(AWS EventBridge): Support for using native CloudFormation (#8437)

Co-authored-by: Piotr Grzesik <pj.grzesik@gmail.com>
This commit is contained in:
stuartforrest-infinity 2021-02-24 14:00:48 +00:00 committed by GitHub
parent 9b030ad5f4
commit 13444caa28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 1021 additions and 308 deletions

View File

@ -17,6 +17,16 @@ disabledDeprecations:
- '*' # To disable all deprecation messages
```
<a name="AWS_EVENT_BRIDGE_CUSTOM_RESOURCE"><div>&nbsp;</div></a>
## 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`.
<a name="NEW_VARIABLES_RESOLVER"><div>&nbsp;</div></a>
## New variables resolver

View File

@ -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() {

View File

@ -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;

View File

@ -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,
};

View File

@ -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: {

View File

@ -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,
};

View File

@ -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}"`);

View File

@ -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'
);
});
});
});

View File

@ -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": <eventTime>, "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": <eventTime>, "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": <eventTime>, "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');
});
});
});
});