From e1dc3b8202b38d9f6c0a560ed7e3f1cae4d9725e Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sun, 18 Aug 2019 16:39:21 +0200 Subject: [PATCH 1/5] feat: capture state machine schema explicitly --- .../stepFunctions/compileStateMachines.js | 69 +++++-------------- .../compileStateMachines.schema.js | 42 +++++++++++ .../compileStateMachines.test.js | 4 +- 3 files changed, 62 insertions(+), 53 deletions(-) create mode 100644 lib/deploy/stepFunctions/compileStateMachines.schema.js diff --git a/lib/deploy/stepFunctions/compileStateMachines.js b/lib/deploy/stepFunctions/compileStateMachines.js index 9c4f6428..cdec1440 100644 --- a/lib/deploy/stepFunctions/compileStateMachines.js +++ b/lib/deploy/stepFunctions/compileStateMachines.js @@ -1,8 +1,10 @@ 'use strict'; const _ = require('lodash'); -const BbPromise = require('bluebird'); +const Joi = require('@hapi/joi'); const Chance = require('chance'); +const BbPromise = require('bluebird'); +const schema = require('./compileStateMachines.schema'); const { isIntrinsic, translateLocalFunctionNames } = require('../../utils/aws'); const chance = new Chance(); @@ -14,22 +16,14 @@ function randomName() { }); } -function toTags(obj, serverless) { +function toTags(obj) { const tags = []; if (!obj) { return tags; } - if (_.isPlainObject(obj)) { - _.forEach( - obj, - (Value, Key) => tags.push({ Key, Value: Value.toString() }), - ); - } else { - throw new serverless.classes - .Error('Unable to parse tags, it should be an object.'); - } + _.forEach(obj, (Value, Key) => tags.push({ Key, Value: Value.toString() })); return tags; } @@ -75,7 +69,15 @@ module.exports = { let DefinitionString; let RoleArn; let DependsOn = []; - const Tags = toTags(this.serverless.service.provider.tags, this.serverless); + const Tags = toTags(this.serverless.service.provider.tags); + + const { error } = Joi.validate(stateMachineObj, schema, { allowUnknown: false }); + if (error) { + const errorMessage = `State machine [${stateMachineName}] is malformed. ` + + 'Please check the README for more info. ' + + `${error}`; + throw new this.serverless.classes.Error(errorMessage); + } if (stateMachineObj.definition) { if (typeof stateMachineObj.definition === 'string') { @@ -98,38 +100,10 @@ module.exports = { }; } } - } else { - const errorMessage = [ - `Missing "definition" property in stateMachine ${stateMachineName}`, - ' Please check the README for more info.', - ].join(''); - throw new this.serverless.classes - .Error(errorMessage); } if (stateMachineObj.role) { - if (typeof stateMachineObj.role === 'string') { - if (stateMachineObj.role.startsWith('arn:aws')) { - RoleArn = stateMachineObj.role; - } else { - const errorMessage = [ - `role property in stateMachine "${stateMachineName}" is not ARN`, - ' Please check the README for more info.', - ].join(''); - throw new this.serverless.classes - .Error(errorMessage); - } - } else if (isIntrinsic(stateMachineObj.role)) { - RoleArn = stateMachineObj.role; - } else { - const errorMessage = [ - `role property in stateMachine "${stateMachineName}" is neither a string`, - ' nor a CloudFormation intrinsic function', - ' Please check the README for more info.', - ].join(''); - throw new this.serverless.classes - .Error(errorMessage); - } + RoleArn = stateMachineObj.role; } else { RoleArn = { 'Fn::GetAtt': [ @@ -143,22 +117,15 @@ module.exports = { if (stateMachineObj.dependsOn) { const dependsOn = stateMachineObj.dependsOn; - if (_.isArray(dependsOn) && _.every(dependsOn, _.isString)) { + if (_.isArray(dependsOn)) { DependsOn = _.concat(DependsOn, dependsOn); - } else if (_.isString(dependsOn)) { - DependsOn.push(dependsOn); } else { - const errorMessage = [ - `dependsOn property in stateMachine "${stateMachineName}" is neither a string`, - ' nor an array of strings', - ].join(''); - throw new this.serverless.classes - .Error(errorMessage); + DependsOn.push(dependsOn); } } if (stateMachineObj.tags) { - const stateMachineTags = toTags(stateMachineObj.tags, this.serverless); + const stateMachineTags = toTags(stateMachineObj.tags); _.forEach(stateMachineTags, tag => Tags.push(tag)); } diff --git a/lib/deploy/stepFunctions/compileStateMachines.schema.js b/lib/deploy/stepFunctions/compileStateMachines.schema.js new file mode 100644 index 00000000..1baac667 --- /dev/null +++ b/lib/deploy/stepFunctions/compileStateMachines.schema.js @@ -0,0 +1,42 @@ +const Joi = require('@hapi/joi'); + +const arn = Joi.alternatives().try( + Joi.string().regex(/^arn:aws/, 'ARN'), + Joi.object().keys({ + Ref: Joi.string(), + }), + Joi.object().keys({ + 'Fn::GetAtt': Joi.array().items(Joi.string()), + }), +); + +const definition = Joi.alternatives().try( + Joi.string(), + Joi.object(), +); + +const dependsOn = Joi.alternatives().try( + Joi.string(), + Joi.array().items(Joi.string()), +); + +const id = Joi.string(); +const tags = Joi.object(); +const name = Joi.string(); +const events = Joi.array(); +const alarms = Joi.object(); +const notifications = Joi.object(); + +const schema = Joi.object().keys({ + id, + events, + name, + role: arn, + definition: definition.required(), + dependsOn, + tags, + alarms, + notifications, +}); + +module.exports = schema; diff --git a/lib/deploy/stepFunctions/compileStateMachines.test.js b/lib/deploy/stepFunctions/compileStateMachines.test.js index 6c14eb6b..bc5bd440 100644 --- a/lib/deploy/stepFunctions/compileStateMachines.test.js +++ b/lib/deploy/stepFunctions/compileStateMachines.test.js @@ -204,7 +204,7 @@ describe('#compileStateMachines', () => { myStateMachine1: { name: 'stateMachineWithIntrinsicRole1', definition: 'definition1\n', - role: { 'Fn::Attr': ['RoleID', 'Arn'] }, + role: { 'Fn::GetAtt': ['RoleID', 'Arn'] }, }, myStateMachine2: { name: 'stateMachineWithIntrinsicRole2', @@ -216,7 +216,7 @@ describe('#compileStateMachines', () => { serverlessStepFunctions.compileStateMachines(); expect(serverlessStepFunctions.serverless.service .provider.compiledCloudFormationTemplate.Resources - .StateMachineWithIntrinsicRole1.Properties.RoleArn).to.deep.equal({ 'Fn::Attr': ['RoleID', 'Arn'] }); + .StateMachineWithIntrinsicRole1.Properties.RoleArn).to.deep.equal({ 'Fn::GetAtt': ['RoleID', 'Arn'] }); expect(serverlessStepFunctions.serverless.service .provider.compiledCloudFormationTemplate.Resources .StateMachineWithIntrinsicRole2.Properties.RoleArn).to.deep.equal({ Ref: 'CloudformationId' }); From e967f0454cfd490f3ca7cf25495a47c8f0b4d354 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sun, 18 Aug 2019 21:09:03 +0200 Subject: [PATCH 2/5] feat: support referencing exact version Closes #240 --- lib/deploy/stepFunctions/compileIamRole.js | 62 ++++++++---- .../stepFunctions/compileIamRole.test.js | 41 +++++--- .../stepFunctions/compileStateMachines.js | 9 +- .../compileStateMachines.schema.js | 2 + .../compileStateMachines.test.js | 95 +++++++++++++++++++ lib/utils/aws.js | 35 +++++++ 6 files changed, 210 insertions(+), 34 deletions(-) diff --git a/lib/deploy/stepFunctions/compileIamRole.js b/lib/deploy/stepFunctions/compileIamRole.js index de3dbb08..f3860db6 100644 --- a/lib/deploy/stepFunctions/compileIamRole.js +++ b/lib/deploy/stepFunctions/compileIamRole.js @@ -152,46 +152,66 @@ function getLambdaPermissions(state) { if (_.isString(functionName)) { const segments = functionName.split(':'); - let functionArn; + let functionArns; if (functionName.startsWith('arn:aws:lambda')) { // full ARN - functionArn = functionName; + functionArns = [ + functionName, + `${functionName}:*`, + ]; } else if (segments.length === 3 && segments[0].match(/^\d+$/)) { // partial ARN - functionArn = { - 'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:${functionName}`, - }; + functionArns = [ + { 'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:${functionName}` }, + { 'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:${functionName}:*` }, + ]; } else { // name-only (with or without alias) - functionArn = { - 'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${functionName}`, - }; + functionArns = [ + { + 'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${functionName}`, + }, + { + 'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${functionName}:*`, + }, + ]; } return [{ action: 'lambda:InvokeFunction', - resource: functionArn, + resource: functionArns, }]; } if (_.has(functionName, 'Fn::GetAtt')) { // because the FunctionName parameter can be either a name or ARN // so you should be able to use Fn::GetAtt here to get the ARN + const functionArn = translateLocalFunctionNames.bind(this)(functionName); return [{ action: 'lambda:InvokeFunction', - resource: translateLocalFunctionNames.bind(this)(functionName), + resource: [ + functionArn, + { 'Fn::Sub': ['${functionArn}:*', { functionArn }] }, + ], }]; } if (_.has(functionName, 'Ref')) { // because the FunctionName parameter can be either a name or ARN // so you should be able to use Ref here to get the function name + const functionArn = translateLocalFunctionNames.bind(this)(functionName); return [{ action: 'lambda:InvokeFunction', - resource: { - 'Fn::Sub': [ - 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${FunctionName}', - { - FunctionName: translateLocalFunctionNames.bind(this)(functionName), - }, - ], - }, + resource: [ + { + 'Fn::Sub': [ + 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${functionArn}', + { functionArn }, + ], + }, + { + 'Fn::Sub': [ + 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${functionArn}:*', + { functionArn }, + ], + }, + ], }]; } @@ -278,9 +298,13 @@ function getIamPermissions(taskStates) { default: if (isIntrinsic(state.Resource) || state.Resource.startsWith('arn:aws:lambda')) { + const functionArn = translateLocalFunctionNames.bind(this)(state.Resource); return [{ action: 'lambda:InvokeFunction', - resource: translateLocalFunctionNames.bind(this)(state.Resource), + resource: [ + functionArn, + { 'Fn::Sub': ['${functionArn}:*', { functionArn }] }, + ], }]; } this.serverless.cli.consoleLog('Cannot generate IAM policy statement for Task state', state); diff --git a/lib/deploy/stepFunctions/compileIamRole.test.js b/lib/deploy/stepFunctions/compileIamRole.test.js index 40fe22c4..9a87ea8f 100644 --- a/lib/deploy/stepFunctions/compileIamRole.test.js +++ b/lib/deploy/stepFunctions/compileIamRole.test.js @@ -99,7 +99,7 @@ describe('#compileIamRole', () => { const helloLambda = 'arn:aws:lambda:123:*:function:hello'; const worldLambda = 'arn:aws:lambda:*:*:function:world'; const fooLambda = 'arn:aws:lambda:us-west-2::function:foo_'; - const barLambda = 'arn:aws:lambda:#{AWS::Region}:#{AWS::AccountId}:function:bar'; + const barLambda = 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:bar'; const genStateMachine = (name, lambda1, lambda2) => ({ name, @@ -131,8 +131,21 @@ describe('#compileIamRole', () => { const policy = serverlessStepFunctions.serverless.service .provider.compiledCloudFormationTemplate.Resources.IamRoleStateMachineExecution .Properties.Policies[0]; - expect(policy.PolicyDocument.Statement[0].Resource) - .to.be.deep.equal([helloLambda, worldLambda, fooLambda, barLambda]); + expect(policy.PolicyDocument.Statement[0].Action).to.deep.equal(['lambda:InvokeFunction']); + + const resources = policy.PolicyDocument.Statement[0].Resource; + expect(resources).to.have.lengthOf(8); + + expect(resources).to.include.members([helloLambda, worldLambda, fooLambda, barLambda]); + + const versionResources = resources.filter(x => x['Fn::Sub']); + versionResources.forEach((x) => { + const template = x['Fn::Sub'][0]; + expect(template).to.equal('${functionArn}:*'); + }); + + const versionedArns = versionResources.map(x => x['Fn::Sub'][1].functionArn); + expect(versionedArns).to.deep.equal([helloLambda, worldLambda, fooLambda, barLambda]); }); it('should give sns:Publish permission for only SNS topics referenced by state machine', () => { @@ -786,7 +799,7 @@ describe('#compileIamRole', () => { const lambdaPermissions = statements.filter(s => _.isEqual(s.Action, ['lambda:InvokeFunction'])); expect(lambdaPermissions).to.have.lengthOf(1); - expect(lambdaPermissions[0].Resource).to.deep.eq([lambda1, lambda2]); + expect(lambdaPermissions[0].Resource).to.include.members([lambda1, lambda2]); const snsPermissions = statements.filter(s => _.isEqual(s.Action, ['sns:Publish'])); expect(snsPermissions).to.have.lengthOf(1); @@ -969,7 +982,7 @@ describe('#compileIamRole', () => { const statements = policy.PolicyDocument.Statement; const lambdaPermissions = statements.find(x => x.Action[0] === 'lambda:InvokeFunction'); - expect(lambdaPermissions.Resource).to.be.deep.equal([ + expect(lambdaPermissions.Resource).to.deep.include.members([ { Ref: 'MyFunction' }, { Ref: 'MyFunction2' }]); const snsPermissions = statements.find(x => x.Action[0] === 'sns:Publish'); @@ -1130,7 +1143,7 @@ describe('#compileIamRole', () => { 'arn:aws:lambda:us-west-2:1234567890:function:c', { 'Fn::Sub': 'arn:aws:lambda:${AWS::Region}:1234567890:function:d' }, ]; - expect(lambdaPermissions[0].Resource).to.deep.eq(lambdaArns); + expect(lambdaPermissions[0].Resource).to.deep.include.members(lambdaArns); }); it('should support lambda::invoke resource type', () => { @@ -1183,7 +1196,7 @@ describe('#compileIamRole', () => { 'arn:aws:lambda:us-west-2:1234567890:function:c', { 'Fn::Sub': 'arn:aws:lambda:${AWS::Region}:1234567890:function:d' }, ]; - expect(lambdaPermissions[0].Resource).to.deep.eq(lambdaArns); + expect(lambdaPermissions[0].Resource).to.deep.include.members(lambdaArns); }); it('should support intrinsic functions for lambda::invoke resource type', () => { @@ -1238,8 +1251,8 @@ describe('#compileIamRole', () => { const lambdaArns = [ { 'Fn::Sub': [ - 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${FunctionName}', - { FunctionName: lambda1 }, + 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${functionArn}', + { functionArn: lambda1 }, ], }, { @@ -1257,7 +1270,7 @@ describe('#compileIamRole', () => { ], }, ]; - expect(lambdaPermissions[0].Resource).to.deep.eq(lambdaArns); + expect(lambdaPermissions[0].Resource).to.deep.include.members(lambdaArns); }); it('should support local function names', () => { @@ -1305,7 +1318,7 @@ describe('#compileIamRole', () => { ], }, ]; - expect(lambdaPermissions[0].Resource).to.deep.eq(lambdaArns); + expect(lambdaPermissions[0].Resource).to.deep.include.members(lambdaArns); }); it('should support local function names for lambda::invoke resource type', () => { @@ -1356,8 +1369,8 @@ describe('#compileIamRole', () => { const lambdaArns = [ { 'Fn::Sub': [ - 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${FunctionName}', - { FunctionName: { Ref: 'HelloDashworldLambdaFunction' } }, + 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${functionArn}', + { functionArn: { Ref: 'HelloDashworldLambdaFunction' } }, ], }, { @@ -1367,6 +1380,6 @@ describe('#compileIamRole', () => { ], }, ]; - expect(lambdaPermissions[0].Resource).to.deep.eq(lambdaArns); + expect(lambdaPermissions[0].Resource).to.deep.include.members(lambdaArns); }); }); diff --git a/lib/deploy/stepFunctions/compileStateMachines.js b/lib/deploy/stepFunctions/compileStateMachines.js index cdec1440..a44e6504 100644 --- a/lib/deploy/stepFunctions/compileStateMachines.js +++ b/lib/deploy/stepFunctions/compileStateMachines.js @@ -5,7 +5,7 @@ const Joi = require('@hapi/joi'); const Chance = require('chance'); const BbPromise = require('bluebird'); const schema = require('./compileStateMachines.schema'); -const { isIntrinsic, translateLocalFunctionNames } = require('../../utils/aws'); +const { isIntrinsic, translateLocalFunctionNames, convertToFunctionVersion } = require('../../utils/aws'); const chance = new Chance(); @@ -102,6 +102,13 @@ module.exports = { } } + if (stateMachineObj.useExactVersion === true && DefinitionString['Fn::Sub']) { + const params = DefinitionString['Fn::Sub'][1]; + const f = convertToFunctionVersion.bind(this); + const converted = _.mapValues(params, f); + DefinitionString['Fn::Sub'][1] = converted; + } + if (stateMachineObj.role) { RoleArn = stateMachineObj.role; } else { diff --git a/lib/deploy/stepFunctions/compileStateMachines.schema.js b/lib/deploy/stepFunctions/compileStateMachines.schema.js index 1baac667..43b34f14 100644 --- a/lib/deploy/stepFunctions/compileStateMachines.schema.js +++ b/lib/deploy/stepFunctions/compileStateMachines.schema.js @@ -26,12 +26,14 @@ const name = Joi.string(); const events = Joi.array(); const alarms = Joi.object(); const notifications = Joi.object(); +const useExactVersion = Joi.boolean().default(false); const schema = Joi.object().keys({ id, events, name, role: arn, + useExactVersion, definition: definition.required(), dependsOn, tags, diff --git a/lib/deploy/stepFunctions/compileStateMachines.test.js b/lib/deploy/stepFunctions/compileStateMachines.test.js index bc5bd440..866c8770 100644 --- a/lib/deploy/stepFunctions/compileStateMachines.test.js +++ b/lib/deploy/stepFunctions/compileStateMachines.test.js @@ -848,4 +848,99 @@ describe('#compileStateMachines', () => { const lambda2Param = params[lambda2ParamName]; expect(lambda2Param).to.eql({ 'Fn::GetAtt': ['HelloDashworldLambdaFunction', 'Arn'] }); }); + + it('should support using exact versions of functions', () => { + serverless.service.stepFunctions = { + stateMachines: { + myStateMachine1: { + id: 'Test', + useExactVersion: true, + definition: { + StartAt: 'Lambda1', + States: { + Lambda1: { + Type: 'Task', + Resource: 'arn:aws:states:::lambda:invoke', + Parameters: { + FunctionName: { + Ref: 'HelloLambdaFunction', + }, + Payload: { + 'ExecutionName.$': '$$.Execution.Name', + }, + }, + Next: 'Lambda2', + }, + Lambda2: { + Type: 'Task', + Resource: { + 'Fn::GetAtt': ['WorldLambdaFunction', 'Arn'], + }, + End: true, + }, + }, + }, + }, + }, + }; + + serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .HelloLambdaFunction = { + Type: 'AWS::Lambda::Function', + }; + + serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .Lambda1Version13579 = { + Type: 'AWS::Lambda::Version', + Properties: { + FunctionName: { + Ref: 'HelloLambdaFunction', + }, + }, + }; + + serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .WorldLambdaFunction = { + Type: 'AWS::Lambda::Function', + }; + + serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .Lambda2Version24680 = { + Type: 'AWS::Lambda::Version', + Properties: { + FunctionName: { + Ref: 'WorldLambdaFunction', + }, + }, + }; + + serverlessStepFunctions.compileStateMachines(); + const stateMachine = serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .Test; + + expect(stateMachine.Properties.DefinitionString).to.haveOwnProperty('Fn::Sub'); + expect(stateMachine.Properties.DefinitionString['Fn::Sub']).to.have.lengthOf(2); + + const [json, params] = stateMachine.Properties.DefinitionString['Fn::Sub']; + const modifiedDefinition = JSON.parse(json); + + const lambda1 = modifiedDefinition.States.Lambda1; + expect(lambda1.Parameters.FunctionName.startsWith('${')).to.eq(true); + const lambda1ParamName = lambda1.Parameters.FunctionName.replace(/[${}]/g, ''); + expect(params).to.haveOwnProperty(lambda1ParamName); + const lambda1Param = params[lambda1ParamName]; + expect(lambda1Param).to.eql({ Ref: 'Lambda1Version13579' }); + + const lambda2 = modifiedDefinition.States.Lambda2; + expect(lambda2.Resource.startsWith('${')).to.eq(true); + const lambda2ParamName = lambda2.Resource.replace(/[${}]/g, ''); + expect(params).to.haveOwnProperty(lambda2ParamName); + const lambda2Param = params[lambda2ParamName]; + expect(lambda2Param).to.eql({ Ref: 'Lambda2Version24680' }); + }); }); diff --git a/lib/utils/aws.js b/lib/utils/aws.js index 1240f561..b5061f62 100644 --- a/lib/utils/aws.js +++ b/lib/utils/aws.js @@ -37,7 +37,42 @@ function translateLocalFunctionNames(value) { return value; } +// converts a reference to a function to a reference to a function version +function convertToFunctionVersion(value) { + const resources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources; + const versions = Object.keys(resources) // [ [logicalId, version] ] + .filter(logicalId => resources[logicalId].Type === 'AWS::Lambda::Version') + .map(logicalId => [logicalId, resources[logicalId]]); + + const isFunction = logicalId => _.has(resources, logicalId) && resources[logicalId].Type === 'AWS::Lambda::Function'; + const getVersion = (logicalId) => { + const version = versions.find(x => _.get(x[1], 'Properties.FunctionName.Ref') === logicalId); + if (version) { + return version[0]; + } + + return logicalId; + }; + + if (_.has(value, 'Ref') && isFunction(value.Ref)) { + return { + Ref: getVersion(value.Ref), + }; + } + + // for Lambda function, Get::Att can only return the ARN + // but for Lambda version, you need Ref to get its ARN, hence why we return Ref here + if (_.has(value, 'Fn::GetAtt') && isFunction(value['Fn::GetAtt'][0])) { + return { + Ref: getVersion(value['Fn::GetAtt'][0]), + }; + } + + return value; +} + module.exports = { isIntrinsic, translateLocalFunctionNames, + convertToFunctionVersion, }; From 708f4c44a720965d7e644b7dae794b6de0d75c56 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sun, 18 Aug 2019 22:48:24 +0200 Subject: [PATCH 3/5] fix: fix bug when there're no lambda versions --- .../compileStateMachines.test.js | 197 ++++++++++-------- lib/utils/aws.js | 18 +- 2 files changed, 126 insertions(+), 89 deletions(-) diff --git a/lib/deploy/stepFunctions/compileStateMachines.test.js b/lib/deploy/stepFunctions/compileStateMachines.test.js index 866c8770..a3528d06 100644 --- a/lib/deploy/stepFunctions/compileStateMachines.test.js +++ b/lib/deploy/stepFunctions/compileStateMachines.test.js @@ -849,98 +849,131 @@ describe('#compileStateMachines', () => { expect(lambda2Param).to.eql({ 'Fn::GetAtt': ['HelloDashworldLambdaFunction', 'Arn'] }); }); - it('should support using exact versions of functions', () => { - serverless.service.stepFunctions = { - stateMachines: { - myStateMachine1: { - id: 'Test', - useExactVersion: true, - definition: { - StartAt: 'Lambda1', - States: { - Lambda1: { - Type: 'Task', - Resource: 'arn:aws:states:::lambda:invoke', - Parameters: { - FunctionName: { - Ref: 'HelloLambdaFunction', - }, - Payload: { - 'ExecutionName.$': '$$.Execution.Name', + describe('#useExactVersions', () => { + beforeEach(() => { + serverless.service.stepFunctions = { + stateMachines: { + myStateMachine1: { + id: 'Test', + useExactVersion: true, + definition: { + StartAt: 'Lambda1', + States: { + Lambda1: { + Type: 'Task', + Resource: 'arn:aws:states:::lambda:invoke', + Parameters: { + FunctionName: { + Ref: 'HelloLambdaFunction', + }, + Payload: { + 'ExecutionName.$': '$$.Execution.Name', + }, }, + Next: 'Lambda2', }, - Next: 'Lambda2', - }, - Lambda2: { - Type: 'Task', - Resource: { - 'Fn::GetAtt': ['WorldLambdaFunction', 'Arn'], + Lambda2: { + Type: 'Task', + Resource: { + 'Fn::GetAtt': ['WorldLambdaFunction', 'Arn'], + }, + End: true, }, - End: true, }, }, }, }, - }, - }; - - serverlessStepFunctions.serverless.service - .provider.compiledCloudFormationTemplate.Resources - .HelloLambdaFunction = { - Type: 'AWS::Lambda::Function', - }; - - serverlessStepFunctions.serverless.service - .provider.compiledCloudFormationTemplate.Resources - .Lambda1Version13579 = { - Type: 'AWS::Lambda::Version', - Properties: { - FunctionName: { - Ref: 'HelloLambdaFunction', - }, - }, }; - serverlessStepFunctions.serverless.service - .provider.compiledCloudFormationTemplate.Resources - .WorldLambdaFunction = { - Type: 'AWS::Lambda::Function', - }; + serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .HelloLambdaFunction = { + Type: 'AWS::Lambda::Function', + }; + + serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .WorldLambdaFunction = { + Type: 'AWS::Lambda::Function', + }; + }); + + const compileStateMachines = () => { + serverlessStepFunctions.compileStateMachines(); + const stateMachine = serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .Test; + + expect(stateMachine.Properties.DefinitionString).to.haveOwnProperty('Fn::Sub'); + expect(stateMachine.Properties.DefinitionString['Fn::Sub']).to.have.lengthOf(2); + + const [json, params] = stateMachine.Properties.DefinitionString['Fn::Sub']; + const modifiedDefinition = JSON.parse(json); + + const lambda1 = modifiedDefinition.States.Lambda1; + expect(lambda1.Parameters.FunctionName.startsWith('${')).to.eq(true); + const lambda1ParamName = lambda1.Parameters.FunctionName.replace(/[${}]/g, ''); + expect(params).to.haveOwnProperty(lambda1ParamName); + const lambda1Param = params[lambda1ParamName]; + + const lambda2 = modifiedDefinition.States.Lambda2; + expect(lambda2.Resource.startsWith('${')).to.eq(true); + const lambda2ParamName = lambda2.Resource.replace(/[${}]/g, ''); + expect(params).to.haveOwnProperty(lambda2ParamName); + const lambda2Param = params[lambda2ParamName]; + + return { lambda1Param, lambda2Param }; + }; - serverlessStepFunctions.serverless.service - .provider.compiledCloudFormationTemplate.Resources - .Lambda2Version24680 = { - Type: 'AWS::Lambda::Version', - Properties: { - FunctionName: { - Ref: 'WorldLambdaFunction', + it('should change refs to lambda version when useExactVersion is true', () => { + serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .Lambda1Version13579 = { + Type: 'AWS::Lambda::Version', + Properties: { + FunctionName: { + Ref: 'HelloLambdaFunction', + }, }, - }, - }; - - serverlessStepFunctions.compileStateMachines(); - const stateMachine = serverlessStepFunctions.serverless.service - .provider.compiledCloudFormationTemplate.Resources - .Test; - - expect(stateMachine.Properties.DefinitionString).to.haveOwnProperty('Fn::Sub'); - expect(stateMachine.Properties.DefinitionString['Fn::Sub']).to.have.lengthOf(2); - - const [json, params] = stateMachine.Properties.DefinitionString['Fn::Sub']; - const modifiedDefinition = JSON.parse(json); - - const lambda1 = modifiedDefinition.States.Lambda1; - expect(lambda1.Parameters.FunctionName.startsWith('${')).to.eq(true); - const lambda1ParamName = lambda1.Parameters.FunctionName.replace(/[${}]/g, ''); - expect(params).to.haveOwnProperty(lambda1ParamName); - const lambda1Param = params[lambda1ParamName]; - expect(lambda1Param).to.eql({ Ref: 'Lambda1Version13579' }); - - const lambda2 = modifiedDefinition.States.Lambda2; - expect(lambda2.Resource.startsWith('${')).to.eq(true); - const lambda2ParamName = lambda2.Resource.replace(/[${}]/g, ''); - expect(params).to.haveOwnProperty(lambda2ParamName); - const lambda2Param = params[lambda2ParamName]; - expect(lambda2Param).to.eql({ Ref: 'Lambda2Version24680' }); + }; + + serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .Lambda2Version24680 = { + Type: 'AWS::Lambda::Version', + Properties: { + FunctionName: { + Ref: 'WorldLambdaFunction', + }, + }, + }; + + const { lambda1Param, lambda2Param } = compileStateMachines(); + expect(lambda1Param).to.eql({ Ref: 'Lambda1Version13579' }); + expect(lambda2Param).to.eql({ Ref: 'Lambda2Version24680' }); + }); + + it('should not change refs to lambda version if version is not found, even if useExactVersion is true', () => { + const { lambda1Param, lambda2Param } = compileStateMachines(); + expect(lambda1Param).to.eql({ Ref: 'HelloLambdaFunction' }); + expect(lambda2Param).to.eql({ 'Fn::GetAtt': ['WorldLambdaFunction', 'Arn'] }); + }); + + it('should not change refs to lambda version if not using intrinsic functions, even if useExactVersion is true', () => { + const states = serverless.service.stepFunctions + .stateMachines.myStateMachine1.definition.States; + states.Lambda1.Parameters.FunctionName = 'hello'; + states.Lambda2.Resource = 'arn:aws:lambda:us-east-1:1234567890:function:world'; + + serverlessStepFunctions.compileStateMachines(); + const stateMachine = serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .Test; + + const definition = JSON.parse(stateMachine.Properties.DefinitionString); + expect(definition.States.Lambda1.Parameters.FunctionName).to.equal('hello'); + expect(definition.States.Lambda2.Resource) + .to.equal('arn:aws:lambda:us-east-1:1234567890:function:world'); + }); }); }); diff --git a/lib/utils/aws.js b/lib/utils/aws.js index b5061f62..56af6f0c 100644 --- a/lib/utils/aws.js +++ b/lib/utils/aws.js @@ -51,21 +51,25 @@ function convertToFunctionVersion(value) { return version[0]; } - return logicalId; + return null; }; if (_.has(value, 'Ref') && isFunction(value.Ref)) { - return { - Ref: getVersion(value.Ref), - }; + const version = getVersion(value.Ref); + if (version) { + return { Ref: version }; + } + return value; } // for Lambda function, Get::Att can only return the ARN // but for Lambda version, you need Ref to get its ARN, hence why we return Ref here if (_.has(value, 'Fn::GetAtt') && isFunction(value['Fn::GetAtt'][0])) { - return { - Ref: getVersion(value['Fn::GetAtt'][0]), - }; + const version = getVersion(value['Fn::GetAtt'][0]); + if (version) { + return { Ref: version }; + } + return value; } return value; From 1c03515cb6a30a342905bff55a54e2f5abe8a94e Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Sun, 18 Aug 2019 23:15:29 +0200 Subject: [PATCH 4/5] test: cover case when there are no functions --- .../compileStateMachines.test.js | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/lib/deploy/stepFunctions/compileStateMachines.test.js b/lib/deploy/stepFunctions/compileStateMachines.test.js index a3528d06..a6eb9351 100644 --- a/lib/deploy/stepFunctions/compileStateMachines.test.js +++ b/lib/deploy/stepFunctions/compileStateMachines.test.js @@ -975,5 +975,53 @@ describe('#compileStateMachines', () => { expect(definition.States.Lambda2.Resource) .to.equal('arn:aws:lambda:us-east-1:1234567890:function:world'); }); + + it('should do nothing if there are no ref to lambda functions, even if useExactVersion is true', () => { + serverless.service.stepFunctions = { + stateMachines: { + myStateMachine1: { + id: 'Test', + useExactVersion: true, + definition: { + StartAt: 'Sns', + States: { + Sns: { + Type: 'Task', + Resource: 'arn:aws:states:::sns:publish', + Parameters: { + Message: { + 'Fn::GetAtt': ['MyTopic', 'TopicName'], + }, + TopicArn: { + Ref: 'MyTopic', + }, + }, + End: true, + }, + }, + }, + }, + }, + }; + + serverlessStepFunctions.compileStateMachines(); + const stateMachine = serverlessStepFunctions.serverless.service + .provider.compiledCloudFormationTemplate.Resources + .Test; + + expect(stateMachine.Properties.DefinitionString).to.haveOwnProperty('Fn::Sub'); + expect(stateMachine.Properties.DefinitionString['Fn::Sub']).to.have.lengthOf(2); + + const [json, params] = stateMachine.Properties.DefinitionString['Fn::Sub']; + const modifiedDefinition = JSON.parse(json); + + const sns = modifiedDefinition.States.Sns; + expect(sns.Parameters.TopicArn.startsWith('${')).to.eq(true); + const topicArnParam = sns.Parameters.TopicArn.replace(/[${}]/g, ''); + expect(params).to.haveOwnProperty(topicArnParam); + const topicArn = params[topicArnParam]; + + expect(topicArn).to.deep.equal({ Ref: 'MyTopic' }); + }); }); }); From 134946c5e5e492dd95a3bc0812fe30ab524e4dc0 Mon Sep 17 00:00:00 2001 From: theburningmonk Date: Mon, 19 Aug 2019 14:35:21 +0200 Subject: [PATCH 5/5] docs: add blue-green deployment section --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index 86cb7f6b..ff01d184 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ This is the Serverless Framework plugin for AWS Step Functions. - [Depending on another logical id](#depending-on-another-logical-id) - [CloudWatch Alarms](#cloudwatch-alarms) - [CloudWatch Notifications](#cloudwatch-notifications) + - [Blue-Green deployments](#blue-green-deployment) - [Current Gotcha](#current-gotcha) - [Events](#events) - [API Gateway](#api-gateway) @@ -318,6 +319,21 @@ CloudFormation intrinsic functions such as `Ref` and `Fn::GetAtt` are supported. When setting up a notification target against a FIFO SQS queue, the queue must enable the content-based deduplication option and you must configure the `messageGroupId`. +### Blue green deployment + +To implement a [blue-green deployment with Step Functions](https://theburningmonk.com/2019/08/how-to-do-blue-green-deployment-for-step-functions/) you need to reference the exact versions of the functions. + +To do this, you can specify `useExactVersion: true` in the state machine. + +```yml +stepFunctions: + stateMachines: + hellostepfunc1: + useExactVersion: true + definition: + ... +``` + ## Current Gotcha Please keep this gotcha in mind if you want to reference the `name` from the `resources` section. To generate Logical ID for CloudFormation, the plugin transforms the specified name in serverless.yml based on the following scheme.