From 4873149dac6c774ac6b8a648668585ce6dbf2adb Mon Sep 17 00:00:00 2001 From: Katsuhito Iwai Date: Sun, 23 Sep 2018 18:52:51 +0900 Subject: [PATCH] Add support for CloudWatch Events --- .../compileCloudWatchEventEvents.js | 156 +++++++ .../compileCloudWatchEventEvents.test.js | 384 ++++++++++++++++++ lib/index.js | 7 +- lib/index.test.js | 10 + lib/naming.js | 25 ++ 5 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.js create mode 100644 lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.test.js diff --git a/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.js b/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.js new file mode 100644 index 00000000..03ff83fe --- /dev/null +++ b/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.js @@ -0,0 +1,156 @@ +'use strict'; + +const _ = require('lodash'); +const BbPromise = require('bluebird'); + +module.exports = { + compileCloudWatchEventEvents() { + _.forEach(this.getAllStateMachines(), (stateMachineName) => { + const stateMachineObj = this.getStateMachine(stateMachineName); + let cloudWatchEventNumberInFunction = 0; + + if (stateMachineObj.events) { + _.forEach(stateMachineObj.events, (event) => { + if (event.cloudwatchEvent) { + cloudWatchEventNumberInFunction++; + let EventPattern; + let State; + let Input; + let InputPath; + let Description; + let Name; + + if (typeof event.cloudwatchEvent === 'object') { + if (!event.cloudwatchEvent.event) { + const errorMessage = [ + `Missing "event" property for cloudwatch event in stateMachine ${stateMachineName}`, // eslint-disable-line max-len + ' Please check the docs for more info.', + ].join(''); + throw new this.serverless.classes + .Error(errorMessage); + } + + EventPattern = JSON.stringify(event.cloudwatchEvent.event); + State = 'ENABLED'; + if (event.cloudwatchEvent.enabled === false) { + State = 'DISABLED'; + } + Input = event.cloudwatchEvent.input; + InputPath = event.cloudwatchEvent.inputPath; + Description = event.cloudwatchEvent.description; + Name = event.cloudwatchEvent.name; + + if (Input && InputPath) { + const errorMessage = [ + 'You can\'t set both input & inputPath properties at the', + 'same time for cloudwatch events.', + 'Please check the AWS docs for more info', + ].join(''); + throw new this.serverless.classes.Error(errorMessage); + } + + if (Input && typeof Input === 'object') { + Input = JSON.stringify(Input); + } + if (Input && typeof Input === 'string') { + // escape quotes to favor JSON.parse + Input = Input.replace(/\"/g, '\\"'); // eslint-disable-line + } + } else { + const errorMessage = [ + `CloudWatch event of stateMachine "${stateMachineName}" is not an object`, + ' Please check the docs for more info.', + ].join(''); + throw new this.serverless.classes + .Error(errorMessage); + } + + const stateMachineLogicalId = this + .getStateMachineLogicalId(stateMachineName, stateMachineObj); + const cloudWatchLogicalId = this + .getCloudWatchEventLogicalId(stateMachineName, cloudWatchEventNumberInFunction); + const cloudWatchIamRoleLogicalId = this + .getCloudWatchEventToStepFunctionsIamRoleLogicalId(stateMachineName); + const cloudWatchId = this.getCloudWatchEventId(stateMachineName); + const policyName = this.getCloudWatchEventPolicyName(stateMachineName); + + const cloudWatchEventRuleTemplate = ` + { + "Type": "AWS::Events::Rule", + "Properties": { + "EventPattern": ${EventPattern.replace(/\\n|\\r/g, '')}, + "State": "${State}", + ${Description ? `"Description": "${Description}",` : ''} + ${Name ? `"Name": "${Name}",` : ''} + "Targets": [{ + ${Input ? `"Input": "${Input.replace(/\\n|\\r/g, '')}",` : ''} + ${InputPath ? `"InputPath": "${InputPath.replace(/\r?\n/g, '')}",` : ''} + "Arn": { "Ref": "${stateMachineLogicalId}" }, + "Id": "${cloudWatchId}", + "RoleArn": { + "Fn::GetAtt": [ + "${cloudWatchIamRoleLogicalId}", + "Arn" + ] + } + }] + } + } + `; + + const iamRoleTemplate = ` + { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "events.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + }, + "Policies": [ + { + "PolicyName": "${policyName}", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "states:StartExecution" + ], + "Resource": { + "Ref": "${stateMachineLogicalId}" + } + } + ] + } + } + ] + } + } + `; + + const newCloudWatchEventRuleObject = { + [cloudWatchLogicalId]: JSON.parse(cloudWatchEventRuleTemplate), + }; + + const newPermissionObject = { + [cloudWatchIamRoleLogicalId]: JSON.parse(iamRoleTemplate), + }; + + _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, + newCloudWatchEventRuleObject, newPermissionObject); + } + }); + } + }); + return BbPromise.resolve(); + }, +}; diff --git a/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.test.js b/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.test.js new file mode 100644 index 00000000..dfa63b66 --- /dev/null +++ b/lib/deploy/events/cloudWatchEvent/compileCloudWatchEventEvents.test.js @@ -0,0 +1,384 @@ +'use strict'; + +const expect = require('chai').expect; +const Serverless = require('serverless/lib/Serverless'); +const AwsProvider = require('serverless/lib/plugins/aws/provider/awsProvider'); +const ServerlessStepFunctions = require('./../../../index'); + +describe('awsCompileCloudWatchEventEvents', () => { + let serverless; + let serverlessStepFunctions; + + beforeEach(() => { + serverless = new Serverless(); + serverless.setProvider('aws', new AwsProvider(serverless)); + const options = { + stage: 'dev', + region: 'us-east-1', + }; + serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} }; + serverlessStepFunctions = new ServerlessStepFunctions(serverless, options); + }); + + describe('#compileCloudWatchEventEvents()', () => { + it('should throw an error if cloudwatch event type is not an object', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: 42, + }, + ], + }, + }, + }; + + expect(() => serverlessStepFunctions.compileCloudWatchEventEvents()).to.throw(Error); + }); + + it('should throw an error if the "event" property is not given', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: null, + }, + }, + ], + }, + }, + }; + + expect(() => serverlessStepFunctions.compileCloudWatchEventEvents()).to.throw(Error); + }); + + it('should create corresponding resources when cloudwatch events are given', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: false, + }, + }, + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: true, + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileCloudWatchEventEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.FirstEventsRuleCloudWatchEvent1.Type + ).to.equal('AWS::Events::Rule'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.FirstEventsRuleCloudWatchEvent2.Type + ).to.equal('AWS::Events::Rule'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstEventToStepFunctionsRole.Type + ).to.equal('AWS::IAM::Role'); + }); + + it('should respect enabled variable, defaulting to true', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: false, + }, + }, + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: true, + }, + }, + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileCloudWatchEventEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.FirstEventsRuleCloudWatchEvent1 + .Properties.State + ).to.equal('DISABLED'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.FirstEventsRuleCloudWatchEvent2 + .Properties.State + ).to.equal('ENABLED'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.FirstEventsRuleCloudWatchEvent3 + .Properties.State + ).to.equal('ENABLED'); + }); + + it('should respect inputPath variable', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: false, + inputPath: '$.stageVariables', + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileCloudWatchEventEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.FirstEventsRuleCloudWatchEvent1 + .Properties.Targets[0].InputPath + ).to.equal('$.stageVariables'); + }); + + it('should respect input variable', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: false, + input: '{"key":"value"}', + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileCloudWatchEventEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.FirstEventsRuleCloudWatchEvent1 + .Properties.Targets[0].Input + ).to.equal('{"key":"value"}'); + }); + + it('should respect description variable', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: false, + input: '{"key":"value"}', + description: 'test description', + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileCloudWatchEventEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.FirstEventsRuleCloudWatchEvent1 + .Properties.Description + ).to.equal('test description'); + }); + + it('should respect name variable', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: false, + input: '{"key":"value"}', + name: 'test-event-name', + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileCloudWatchEventEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.FirstEventsRuleCloudWatchEvent1 + .Properties.Name + ).to.equal('test-event-name'); + }); + + it('should respect input variable as an object', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: false, + input: { + key: 'value', + }, + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileCloudWatchEventEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources.FirstEventsRuleCloudWatchEvent1 + .Properties.Targets[0].Input + ).to.equal('{"key":"value"}'); + }); + + it('should throw an error when both Input and InputPath are set', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification'], + detail: { state: ['pending'] }, + }, + enabled: false, + input: { + key: 'value', + }, + inputPath: '$.stageVariables', + }, + }, + ], + }, + }, + }; + + expect(() => serverlessStepFunctions.compileCloudWatchEventEvents()).to.throw(Error); + }); + + it('should respect variables if multi-line variables is given', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + cloudwatchEvent: { + event: { + source: ['aws.ec2'], + 'detail-type': ['EC2 Instance State-change Notification \n with newline'], + detail: { state: ['pending'] }, + }, + enabled: false, + input: { + key: 'value\n', + }, + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileCloudWatchEventEvents(); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstEventsRuleCloudWatchEvent1.Properties.EventPattern['detail-type'][0] + ).to.equal('EC2 Instance State-change Notification with newline'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstEventsRuleCloudWatchEvent1.Properties.Targets[0].Input + ).to.equal('{"key":"value"}'); + }); + + it('should not create corresponding resources when cloudwatch events are not given', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [], + }, + }, + }; + + serverlessStepFunctions.compileCloudWatchEventEvents(); + + expect( + serverlessStepFunctions.serverless.service.provider.compiledCloudFormationTemplate + .Resources + ).to.deep.equal({}); + }); + }); +}); diff --git a/lib/index.js b/lib/index.js index f159379d..10e6880e 100644 --- a/lib/index.js +++ b/lib/index.js @@ -19,6 +19,8 @@ const httpDeployment = require('./deploy/events/apiGateway/deployment'); const httpRestApi = require('./deploy/events/apiGateway/restApi'); const httpInfo = require('./deploy/events/apiGateway/endpointInfo'); const compileScheduledEvents = require('./deploy/events/schedule/compileScheduledEvents'); +const compileCloudWatchEventEvents = + require('./deploy/events/cloudWatchEvent/compileCloudWatchEventEvents'); const invoke = require('./invoke/invoke'); const yamlParser = require('./yamlParser'); const naming = require('./naming'); @@ -53,7 +55,8 @@ class ServerlessStepFunctions { invoke, yamlParser, naming, - compileScheduledEvents + compileScheduledEvents, + compileCloudWatchEventEvents ); this.commands = { @@ -125,7 +128,7 @@ class ServerlessStepFunctions { .then(this.compileUsagePlan) .then(this.compileUsagePlanKeys); } - ), + ).then(() => this.compileCloudWatchEventEvents()), 'after:deploy:deploy': () => BbPromise.bind(this) .then(this.getEndpointInfo) .then(this.display), diff --git a/lib/index.test.js b/lib/index.test.js index a7754899..b81a2654 100644 --- a/lib/index.test.js +++ b/lib/index.test.js @@ -93,6 +93,9 @@ describe('#index', () => { () => { const compileScheduledEventsStub = sinon .stub(serverlessStepFunctions, 'compileScheduledEvents').returns(BbPromise.resolve()); + const compileCloudWatchEventEventsStub = sinon + .stub(serverlessStepFunctions, 'compileCloudWatchEventEvents') + .returns(BbPromise.resolve()); const httpValidateStub = sinon .stub(serverlessStepFunctions, 'httpValidate').returns({ events: [] }); const compileRestApiStub = sinon @@ -118,6 +121,7 @@ describe('#index', () => { return serverlessStepFunctions.hooks['package:compileEvents']() .then(() => { expect(compileScheduledEventsStub.calledOnce).to.be.equal(true); + expect(compileCloudWatchEventEventsStub.calledOnce).to.be.equal(true); expect(httpValidateStub.calledOnce).to.be.equal(true); expect(compileRestApiStub.notCalled).to.be.equal(true); expect(compileResourcesStub.notCalled).to.be.equal(true); @@ -130,6 +134,7 @@ describe('#index', () => { expect(compileUsagePlanStub.notCalled).to.be.equal(true); expect(compileUsagePlanKeysStub.notCalled).to.be.equal(true); serverlessStepFunctions.compileScheduledEvents.restore(); + serverlessStepFunctions.compileCloudWatchEventEvents.restore(); serverlessStepFunctions.httpValidate.restore(); serverlessStepFunctions.compileRestApi.restore(); serverlessStepFunctions.compileResources.restore(); @@ -148,6 +153,9 @@ describe('#index', () => { () => { const compileScheduledEventsStub = sinon .stub(serverlessStepFunctions, 'compileScheduledEvents').returns(BbPromise.resolve()); + const compileCloudWatchEventEventsStub = sinon + .stub(serverlessStepFunctions, 'compileCloudWatchEventEvents') + .returns(BbPromise.resolve()); const httpValidateStub = sinon .stub(serverlessStepFunctions, 'httpValidate').returns({ events: [1, 2, 3] }); const compileRestApiStub = sinon @@ -173,6 +181,7 @@ describe('#index', () => { return serverlessStepFunctions.hooks['package:compileEvents']() .then(() => { expect(compileScheduledEventsStub.calledOnce).to.be.equal(true); + expect(compileCloudWatchEventEventsStub.calledOnce).to.be.equal(true); expect(httpValidateStub.calledOnce).to.be.equal(true); expect(compileRestApiStub.calledOnce).to.be.equal(true); expect(compileResourcesStub.calledAfter(compileRestApiStub)).to.be.equal(true); @@ -186,6 +195,7 @@ describe('#index', () => { expect(compileUsagePlanKeysStub.calledAfter(compileUsagePlanStub)).to.be.equal(true); serverlessStepFunctions.compileScheduledEvents.restore(); + serverlessStepFunctions.compileCloudWatchEventEvents.restore(); serverlessStepFunctions.httpValidate.restore(); serverlessStepFunctions.compileRestApi.restore(); serverlessStepFunctions.compileResources.restore(); diff --git a/lib/naming.js b/lib/naming.js index 418b693d..ead86277 100644 --- a/lib/naming.js +++ b/lib/naming.js @@ -82,4 +82,29 @@ module.exports = { 'schedule', ].join('-'); }, + + // CloudWatch Event + getCloudWatchEventId(stateMachineName) { + return `${stateMachineName}CloudWatchEvent`; + }, + + getCloudWatchEventLogicalId(stateMachineName, cloudWatchIndex) { + return `${this.provider.naming + .getNormalizedFunctionName(stateMachineName)}EventsRuleCloudWatchEvent${cloudWatchIndex}`; + }, + + getCloudWatchEventPolicyName(stateMachineName) { + return [ + this.provider.getStage(), + this.provider.getRegion(), + this.provider.serverless.service.service, + stateMachineName, + 'event', + ].join('-'); + }, + + getCloudWatchEventToStepFunctionsIamRoleLogicalId(stateMachineName) { + return `${this.provider.naming.getNormalizedFunctionName( + stateMachineName)}EventToStepFunctionsRole`; + }, };