Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions fixtures/circular-dependency/handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
'use strict';

module.exports.parseCSV = async () => ({ statusCode: 200 });
23 changes: 23 additions & 0 deletions fixtures/circular-dependency/serverless.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
service: integration-circular-dependency

provider: ${file(../base.yml):provider}
plugins: ${file(../base.yml):plugins}
package: ${file(../base.yml):package}
custom: ${file(../base.yml):custom}

functions:
parseCSV:
handler: handler.parseCSV

stepFunctions:
stateMachines:
circularMachine:
id: CircularMachine
name: integration-circular-${opt:stage, 'test'}
definition:
StartAt: Parse
States:
Parse:
Type: Task
Resource: !Ref ParseCSVLambdaFunction
End: true
2 changes: 1 addition & 1 deletion fixtures/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 25 additions & 8 deletions lib/deploy/stepFunctions/compileStateMachines.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ const aslValidator = require('asl-validator');
const BbPromise = require('bluebird');
const crypto = require('node:crypto');
const schema = require('./compileStateMachines.schema');
const { isIntrinsic, translateLocalFunctionNames, convertToFunctionVersion } = require('../../utils/aws');
const {
isIntrinsic, translateLocalFunctionNames, convertToFunctionVersion, resolveLambdaFunctionName,
} = require('../../utils/aws');
const logger = require('../../utils/logger');

function generateSubVariableName(element) {
Expand Down Expand Up @@ -155,14 +157,29 @@ module.exports = {
);
}
});
const params = _.fromPairs(functionMappings.map(([k, v]) => [k, f(v)]));
const params = {};
functionMappings.forEach(([k, v]) => {
const translated = f(v);
const functionName = resolveLambdaFunctionName(translated, this);
if (functionName) {
// Inline a static Fn::Sub ARN directly into the definition string.
// Using { Ref: LambdaFunction } resolves to the function *name* at
// CF evaluation time, not its ARN — Step Functions rejects that.
// A static ARN eliminates the CF resource dependency that causes the
// circular dependency error reported in #470.
const staticArn = `arn:\${AWS::Partition}:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${functionName}`;
processedDefinitionString = processedDefinitionString.replace(
new RegExp(`\\$\\{${k}\\}`, 'g'),
staticArn,
);
} else {
params[k] = translated;
}
});

DefinitionString = {
'Fn::Sub': [
processedDefinitionString,
params,
],
};
DefinitionString = _.isEmpty(params)
? { 'Fn::Sub': processedDefinitionString }
: { 'Fn::Sub': [processedDefinitionString, params] };
}
}
}
Expand Down
49 changes: 49 additions & 0 deletions lib/deploy/stepFunctions/compileStateMachines.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,55 @@ describe('#compileStateMachines', () => {
expect(lambda2Param).to.eql({ 'Fn::GetAtt': ['HelloDashworldLambdaFunction', 'Arn'] });
});

it('should inline a static Lambda ARN when Resource is a Ref to a local function with a known name', () => {
// Reproduces issue #470: `Resource: !Ref parseCSV` compiles to
// `{ Ref: ParseCSVLambdaFunction }` in the Fn::Sub params map.
// CloudFormation resolves a Lambda function Ref to the function *name*
// (e.g. "my-service-dev-parseCSV"), not its ARN — Step Functions then
// rejects the definition with InvalidDefinition. The fix inlines a static
// Fn::Sub ARN directly into the definition string when the function name
// is known at compile time, removing the CF resource reference entirely.
serverless.service.functions = {
parseCSV: {
handler: 'handler.parseCSV',
name: 'my-service-dev-parseCSV',
},
};
serverless.service.stepFunctions = {
stateMachines: {
myStateMachine1: {
id: 'Test',
definition: {
StartAt: 'Parse',
States: {
Parse: {
Type: 'Task',
Resource: { Ref: 'parseCSV' },
End: true,
},
},
},
},
},
};

serverlessStepFunctions.compileStateMachines();
const stateMachine = serverlessStepFunctions.serverless.service
.provider.compiledCloudFormationTemplate.Resources.Test;

const definitionString = stateMachine.Properties.DefinitionString;
const subContent = definitionString['Fn::Sub'];
const definitionJson = Array.isArray(subContent) ? subContent[0] : subContent;
const params = Array.isArray(subContent) ? subContent[1] : {};

// No Ref to the Lambda function — that would create a CF dependency and
// resolve to the function name (not ARN) at CF evaluation time
expect(JSON.stringify(params)).to.not.include('"Ref"');

// The resource should be a static Fn::Sub ARN inlined in the definition string
expect(definitionJson).to.include('arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:my-service-dev-parseCSV');
});

describe('#useExactVersions', () => {
beforeEach(() => {
serverless.service.stepFunctions = {
Expand Down
26 changes: 25 additions & 1 deletion lib/deploy/stepFunctions/iamStrategies/lambda.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
'use strict';

const _ = require('lodash');
const { isIntrinsic, translateLocalFunctionNames, trimAliasFromLambdaArn } = require('../../../utils/aws');
const {
isIntrinsic, translateLocalFunctionNames, trimAliasFromLambdaArn, resolveLambdaFunctionName,
} = require('../../../utils/aws');
const logger = require('../../../utils/logger');
const { isJsonPathParameter, isJsonataArgument, getParameterOrArgument } = require('./utils');

Expand Down Expand Up @@ -59,6 +61,16 @@ function getPermissions(state, { plugin }) {
}

if (_.has(functionName, 'Ref')) {
const resolvedName = resolveLambdaFunctionName(functionName, plugin);
if (resolvedName) {
return [{
action: 'lambda:InvokeFunction',
resource: [
{ 'Fn::Sub': `arn:\${AWS::Partition}:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${resolvedName}` },
{ 'Fn::Sub': `arn:\${AWS::Partition}:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${resolvedName}:*` },
],
}];
}
const functionArn = translateLocalFunctionNames.bind(plugin)(functionName);
return [{
action: 'lambda:InvokeFunction',
Expand Down Expand Up @@ -88,6 +100,18 @@ function getPermissions(state, { plugin }) {
function getFallbackPermissions(state, { plugin }) {
if (isIntrinsic(state.Resource) || !!state.Resource.match(/arn:aws(-[a-z]+)*:lambda/)) {
const trimmedArn = trimAliasFromLambdaArn(state.Resource);

const resolvedName = resolveLambdaFunctionName(trimmedArn, plugin);
if (resolvedName) {
return [{
action: 'lambda:InvokeFunction',
resource: [
{ 'Fn::Sub': `arn:\${AWS::Partition}:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${resolvedName}` },
{ 'Fn::Sub': `arn:\${AWS::Partition}:lambda:\${AWS::Region}:\${AWS::AccountId}:function:${resolvedName}:*` },
],
}];
}

const functionArn = translateLocalFunctionNames.bind(plugin)(trimmedArn);
return [{
action: 'lambda:InvokeFunction',
Expand Down
59 changes: 57 additions & 2 deletions lib/deploy/stepFunctions/iamStrategies/lambda.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ describe('lambda strategy — getPermissions (lambda:invoke resource type)', ()
expect(result[0].resource).to.deep.include(lambda3);
});

it('should translate local function name Ref to logical resource ID', () => {
it('should translate local function name Ref to logical resource ID when name is not known', () => {
const lambda1 = { Ref: 'hello-world' };
const state = {
Type: 'Task',
Expand All @@ -170,6 +170,26 @@ describe('lambda strategy — getPermissions (lambda:invoke resource type)', ()
});
});

it('should use a static Fn::Sub ARN (no Ref) when FunctionName is a Ref to a local function with a known name', () => {
// Same circular dependency risk as #470 but via the lambda:invoke SDK integration
// path: { Ref: LambdaFunction } in the Fn::Sub substitution map still creates a CF
// dependency. When the function name is known at compile time, use a static string.
const state = {
Type: 'Task',
Resource: 'arn:aws:states:::lambda:invoke',
Parameters: {
FunctionName: { Ref: 'hello-world' },
},
};
const plugin = makePlugin({ 'hello-world': { handler: 'hello-world.handler', name: 'my-service-dev-hello-world' } });
const result = getPermissions(state, { plugin });
expect(result[0].action).to.equal('lambda:InvokeFunction');
expect(JSON.stringify(result[0].resource)).to.not.include('"Ref"');
expect(result[0].resource).to.deep.include({
'Fn::Sub': 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:my-service-dev-hello-world',
});
});

it('should translate local function name Fn::GetAtt to logical resource ID', () => {
const lambda2 = { 'Fn::GetAtt': ['hello-world', 'Arn'] };
const state = {
Expand Down Expand Up @@ -288,7 +308,7 @@ describe('lambda strategy — getFallbackPermissions (direct lambda ARN task res
expect(result[0].resource).to.include(baseArn);
});

it('should give lambda:InvokeFunction for an intrinsic function resource (Ref)', () => {
it('should give lambda:InvokeFunction for an intrinsic function resource (Ref to external resource)', () => {
const state = {
Type: 'Task',
Resource: { Ref: 'MyFunction' },
Expand All @@ -298,6 +318,41 @@ describe('lambda strategy — getFallbackPermissions (direct lambda ARN task res
expect(result[0].action).to.equal('lambda:InvokeFunction');
});

it('should use a static Fn::Sub ARN (no Ref) when Resource is a Ref to a local function with a known name', () => {
// Reproduces issue #470: using { Ref: localFunction } in the IAM policy resource
// creates a CloudFormation dependency StateMachineRole → Lambda. If the Lambda has
// an env var referencing the state machine ARN, CloudFormation rejects the template
// with a circular dependency error. The fix: resolve the function name at compile
// time and emit a static Fn::Sub string so no CF resource reference is introduced.
const state = {
Type: 'Task',
Resource: { Ref: 'parseCSV' },
};
const plugin = makePlugin({ parseCSV: { handler: 'handler.parseCSV', name: 'my-service-dev-parseCSV' } });
const result = getFallbackPermissions(state, { plugin });
expect(result[0].action).to.equal('lambda:InvokeFunction');
expect(JSON.stringify(result[0].resource)).to.not.include('"Ref"');
expect(result[0].resource).to.deep.include({
'Fn::Sub': 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:my-service-dev-parseCSV',
});
expect(result[0].resource).to.deep.include({
'Fn::Sub': 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:my-service-dev-parseCSV:*',
});
});

it('should use a static Fn::Sub ARN when Resource is the CloudFormation logical ID of a local function', () => {
const state = {
Type: 'Task',
Resource: { Ref: 'ParseCSVLambdaFunction' },
};
const plugin = makePlugin({ parseCSV: { handler: 'handler.parseCSV', name: 'my-service-dev-parseCSV' } });
const result = getFallbackPermissions(state, { plugin });
expect(JSON.stringify(result[0].resource)).to.not.include('"Ref"');
expect(result[0].resource).to.deep.include({
'Fn::Sub': 'arn:${AWS::Partition}:lambda:${AWS::Region}:${AWS::AccountId}:function:my-service-dev-parseCSV',
});
});

it('should return [] for a non-lambda, non-intrinsic resource', () => {
const state = {
Type: 'Task',
Expand Down
32 changes: 32 additions & 0 deletions lib/utils/aws.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,37 @@ function convertToFunctionVersion(value) {
return value;
}

// Resolve a Ref to a local Lambda function to its deployed function name string.
// Returns the name if resolvable, null otherwise.
//
// Using { Ref: LambdaFunctionLogicalId } in an IAM policy resource or state
// machine definition creates a hard CloudFormation dependency and resolves to
// the function *name* (not ARN) at CF evaluation time. IAM rejects non-ARN
// resources; Step Functions rejects non-ARN task resources. Resolving to a
// static name string allows callers to construct a proper Fn::Sub ARN without
// any CF resource reference, eliminating both problems.
function resolveLambdaFunctionName(ref, plugin) {
if (!_.has(ref, 'Ref')) return null;

const refValue = ref.Ref;
const functions = _.get(plugin, 'serverless.service.functions') || {};

// Case 1: Ref is the Serverless function key directly (e.g. 'parseCSV')
let fnKey = _.has(functions, refValue) ? refValue : null;

// Case 2: Ref is the CloudFormation logical ID (e.g. 'ParseCSVLambdaFunction')
if (!fnKey) {
fnKey = Object.keys(functions).find(
(key) => plugin.provider.naming.getLambdaLogicalId(key) === refValue,
) || null;
}

if (!fnKey) return null;

const name = _.get(functions, [fnKey, 'name']);
return typeof name === 'string' ? name : null;
}

// If the resource is a lambda ARN string, trim off the alias
function trimAliasFromLambdaArn(resource) {
if (typeof resource === 'string' && !!resource.match(/^arn:aws(-[a-z]+)*:lambda/)) {
Expand All @@ -94,4 +125,5 @@ module.exports = {
translateLocalFunctionNames,
convertToFunctionVersion,
trimAliasFromLambdaArn,
resolveLambdaFunctionName,
};
Loading