diff --git a/README.md b/README.md index 829e510b..70cd7776 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,56 @@ You can input an value as json in request body, the value is passed as the input ``` $ curl -XPOST https://xxxxxxxxx.execute-api.us-east-1.amazonaws.com/dev/posts/create -d '{"foo":"bar"}' ``` +### Schedule +The following config will attach a schedule event and causes the stateMachine `crawl` to be called every 2 hours. The configuration allows you to attach multiple schedules to the same stateMachine. You can either use the `rate` or `cron` syntax. Take a look at the [AWS schedule syntax documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html) for more details. + +```yaml +stepFunctions: + stateMachines: + crawl: + events: + - schedule: rate(2 hours) + - schedule: cron(0 12 * * ? *) + definition: +``` + +## Enabling / Disabling + +**Note:** `schedule` events are enabled by default. + +This will create and attach a schedule event for the `aggregate` stateMachine which is disabled. If enabled it will call +the `aggregate` stateMachine every 10 minutes. + +```yaml +stepFunctions: + stateMachines: + aggregate: + events: + - schedule: + rate: rate(10 minutes) + enabled: false + input: + key1: value1 + key2: value2 + stageParams: + stage: dev + - schedule: + rate: cron(0 12 * * ? *) + enabled: false + inputPath: '$.stageVariables' +``` + +## Specify Name and Description + +Name and Description can be specified for a schedule event. These are not required properties. + +```yaml +events: + - schedule: + name: your-scheduled-rate-event-name + description: 'your scheduled rate event description' + rate: rate(2 hours) +``` ## Command ### deploy diff --git a/lib/deploy/events/schedule/compileScheduledEvents.js b/lib/deploy/events/schedule/compileScheduledEvents.js new file mode 100644 index 00000000..07acda31 --- /dev/null +++ b/lib/deploy/events/schedule/compileScheduledEvents.js @@ -0,0 +1,164 @@ +'use strict'; + +const _ = require('lodash'); +const BbPromise = require('bluebird'); + +module.exports = { + compileScheduledEvents() { + _.forEach(this.getAllStateMachines(), (stateMachineName) => { + const stateMachineObj = this.getStateMachine(stateMachineName); + let scheduleNumberInFunction = 0; + + if (stateMachineObj.events) { + _.forEach(stateMachineObj.events, (event) => { + if (event.schedule) { + scheduleNumberInFunction++; + let ScheduleExpression; + let State; + let Input; + let InputPath; + let Name; + let Description; + + // TODO validate rate syntax + if (typeof event.schedule === 'object') { + if (!event.schedule.rate) { + const errorMessage = [ + `Missing "rate" property for schedule event in stateMachine ${stateMachineName}`, + ' The correct syntax is: schedule: rate(10 minutes)', + ' OR an object with "rate" property.', + ' Please check the README for more info.', + ].join(''); + throw new this.serverless.classes + .Error(errorMessage); + } + ScheduleExpression = event.schedule.rate; + State = 'ENABLED'; + if (event.schedule.enabled === false) { + State = 'DISABLED'; + } + Input = event.schedule.input; + InputPath = event.schedule.inputPath; + Name = event.schedule.name; + Description = event.schedule.description; + + if (Input && InputPath) { + const errorMessage = [ + 'You can\'t set both input & inputPath properties at the', + 'same time for schedule 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 if (typeof event.schedule === 'string') { + ScheduleExpression = event.schedule; + State = 'ENABLED'; + } else { + const errorMessage = [ + `Schedule event of stateMachine ${stateMachineName} is not an object nor a string`, + ' The correct syntax is: schedule: rate(10 minutes)', + ' OR an object with "rate" property.', + ' Please check the README for more info.', + ].join(''); + throw new this.serverless.classes + .Error(errorMessage); + } + + const stateMachineLogicalId = this + .getStateMachineLogicalId(stateMachineName, stateMachineObj.name); + const scheduleLogicalId = this + .getScheduleLogicalId(stateMachineName, scheduleNumberInFunction); + const scheduleIamRoleLogicalId = this + .getScheduleToStepFunctionsIamRoleLogicalId(stateMachineName); + const scheduleId = this.getScheduleId(stateMachineName); + const policyName = this.getSchedulePolicyName(stateMachineName); + + const scheduleTemplate = ` + { + "Type": "AWS::Events::Rule", + "Properties": { + "ScheduleExpression": "${ScheduleExpression}", + "State": "${State}", + ${Name ? `"Name": "${Name}",` : ''} + ${Description ? `"Description": "${Description}",` : ''} + "Targets": [{ + ${Input ? `"Input": "${Input}",` : ''} + ${InputPath ? `"InputPath": "${InputPath}",` : ''} + "Arn": { "Ref": "${stateMachineLogicalId}" }, + "Id": "${scheduleId}", + "RoleArn": { + "Fn::GetAtt": [ + "${scheduleIamRoleLogicalId}", + "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 newScheduleObject = { + [scheduleLogicalId]: JSON.parse(scheduleTemplate), + }; + + const newPermissionObject = { + [scheduleIamRoleLogicalId]: JSON.parse(iamRoleTemplate), + }; + + _.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, + newScheduleObject, newPermissionObject); + } + }); + } + }); + return BbPromise.resolve(); + }, +}; diff --git a/lib/deploy/events/schedule/compileScheduledEvents.test.js b/lib/deploy/events/schedule/compileScheduledEvents.test.js new file mode 100644 index 00000000..2a4cdc2b --- /dev/null +++ b/lib/deploy/events/schedule/compileScheduledEvents.test.js @@ -0,0 +1,345 @@ +'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('#httpValidate()', () => { + 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('#compileScheduledEvents()', () => { + it('should throw an error if schedule event type is not a string or an object', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + schedule: 42, + }, + ], + }, + }, + }; + + expect(() => serverlessStepFunctions.compileScheduledEvents()).to.throw(Error); + }); + + it('should throw an error if the "rate" property is not given', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + schedule: { + rate: null, + }, + }, + ], + }, + }, + }; + + expect(() => serverlessStepFunctions.compileScheduledEvents()).to.throw(Error); + }); + + it('should create corresponding resources when schedule events are given', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + schedule: { + rate: 'rate(10 minutes)', + enabled: false, + }, + }, + { + schedule: { + rate: 'rate(10 minutes)', + enabled: true, + }, + }, + { + schedule: 'rate(10 minutes)', + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileScheduledEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule1.Type + ).to.equal('AWS::Events::Rule'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule2.Type + ).to.equal('AWS::Events::Rule'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule3.Type + ).to.equal('AWS::Events::Rule'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstScheduleToStepFunctionsRole.Type + ).to.equal('AWS::IAM::Role'); + }); + + it('should respect enabled variable, defaulting to true', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + schedule: { + rate: 'rate(10 minutes)', + enabled: false, + }, + }, + { + schedule: { + rate: 'rate(10 minutes)', + enabled: true, + }, + }, + { + schedule: { + rate: 'rate(10 minutes)', + }, + }, + { + schedule: 'rate(10 minutes)', + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileScheduledEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule1 + .Properties.State + ).to.equal('DISABLED'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule2 + .Properties.State + ).to.equal('ENABLED'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule3 + .Properties.State + ).to.equal('ENABLED'); + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule4 + .Properties.State + ).to.equal('ENABLED'); + }); + + it('should respect name variable', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + schedule: { + rate: 'rate(10 minutes)', + enabled: false, + name: 'your-scheduled-event-name', + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileScheduledEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule1 + .Properties.Name + ).to.equal('your-scheduled-event-name'); + }); + + it('should respect description variable', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + schedule: { + rate: 'rate(10 minutes)', + enabled: false, + description: 'your scheduled event description', + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileScheduledEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule1 + .Properties.Description + ).to.equal('your scheduled event description'); + }); + + it('should respect inputPath variable', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + schedule: { + rate: 'rate(10 minutes)', + enabled: false, + inputPath: '$.stageVariables', + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileScheduledEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule1 + .Properties.Targets[0].InputPath + ).to.equal('$.stageVariables'); + }); + + it('should respect input variable', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + schedule: { + rate: 'rate(10 minutes)', + enabled: false, + input: '{"key":"value"}', + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileScheduledEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule1 + .Properties.Targets[0].Input + ).to.equal('{"key":"value"}'); + }); + + it('should respect input variable as an object', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + { + schedule: { + rate: 'rate(10 minutes)', + enabled: false, + input: { + key: 'value', + }, + }, + }, + ], + }, + }, + }; + + serverlessStepFunctions.compileScheduledEvents(); + + expect(serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .FirstStepFunctionsEventsRuleSchedule1 + .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: [ + { + schedule: { + rate: 'rate(10 minutes)', + enabled: false, + input: { + key: 'value', + }, + inputPath: '$.stageVariables', + }, + }, + ], + }, + }, + }; + + expect(() => serverlessStepFunctions.compileScheduledEvents()).to.throw(Error); + }); + + it('should not create corresponding resources when scheduled events are not given', () => { + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + }, + }, + }; + + serverlessStepFunctions.compileScheduledEvents(); + + expect( + serverlessStepFunctions.serverless.service.provider.compiledCloudFormationTemplate + .Resources + ).to.deep.equal({}); + + serverlessStepFunctions.serverless.service.stepFunctions = { + stateMachines: { + first: { + events: [ + 'schedule', + ], + }, + }, + }; + + serverlessStepFunctions.compileScheduledEvents(); + + expect( + serverlessStepFunctions.serverless.service.provider.compiledCloudFormationTemplate + .Resources + ).to.deep.equal({}); + }); + }); +}); diff --git a/lib/index.js b/lib/index.js index dd94fdb1..a0e1a235 100644 --- a/lib/index.js +++ b/lib/index.js @@ -12,6 +12,7 @@ const httpIamRole = require('./deploy/events/apiGateway/iamRole'); 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 invoke = require('./invoke/invoke'); const yamlParser = require('./yamlParser'); const naming = require('./naming'); @@ -42,7 +43,8 @@ class ServerlessStepFunctions { httpDeployment, invoke, yamlParser, - naming + naming, + compileScheduledEvents ); this.commands = { @@ -91,21 +93,23 @@ class ServerlessStepFunctions { .then(this.compileIamRole) .then(this.compileStateMachines) .then(this.compileActivities), - 'package:compileEvents': () => { - this.pluginhttpValidated = this.httpValidate(); + 'package:compileEvents': () => + this.compileScheduledEvents().then(() => { + this.pluginhttpValidated = this.httpValidate(); - if (this.pluginhttpValidated.events.length === 0) { - return BbPromise.resolve(); - } + if (this.pluginhttpValidated.events.length === 0) { + return BbPromise.resolve(); + } - return BbPromise.bind(this) - .then(this.compileRestApi) - .then(this.compileResources) - .then(this.compileMethods) - .then(this.compileCors) - .then(this.compileHttpIamRole) - .then(this.compileDeployment); - }, + return BbPromise.bind(this) + .then(this.compileRestApi) + .then(this.compileResources) + .then(this.compileMethods) + .then(this.compileCors) + .then(this.compileHttpIamRole) + .then(this.compileDeployment); + } + ), '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 32be3cd9..aa26af9c 100644 --- a/lib/index.test.js +++ b/lib/index.test.js @@ -87,6 +87,8 @@ describe('#index', () => { it('should run package:compileEvents promise chain in order when http event is empty', () => { + const compileScheduledEventsStub = sinon + .stub(serverlessStepFunctions, 'compileScheduledEvents').returns(BbPromise.resolve()); const httpValidateStub = sinon .stub(serverlessStepFunctions, 'httpValidate').returns({ events: [] }); const compileRestApiStub = sinon @@ -103,6 +105,7 @@ describe('#index', () => { .stub(serverlessStepFunctions, 'compileDeployment').returns(BbPromise.resolve()); return serverlessStepFunctions.hooks['package:compileEvents']() .then(() => { + expect(compileScheduledEventsStub.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); @@ -110,6 +113,7 @@ describe('#index', () => { expect(compileCorsStub.notCalled).to.be.equal(true); expect(compileHttpIamRoleStub.notCalled).to.be.equal(true); expect(compileDeploymentStub.notCalled).to.be.equal(true); + serverlessStepFunctions.compileScheduledEvents.restore(); serverlessStepFunctions.httpValidate.restore(); serverlessStepFunctions.compileRestApi.restore(); serverlessStepFunctions.compileResources.restore(); @@ -122,6 +126,8 @@ describe('#index', () => { it('should run package:compileEvents promise chain in order', () => { + const compileScheduledEventsStub = sinon + .stub(serverlessStepFunctions, 'compileScheduledEvents').returns(BbPromise.resolve()); const httpValidateStub = sinon .stub(serverlessStepFunctions, 'httpValidate').returns({ events: [1, 2, 3] }); const compileRestApiStub = sinon @@ -138,6 +144,7 @@ describe('#index', () => { .stub(serverlessStepFunctions, 'compileDeployment').returns(BbPromise.resolve()); return serverlessStepFunctions.hooks['package:compileEvents']() .then(() => { + expect(compileScheduledEventsStub.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); @@ -146,6 +153,7 @@ describe('#index', () => { expect(compileHttpIamRoleStub.calledAfter(compileCorsStub)).to.be.equal(true); expect(compileDeploymentStub.calledAfter(compileHttpIamRoleStub)).to.be.equal(true); + serverlessStepFunctions.compileScheduledEvents.restore(); serverlessStepFunctions.httpValidate.restore(); serverlessStepFunctions.compileRestApi.restore(); serverlessStepFunctions.compileResources.restore(); diff --git a/lib/naming.js b/lib/naming.js index a4e3b114..578074bf 100644 --- a/lib/naming.js +++ b/lib/naming.js @@ -51,4 +51,29 @@ module.exports = { getApiToStepFunctionsIamRoleLogicalId() { return 'ApigatewayToStepFunctionsRole'; }, + + // Schedule + getScheduleId(stateMachineName) { + return `${stateMachineName}StepFunctionsSchedule`; + }, + + getScheduleLogicalId(stateMachineName, scheduleIndex) { + return `${this.provider.naming + .getNormalizedFunctionName(stateMachineName)}StepFunctionsEventsRuleSchedule${scheduleIndex}`; + }, + + getScheduleToStepFunctionsIamRoleLogicalId(stateMachineName) { + return `${this.provider.naming.getNormalizedFunctionName( + stateMachineName)}ScheduleToStepFunctionsRole`; + }, + + getSchedulePolicyName(stateMachineName) { + return [ + this.provider.getStage(), + this.provider.getRegion(), + this.provider.serverless.service.service, + stateMachineName, + 'schedule', + ].join('-'); + }, }; diff --git a/lib/naming.test.js b/lib/naming.test.js index e751ff6d..55abae0f 100644 --- a/lib/naming.test.js +++ b/lib/naming.test.js @@ -87,4 +87,32 @@ describe('#naming', () => { .equal('dev-step-functions-stepfunctions'); }); }); + + describe('#getScheduleId()', () => { + it('should normalize the stateMachine output name', () => { + expect(serverlessStepFunctions.getScheduleId('stateMachine')).to + .equal('stateMachineStepFunctionsSchedule'); + }); + }); + + describe('#getScheduleLogicalId()', () => { + it('should normalize the stateMachine output name and add the standard suffix', () => { + expect(serverlessStepFunctions.getScheduleLogicalId('stateMachine', 1)).to + .equal('StateMachineStepFunctionsEventsRuleSchedule1'); + }); + }); + + describe('#getScheduleToStepFunctionsIamRoleLogicalId()', () => { + it('should normalize the stateMachine output name', () => { + expect(serverlessStepFunctions.getScheduleToStepFunctionsIamRoleLogicalId('stateMachine')).to + .equal('StateMachineScheduleToStepFunctionsRole'); + }); + }); + + describe('#getSchedulePolicyName()', () => { + it('should use the stage and service name', () => { + expect(serverlessStepFunctions.getSchedulePolicyName('stateMachine')).to + .equal('dev-us-east-1-step-functions-stateMachine-schedule'); + }); + }); });