Skip to content

Commit e967f04

Browse files
feat: support referencing exact version
Closes #240
1 parent e1dc3b8 commit e967f04

File tree

6 files changed

+210
-34
lines changed

6 files changed

+210
-34
lines changed

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

@@ -278,9 +298,13 @@ function getIamPermissions(taskStates) {
278298

279299
default:
280300
if (isIntrinsic(state.Resource) || state.Resource.startsWith('arn:aws:lambda')) {
301+
const functionArn = translateLocalFunctionNames.bind(this)(state.Resource);
281302
return [{
282303
action: 'lambda:InvokeFunction',
283-
resource: translateLocalFunctionNames.bind(this)(state.Resource),
304+
resource: [
305+
functionArn,
306+
{ 'Fn::Sub': ['${functionArn}:*', { functionArn }] },
307+
],
284308
}];
285309
}
286310
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,6 +1380,6 @@ 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
});

lib/deploy/stepFunctions/compileStateMachines.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const Joi = require('@hapi/joi');
55
const Chance = require('chance');
66
const BbPromise = require('bluebird');
77
const schema = require('./compileStateMachines.schema');
8-
const { isIntrinsic, translateLocalFunctionNames } = require('../../utils/aws');
8+
const { isIntrinsic, translateLocalFunctionNames, convertToFunctionVersion } = require('../../utils/aws');
99

1010
const chance = new Chance();
1111

@@ -102,6 +102,13 @@ module.exports = {
102102
}
103103
}
104104

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;
110+
}
111+
105112
if (stateMachineObj.role) {
106113
RoleArn = stateMachineObj.role;
107114
} else {

lib/deploy/stepFunctions/compileStateMachines.schema.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ const name = Joi.string();
2626
const events = Joi.array();
2727
const alarms = Joi.object();
2828
const notifications = Joi.object();
29+
const useExactVersion = Joi.boolean().default(false);
2930

3031
const schema = Joi.object().keys({
3132
id,
3233
events,
3334
name,
3435
role: arn,
36+
useExactVersion,
3537
definition: definition.required(),
3638
dependsOn,
3739
tags,

lib/deploy/stepFunctions/compileStateMachines.test.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,4 +848,99 @@ describe('#compileStateMachines', () => {
848848
const lambda2Param = params[lambda2ParamName];
849849
expect(lambda2Param).to.eql({ 'Fn::GetAtt': ['HelloDashworldLambdaFunction', 'Arn'] });
850850
});
851+
852+
it('should support using exact versions of functions', () => {
853+
serverless.service.stepFunctions = {
854+
stateMachines: {
855+
myStateMachine1: {
856+
id: 'Test',
857+
useExactVersion: true,
858+
definition: {
859+
StartAt: 'Lambda1',
860+
States: {
861+
Lambda1: {
862+
Type: 'Task',
863+
Resource: 'arn:aws:states:::lambda:invoke',
864+
Parameters: {
865+
FunctionName: {
866+
Ref: 'HelloLambdaFunction',
867+
},
868+
Payload: {
869+
'ExecutionName.$': '$$.Execution.Name',
870+
},
871+
},
872+
Next: 'Lambda2',
873+
},
874+
Lambda2: {
875+
Type: 'Task',
876+
Resource: {
877+
'Fn::GetAtt': ['WorldLambdaFunction', 'Arn'],
878+
},
879+
End: true,
880+
},
881+
},
882+
},
883+
},
884+
},
885+
};
886+
887+
serverlessStepFunctions.serverless.service
888+
.provider.compiledCloudFormationTemplate.Resources
889+
.HelloLambdaFunction = {
890+
Type: 'AWS::Lambda::Function',
891+
};
892+
893+
serverlessStepFunctions.serverless.service
894+
.provider.compiledCloudFormationTemplate.Resources
895+
.Lambda1Version13579 = {
896+
Type: 'AWS::Lambda::Version',
897+
Properties: {
898+
FunctionName: {
899+
Ref: 'HelloLambdaFunction',
900+
},
901+
},
902+
};
903+
904+
serverlessStepFunctions.serverless.service
905+
.provider.compiledCloudFormationTemplate.Resources
906+
.WorldLambdaFunction = {
907+
Type: 'AWS::Lambda::Function',
908+
};
909+
910+
serverlessStepFunctions.serverless.service
911+
.provider.compiledCloudFormationTemplate.Resources
912+
.Lambda2Version24680 = {
913+
Type: 'AWS::Lambda::Version',
914+
Properties: {
915+
FunctionName: {
916+
Ref: 'WorldLambdaFunction',
917+
},
918+
},
919+
};
920+
921+
serverlessStepFunctions.compileStateMachines();
922+
const stateMachine = serverlessStepFunctions.serverless.service
923+
.provider.compiledCloudFormationTemplate.Resources
924+
.Test;
925+
926+
expect(stateMachine.Properties.DefinitionString).to.haveOwnProperty('Fn::Sub');
927+
expect(stateMachine.Properties.DefinitionString['Fn::Sub']).to.have.lengthOf(2);
928+
929+
const [json, params] = stateMachine.Properties.DefinitionString['Fn::Sub'];
930+
const modifiedDefinition = JSON.parse(json);
931+
932+
const lambda1 = modifiedDefinition.States.Lambda1;
933+
expect(lambda1.Parameters.FunctionName.startsWith('${')).to.eq(true);
934+
const lambda1ParamName = lambda1.Parameters.FunctionName.replace(/[${}]/g, '');
935+
expect(params).to.haveOwnProperty(lambda1ParamName);
936+
const lambda1Param = params[lambda1ParamName];
937+
expect(lambda1Param).to.eql({ Ref: 'Lambda1Version13579' });
938+
939+
const lambda2 = modifiedDefinition.States.Lambda2;
940+
expect(lambda2.Resource.startsWith('${')).to.eq(true);
941+
const lambda2ParamName = lambda2.Resource.replace(/[${}]/g, '');
942+
expect(params).to.haveOwnProperty(lambda2ParamName);
943+
const lambda2Param = params[lambda2ParamName];
944+
expect(lambda2Param).to.eql({ Ref: 'Lambda2Version24680' });
945+
});
851946
});

lib/utils/aws.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,42 @@ function translateLocalFunctionNames(value) {
3737
return value;
3838
}
3939

40+
// converts a reference to a function to a reference to a function version
41+
function convertToFunctionVersion(value) {
42+
const resources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources;
43+
const versions = Object.keys(resources) // [ [logicalId, version] ]
44+
.filter(logicalId => resources[logicalId].Type === 'AWS::Lambda::Version')
45+
.map(logicalId => [logicalId, resources[logicalId]]);
46+
47+
const isFunction = logicalId => _.has(resources, logicalId) && resources[logicalId].Type === 'AWS::Lambda::Function';
48+
const getVersion = (logicalId) => {
49+
const version = versions.find(x => _.get(x[1], 'Properties.FunctionName.Ref') === logicalId);
50+
if (version) {
51+
return version[0];
52+
}
53+
54+
return logicalId;
55+
};
56+
57+
if (_.has(value, 'Ref') && isFunction(value.Ref)) {
58+
return {
59+
Ref: getVersion(value.Ref),
60+
};
61+
}
62+
63+
// for Lambda function, Get::Att can only return the ARN
64+
// but for Lambda version, you need Ref to get its ARN, hence why we return Ref here
65+
if (_.has(value, 'Fn::GetAtt') && isFunction(value['Fn::GetAtt'][0])) {
66+
return {
67+
Ref: getVersion(value['Fn::GetAtt'][0]),
68+
};
69+
}
70+
71+
return value;
72+
}
73+
4074
module.exports = {
4175
isIntrinsic,
4276
translateLocalFunctionNames,
77+
convertToFunctionVersion,
4378
};

0 commit comments

Comments
 (0)