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/use-exact-version/handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
'use strict';

module.exports.hello = async () => ({ statusCode: 200 });
5 changes: 5 additions & 0 deletions fixtures/use-exact-version/layers/utils/nodejs/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
'use strict';

// Minimal layer placeholder — content is not exercised by the integration test.
// The layer's presence in serverless.yml is what triggers the useExactVersion
// layer-aware version resource behaviour validated by verify.test.js.
32 changes: 32 additions & 0 deletions fixtures/use-exact-version/serverless.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
service: integration-use-exact-version

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

layers:
utils:
path: layers/utils
compatibleRuntimes:
- nodejs22.x

functions:
hello:
handler: handler.hello
layers:
- Ref: UtilsLambdaLayer

stepFunctions:
stateMachines:
exactVersionMachine:
name: integration-exact-version-${opt:stage, 'test'}
useExactVersion: true
definition:
StartAt: InvokeHello
States:
InvokeHello:
Type: Task
Resource:
Fn::GetAtt: [HelloLambdaFunction, Arn]
End: true
85 changes: 85 additions & 0 deletions fixtures/use-exact-version/verify.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');
const expect = require('chai').expect;

const templatePath = path.join(__dirname, '.serverless', 'cloudformation-template-update-stack.json');

describe('use-exact-version fixture — CloudFormation template', () => {
let resources;

before(() => {
const template = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
resources = template.Resources;
});

it('should create a layer-aware AWS::Lambda::Version resource for the function with layers', () => {
// When useExactVersion: true and the function has layers, the plugin creates a new
// AWS::Lambda::Version resource whose logical ID incorporates a hash of the layer
// version logical IDs. This ensures a new version is published (and the state
// machine DefinitionString changes) whenever layers are updated — even if the
// function's own code artifact is unchanged.
const layerAwareVersionLogicalId = Object.keys(resources).find(
(k) => resources[k].Type === 'AWS::Lambda::Version'
&& /L[0-9a-f]{8}$/.test(k)
&& resources[k].Properties.FunctionName.Ref === 'HelloLambdaFunction',
);

expect(
layerAwareVersionLogicalId,
'should have a layer-aware AWS::Lambda::Version resource (logical ID ending in L + 8 hex chars)',
).to.not.equal(undefined);
});

it('should reference the layer-aware version (not the plain SF version) in the state machine definition', () => {
// Find the layer-aware version resource logical ID.
const layerAwareVersionLogicalId = Object.keys(resources).find(
(k) => resources[k].Type === 'AWS::Lambda::Version'
&& /L[0-9a-f]{8}$/.test(k)
&& resources[k].Properties.FunctionName.Ref === 'HelloLambdaFunction',
);
expect(layerAwareVersionLogicalId, 'layer-aware version resource must exist').to.not.equal(undefined);

// Find the state machine resource.
const stateMachine = Object.values(resources).find(
(r) => r.Type === 'AWS::StepFunctions::StateMachine',
);
expect(stateMachine, 'state machine must exist').to.not.equal(undefined);

const { DefinitionString } = stateMachine.Properties;
expect(DefinitionString, 'DefinitionString must be an Fn::Sub').to.haveOwnProperty('Fn::Sub');
expect(Array.isArray(DefinitionString['Fn::Sub']), 'Fn::Sub must be array form').to.equal(true);

const [, params] = DefinitionString['Fn::Sub'];
const referencedVersionLogicalIds = Object.values(params)
.filter((v) => v && v.Ref)
.map((v) => v.Ref)
.filter((ref) => resources[ref] && resources[ref].Type === 'AWS::Lambda::Version');

expect(
referencedVersionLogicalIds,
'state machine Fn::Sub params must reference at least one Lambda version',
).to.have.length.greaterThan(0);

expect(
referencedVersionLogicalIds,
'state machine must reference the layer-aware version, not the plain SF version',
).to.include(layerAwareVersionLogicalId);

// The plain SF-generated version (without layer hash suffix) must NOT be directly
// referenced by the state machine — the layer-aware one replaces it.
const plainSFVersionLogicalId = layerAwareVersionLogicalId.replace(/L[0-9a-f]{8}$/, '');
expect(
referencedVersionLogicalIds,
'state machine must not reference the plain SF version directly',
).to.not.include(plainSFVersionLogicalId);
});

it('should have the AWS::Lambda::LayerVersion resource for the utils layer', () => {
const layerVersion = Object.values(resources).find(
(r) => r.Type === 'AWS::Lambda::LayerVersion',
);
expect(layerVersion, 'utils layer version resource must exist').to.not.equal(undefined);
});
});
44 changes: 44 additions & 0 deletions lib/deploy/stepFunctions/compileStateMachines.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,50 @@ module.exports = {
const params = DefinitionString['Fn::Sub'][1];
const f = convertToFunctionVersion.bind(this);
const converted = _.mapValues(params, f);

// When a Lambda function's layers change, the function code hash (and
// therefore the Serverless-generated version logical ID) stays the same,
// so CloudFormation sees no definition change and skips the state machine
// update. To fix this, we create a layer-hash-aware AWS::Lambda::Version
// resource whose logical ID incorporates a hash of the function's layer
// version logical IDs. When layers change, the hash changes, a new version
// resource is created (publishing a new Lambda version), and the state
// machine DefinitionString changes so CloudFormation updates it.
const resources = this.serverless.service.provider.compiledCloudFormationTemplate
.Resources;
Object.keys(converted).forEach((paramKey) => {
const paramValue = converted[paramKey];
if (!_.has(paramValue, 'Ref')) return;

const sfVersionLogicalId = paramValue.Ref;
const sfVersionResource = resources[sfVersionLogicalId];
if (!sfVersionResource || sfVersionResource.Type !== 'AWS::Lambda::Version') return;

const functionLogicalId = _.get(sfVersionResource, 'Properties.FunctionName.Ref');
if (!functionLogicalId) return;

const functionResource = resources[functionLogicalId];
const layers = _.get(functionResource, 'Properties.Layers', []);
if (layers.length === 0) return;
// Serialize each layer entry to a stable string regardless of form:
// {Ref: 'LogicalId'}, 'arn:...', {Fn::Sub: '...'}, {Fn::ImportValue: '...'}, etc.
const layerRefs = layers
.map((l) => (typeof l === 'string' ? l : JSON.stringify(l)))
.sort();

const layerHash = crypto
.createHash('md5')
.update(layerRefs.join(','))
.digest('hex')
.slice(0, 8);

const layerAwareVersionLogicalId = `${sfVersionLogicalId}L${layerHash}`;
if (!resources[layerAwareVersionLogicalId]) {
resources[layerAwareVersionLogicalId] = _.cloneDeep(sfVersionResource);
}
converted[paramKey] = { Ref: layerAwareVersionLogicalId };
});

DefinitionString['Fn::Sub'][1] = converted;
}

Expand Down
193 changes: 193 additions & 0 deletions lib/deploy/stepFunctions/compileStateMachines.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,199 @@ describe('#compileStateMachines', () => {

expect(topicArn).to.deep.equal({ Ref: 'MyTopic' });
});

it('should create a layer-aware version resource and reference it when function has layers', () => {
serverlessStepFunctions.serverless.service
.provider.compiledCloudFormationTemplate.Resources
.Lambda1Version13579 = {
Type: 'AWS::Lambda::Version',
Properties: {
FunctionName: {
Ref: 'HelloLambdaFunction',
},
},
};

serverlessStepFunctions.serverless.service
.provider.compiledCloudFormationTemplate.Resources
.HelloLambdaFunction.Properties = {
Layers: [{ Ref: 'MyLayerVersion1' }],
};

const { lambda1Param } = compileStateMachines();

// The param should reference a layer-aware version, not the original SF version.
expect(lambda1Param).to.haveOwnProperty('Ref');
expect(lambda1Param.Ref).to.not.equal('Lambda1Version13579');
expect(lambda1Param.Ref).to.match(/^Lambda1Version13579L[0-9a-f]{8}$/);

// A new layer-aware version resource should exist in the CF template.
const resources = serverlessStepFunctions.serverless.service
.provider.compiledCloudFormationTemplate.Resources;
expect(resources).to.haveOwnProperty(lambda1Param.Ref);
expect(resources[lambda1Param.Ref].Type).to.equal('AWS::Lambda::Version');
expect(resources[lambda1Param.Ref].Properties.FunctionName).to.deep.equal({
Ref: 'HelloLambdaFunction',
});
});

it('should reference different layer-aware version resources when layers change', () => {
// Helper to compile with a specific layer version and return the version ref
// used in the state machine params for the HelloLambdaFunction.
const compileWithLayer = (layerLogicalId) => {
const sl = createServerless();
sl.service.provider.compiledCloudFormationTemplate = { Resources: {}, Outputs: {} };
sl.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' },
},
End: true,
},
},
},
},
},
};
sl.service.provider.compiledCloudFormationTemplate.Resources.HelloLambdaFunction = {
Type: 'AWS::Lambda::Function',
Properties: { Layers: [{ Ref: layerLogicalId }] },
};
sl.service.provider.compiledCloudFormationTemplate.Resources.Lambda1Version13579 = {
Type: 'AWS::Lambda::Version',
Properties: { FunctionName: { Ref: 'HelloLambdaFunction' } },
};

const plugin = new ServerlessStepFunctions(sl);
plugin.compileStateMachines();

const resources = sl.service.provider.compiledCloudFormationTemplate.Resources;
const stateMachine = resources.Test;
const [, params] = stateMachine.Properties.DefinitionString['Fn::Sub'];
const layerAwareKey = Object.keys(params).find(
(k) => params[k] && params[k].Ref && params[k].Ref.startsWith('Lambda1Version13579L'),
);
return { versionRef: params[layerAwareKey].Ref, resources };
};

const { versionRef: versionRefV1 } = compileWithLayer('MyLayerVersion1');
const { versionRef: versionRefV2, resources: resourcesV2 } = compileWithLayer('MyLayerVersion2');

// Different layers → different layer-aware version resource logical IDs.
expect(versionRefV1).to.not.equal(versionRefV2);
expect(resourcesV2).to.haveOwnProperty(versionRefV2);
});

it('should create a layer-aware version resource when function has ARN string layers', () => {
serverlessStepFunctions.serverless.service
.provider.compiledCloudFormationTemplate.Resources
.Lambda1Version13579 = {
Type: 'AWS::Lambda::Version',
Properties: {
FunctionName: { Ref: 'HelloLambdaFunction' },
},
};

serverlessStepFunctions.serverless.service
.provider.compiledCloudFormationTemplate.Resources
.HelloLambdaFunction.Properties = {
Layers: ['arn:aws:lambda:us-east-1:123456789012:layer:my-layer:5'],
};

const { lambda1Param } = compileStateMachines();

expect(lambda1Param).to.haveOwnProperty('Ref');
expect(lambda1Param.Ref).to.match(/^Lambda1Version13579L[0-9a-f]{8}$/);
});

it('should produce different layer hashes for different ARN string layer versions', () => {
const compileWithArnLayer = (layerArn) => {
const sl = createServerless();
sl.service.provider.compiledCloudFormationTemplate = { Resources: {}, Outputs: {} };
sl.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' },
},
End: true,
},
},
},
},
},
};
sl.service.provider.compiledCloudFormationTemplate.Resources.HelloLambdaFunction = {
Type: 'AWS::Lambda::Function',
Properties: { Layers: [layerArn] },
};
sl.service.provider.compiledCloudFormationTemplate.Resources.Lambda1Version13579 = {
Type: 'AWS::Lambda::Version',
Properties: { FunctionName: { Ref: 'HelloLambdaFunction' } },
};

const plugin = new ServerlessStepFunctions(sl);
plugin.compileStateMachines();

const resources = sl.service.provider.compiledCloudFormationTemplate.Resources;
const stateMachine = resources.Test;
const [, params] = stateMachine.Properties.DefinitionString['Fn::Sub'];
const layerAwareKey = Object.keys(params).find(
(k) => params[k] && params[k].Ref && params[k].Ref.startsWith('Lambda1Version13579L'),
);
return { versionRef: params[layerAwareKey].Ref };
};

const { versionRef: v1 } = compileWithArnLayer('arn:aws:lambda:us-east-1:123:layer:my-layer:5');
const { versionRef: v2 } = compileWithArnLayer('arn:aws:lambda:us-east-1:123:layer:my-layer:6');

expect(v1).to.not.equal(v2);
});

it('should not create a layer-aware version resource when function has no layers', () => {
serverlessStepFunctions.serverless.service
.provider.compiledCloudFormationTemplate.Resources
.Lambda1Version13579 = {
Type: 'AWS::Lambda::Version',
Properties: {
FunctionName: { Ref: 'HelloLambdaFunction' },
},
};

// Function has no Layers property
serverlessStepFunctions.serverless.service
.provider.compiledCloudFormationTemplate.Resources
.HelloLambdaFunction.Properties = {};

const { lambda1Param } = compileStateMachines();

// With no layers, should still reference the original SF version resource.
expect(lambda1Param).to.deep.equal({ Ref: 'Lambda1Version13579' });

const resources = serverlessStepFunctions.serverless.service
.provider.compiledCloudFormationTemplate.Resources;
const layerAwareKeys = Object.keys(resources).filter((k) => k.includes('13579L'));
expect(layerAwareKeys).to.have.lengthOf(0);
});
});

it('should not validate definition if not enabled', () => {
Expand Down
Loading