Skip to content

Commit e30421e

Browse files
authored
Merge pull request #247 from horike37/feature/exact_version
Feature/support_exact_version
2 parents 07f4623 + 134946c commit e30421e

7 files changed

+373
-87
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This is the Serverless Framework plugin for AWS Step Functions.
1313
- [Depending on another logical id](#depending-on-another-logical-id)
1414
- [CloudWatch Alarms](#cloudwatch-alarms)
1515
- [CloudWatch Notifications](#cloudwatch-notifications)
16+
- [Blue-Green deployments](#blue-green-deployment)
1617
- [Current Gotcha](#current-gotcha)
1718
- [Events](#events)
1819
- [API Gateway](#api-gateway)
@@ -318,6 +319,21 @@ CloudFormation intrinsic functions such as `Ref` and `Fn::GetAtt` are supported.
318319

319320
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`.
320321

322+
### Blue green deployment
323+
324+
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.
325+
326+
To do this, you can specify `useExactVersion: true` in the state machine.
327+
328+
```yml
329+
stepFunctions:
330+
stateMachines:
331+
hellostepfunc1:
332+
useExactVersion: true
333+
definition:
334+
...
335+
```
336+
321337
## Current Gotcha
322338

323339
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.

lib/deploy/stepFunctions/compileIamRole.js

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -152,46 +152,66 @@ function getLambdaPermissions(state) {
152152
if (_.isString(functionName)) {
153153
const segments = functionName.split(':');
154154

155-
let functionArn;
155+
let functionArns;
156156
if (functionName.startsWith('arn:aws:lambda')) {
157157
// full ARN
158-
functionArn = functionName;
158+
functionArns = [
159+
functionName,
160+
`${functionName}:*`,
161+
];
159162
} else if (segments.length === 3 && segments[0].match(/^\d+$/)) {
160163
// partial ARN
161-
functionArn = {
162-
'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:${functionName}`,
163-
};
164+
functionArns = [
165+
{ 'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:${functionName}` },
166+
{ 'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:${functionName}:*` },
167+
];
164168
} else {
165169
// name-only (with or without alias)
166-
functionArn = {
167-
'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${functionName}`,
168-
};
170+
functionArns = [
171+
{
172+
'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${functionName}`,
173+
},
174+
{
175+
'Fn::Sub': `arn:aws:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${functionName}:*`,
176+
},
177+
];
169178
}
170179

171180
return [{
172181
action: 'lambda:InvokeFunction',
173-
resource: functionArn,
182+
resource: functionArns,
174183
}];
175184
} if (_.has(functionName, 'Fn::GetAtt')) {
176185
// because the FunctionName parameter can be either a name or ARN
177186
// so you should be able to use Fn::GetAtt here to get the ARN
187+
const functionArn = translateLocalFunctionNames.bind(this)(functionName);
178188
return [{
179189
action: 'lambda:InvokeFunction',
180-
resource: translateLocalFunctionNames.bind(this)(functionName),
190+
resource: [
191+
functionArn,
192+
{ 'Fn::Sub': ['${functionArn}:*', { functionArn }] },
193+
],
181194
}];
182195
} if (_.has(functionName, 'Ref')) {
183196
// because the FunctionName parameter can be either a name or ARN
184197
// so you should be able to use Ref here to get the function name
198+
const functionArn = translateLocalFunctionNames.bind(this)(functionName);
185199
return [{
186200
action: 'lambda:InvokeFunction',
187-
resource: {
188-
'Fn::Sub': [
189-
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${FunctionName}',
190-
{
191-
FunctionName: translateLocalFunctionNames.bind(this)(functionName),
192-
},
193-
],
194-
},
201+
resource: [
202+
{
203+
'Fn::Sub': [
204+
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${functionArn}',
205+
{ functionArn },
206+
],
207+
},
208+
{
209+
'Fn::Sub': [
210+
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${functionArn}:*',
211+
{ functionArn },
212+
],
213+
},
214+
],
195215
}];
196216
}
197217

@@ -308,9 +328,13 @@ function getIamPermissions(taskStates) {
308328

309329
default:
310330
if (isIntrinsic(state.Resource) || state.Resource.startsWith('arn:aws:lambda')) {
331+
const functionArn = translateLocalFunctionNames.bind(this)(state.Resource);
311332
return [{
312333
action: 'lambda:InvokeFunction',
313-
resource: translateLocalFunctionNames.bind(this)(state.Resource),
334+
resource: [
335+
functionArn,
336+
{ 'Fn::Sub': ['${functionArn}:*', { functionArn }] },
337+
],
314338
}];
315339
}
316340
this.serverless.cli.consoleLog('Cannot generate IAM policy statement for Task state', state);

lib/deploy/stepFunctions/compileIamRole.test.js

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ describe('#compileIamRole', () => {
9999
const helloLambda = 'arn:aws:lambda:123:*:function:hello';
100100
const worldLambda = 'arn:aws:lambda:*:*:function:world';
101101
const fooLambda = 'arn:aws:lambda:us-west-2::function:foo_';
102-
const barLambda = 'arn:aws:lambda:#{AWS::Region}:#{AWS::AccountId}:function:bar';
102+
const barLambda = 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:bar';
103103

104104
const genStateMachine = (name, lambda1, lambda2) => ({
105105
name,
@@ -131,8 +131,21 @@ describe('#compileIamRole', () => {
131131
const policy = serverlessStepFunctions.serverless.service
132132
.provider.compiledCloudFormationTemplate.Resources.IamRoleStateMachineExecution
133133
.Properties.Policies[0];
134-
expect(policy.PolicyDocument.Statement[0].Resource)
135-
.to.be.deep.equal([helloLambda, worldLambda, fooLambda, barLambda]);
134+
expect(policy.PolicyDocument.Statement[0].Action).to.deep.equal(['lambda:InvokeFunction']);
135+
136+
const resources = policy.PolicyDocument.Statement[0].Resource;
137+
expect(resources).to.have.lengthOf(8);
138+
139+
expect(resources).to.include.members([helloLambda, worldLambda, fooLambda, barLambda]);
140+
141+
const versionResources = resources.filter(x => x['Fn::Sub']);
142+
versionResources.forEach((x) => {
143+
const template = x['Fn::Sub'][0];
144+
expect(template).to.equal('${functionArn}:*');
145+
});
146+
147+
const versionedArns = versionResources.map(x => x['Fn::Sub'][1].functionArn);
148+
expect(versionedArns).to.deep.equal([helloLambda, worldLambda, fooLambda, barLambda]);
136149
});
137150

138151
it('should give sns:Publish permission for only SNS topics referenced by state machine', () => {
@@ -786,7 +799,7 @@ describe('#compileIamRole', () => {
786799

787800
const lambdaPermissions = statements.filter(s => _.isEqual(s.Action, ['lambda:InvokeFunction']));
788801
expect(lambdaPermissions).to.have.lengthOf(1);
789-
expect(lambdaPermissions[0].Resource).to.deep.eq([lambda1, lambda2]);
802+
expect(lambdaPermissions[0].Resource).to.include.members([lambda1, lambda2]);
790803

791804
const snsPermissions = statements.filter(s => _.isEqual(s.Action, ['sns:Publish']));
792805
expect(snsPermissions).to.have.lengthOf(1);
@@ -969,7 +982,7 @@ describe('#compileIamRole', () => {
969982
const statements = policy.PolicyDocument.Statement;
970983

971984
const lambdaPermissions = statements.find(x => x.Action[0] === 'lambda:InvokeFunction');
972-
expect(lambdaPermissions.Resource).to.be.deep.equal([
985+
expect(lambdaPermissions.Resource).to.deep.include.members([
973986
{ Ref: 'MyFunction' }, { Ref: 'MyFunction2' }]);
974987

975988
const snsPermissions = statements.find(x => x.Action[0] === 'sns:Publish');
@@ -1130,7 +1143,7 @@ describe('#compileIamRole', () => {
11301143
'arn:aws:lambda:us-west-2:1234567890:function:c',
11311144
{ 'Fn::Sub': 'arn:aws:lambda:${AWS::Region}:1234567890:function:d' },
11321145
];
1133-
expect(lambdaPermissions[0].Resource).to.deep.eq(lambdaArns);
1146+
expect(lambdaPermissions[0].Resource).to.deep.include.members(lambdaArns);
11341147
});
11351148

11361149
it('should support lambda::invoke resource type', () => {
@@ -1183,7 +1196,7 @@ describe('#compileIamRole', () => {
11831196
'arn:aws:lambda:us-west-2:1234567890:function:c',
11841197
{ 'Fn::Sub': 'arn:aws:lambda:${AWS::Region}:1234567890:function:d' },
11851198
];
1186-
expect(lambdaPermissions[0].Resource).to.deep.eq(lambdaArns);
1199+
expect(lambdaPermissions[0].Resource).to.deep.include.members(lambdaArns);
11871200
});
11881201

11891202
it('should support intrinsic functions for lambda::invoke resource type', () => {
@@ -1238,8 +1251,8 @@ describe('#compileIamRole', () => {
12381251
const lambdaArns = [
12391252
{
12401253
'Fn::Sub': [
1241-
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${FunctionName}',
1242-
{ FunctionName: lambda1 },
1254+
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${functionArn}',
1255+
{ functionArn: lambda1 },
12431256
],
12441257
},
12451258
{
@@ -1257,7 +1270,7 @@ describe('#compileIamRole', () => {
12571270
],
12581271
},
12591272
];
1260-
expect(lambdaPermissions[0].Resource).to.deep.eq(lambdaArns);
1273+
expect(lambdaPermissions[0].Resource).to.deep.include.members(lambdaArns);
12611274
});
12621275

12631276
it('should support local function names', () => {
@@ -1305,7 +1318,7 @@ describe('#compileIamRole', () => {
13051318
],
13061319
},
13071320
];
1308-
expect(lambdaPermissions[0].Resource).to.deep.eq(lambdaArns);
1321+
expect(lambdaPermissions[0].Resource).to.deep.include.members(lambdaArns);
13091322
});
13101323

13111324
it('should support local function names for lambda::invoke resource type', () => {
@@ -1356,8 +1369,8 @@ describe('#compileIamRole', () => {
13561369
const lambdaArns = [
13571370
{
13581371
'Fn::Sub': [
1359-
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${FunctionName}',
1360-
{ FunctionName: { Ref: 'HelloDashworldLambdaFunction' } },
1372+
'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${functionArn}',
1373+
{ functionArn: { Ref: 'HelloDashworldLambdaFunction' } },
13611374
],
13621375
},
13631376
{
@@ -1367,7 +1380,7 @@ describe('#compileIamRole', () => {
13671380
],
13681381
},
13691382
];
1370-
expect(lambdaPermissions[0].Resource).to.deep.eq(lambdaArns);
1383+
expect(lambdaPermissions[0].Resource).to.deep.include.members(lambdaArns);
13711384
});
13721385

13731386
it('should give step functions permissions (too permissive, but mirrors console behaviour)', () => {

lib/deploy/stepFunctions/compileStateMachines.js

Lines changed: 26 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
'use strict';
22

33
const _ = require('lodash');
4-
const BbPromise = require('bluebird');
4+
const Joi = require('@hapi/joi');
55
const Chance = require('chance');
6-
const { isIntrinsic, translateLocalFunctionNames } = require('../../utils/aws');
6+
const BbPromise = require('bluebird');
7+
const schema = require('./compileStateMachines.schema');
8+
const { isIntrinsic, translateLocalFunctionNames, convertToFunctionVersion } = require('../../utils/aws');
79

810
const chance = new Chance();
911

@@ -14,22 +16,14 @@ function randomName() {
1416
});
1517
}
1618

17-
function toTags(obj, serverless) {
19+
function toTags(obj) {
1820
const tags = [];
1921

2022
if (!obj) {
2123
return tags;
2224
}
2325

24-
if (_.isPlainObject(obj)) {
25-
_.forEach(
26-
obj,
27-
(Value, Key) => tags.push({ Key, Value: Value.toString() }),
28-
);
29-
} else {
30-
throw new serverless.classes
31-
.Error('Unable to parse tags, it should be an object.');
32-
}
26+
_.forEach(obj, (Value, Key) => tags.push({ Key, Value: Value.toString() }));
3327

3428
return tags;
3529
}
@@ -75,7 +69,15 @@ module.exports = {
7569
let DefinitionString;
7670
let RoleArn;
7771
let DependsOn = [];
78-
const Tags = toTags(this.serverless.service.provider.tags, this.serverless);
72+
const Tags = toTags(this.serverless.service.provider.tags);
73+
74+
const { error } = Joi.validate(stateMachineObj, schema, { allowUnknown: false });
75+
if (error) {
76+
const errorMessage = `State machine [${stateMachineName}] is malformed. `
77+
+ 'Please check the README for more info. '
78+
+ `${error}`;
79+
throw new this.serverless.classes.Error(errorMessage);
80+
}
7981

8082
if (stateMachineObj.definition) {
8183
if (typeof stateMachineObj.definition === 'string') {
@@ -98,38 +100,17 @@ module.exports = {
98100
};
99101
}
100102
}
101-
} else {
102-
const errorMessage = [
103-
`Missing "definition" property in stateMachine ${stateMachineName}`,
104-
' Please check the README for more info.',
105-
].join('');
106-
throw new this.serverless.classes
107-
.Error(errorMessage);
103+
}
104+
105+
if (stateMachineObj.useExactVersion === true && DefinitionString['Fn::Sub']) {
106+
const params = DefinitionString['Fn::Sub'][1];
107+
const f = convertToFunctionVersion.bind(this);
108+
const converted = _.mapValues(params, f);
109+
DefinitionString['Fn::Sub'][1] = converted;
108110
}
109111

110112
if (stateMachineObj.role) {
111-
if (typeof stateMachineObj.role === 'string') {
112-
if (stateMachineObj.role.startsWith('arn:aws')) {
113-
RoleArn = stateMachineObj.role;
114-
} else {
115-
const errorMessage = [
116-
`role property in stateMachine "${stateMachineName}" is not ARN`,
117-
' Please check the README for more info.',
118-
].join('');
119-
throw new this.serverless.classes
120-
.Error(errorMessage);
121-
}
122-
} else if (isIntrinsic(stateMachineObj.role)) {
123-
RoleArn = stateMachineObj.role;
124-
} else {
125-
const errorMessage = [
126-
`role property in stateMachine "${stateMachineName}" is neither a string`,
127-
' nor a CloudFormation intrinsic function',
128-
' Please check the README for more info.',
129-
].join('');
130-
throw new this.serverless.classes
131-
.Error(errorMessage);
132-
}
113+
RoleArn = stateMachineObj.role;
133114
} else {
134115
RoleArn = {
135116
'Fn::GetAtt': [
@@ -143,22 +124,15 @@ module.exports = {
143124
if (stateMachineObj.dependsOn) {
144125
const dependsOn = stateMachineObj.dependsOn;
145126

146-
if (_.isArray(dependsOn) && _.every(dependsOn, _.isString)) {
127+
if (_.isArray(dependsOn)) {
147128
DependsOn = _.concat(DependsOn, dependsOn);
148-
} else if (_.isString(dependsOn)) {
149-
DependsOn.push(dependsOn);
150129
} else {
151-
const errorMessage = [
152-
`dependsOn property in stateMachine "${stateMachineName}" is neither a string`,
153-
' nor an array of strings',
154-
].join('');
155-
throw new this.serverless.classes
156-
.Error(errorMessage);
130+
DependsOn.push(dependsOn);
157131
}
158132
}
159133

160134
if (stateMachineObj.tags) {
161-
const stateMachineTags = toTags(stateMachineObj.tags, this.serverless);
135+
const stateMachineTags = toTags(stateMachineObj.tags);
162136
_.forEach(stateMachineTags, tag => Tags.push(tag));
163137
}
164138

0 commit comments

Comments
 (0)