Skip to content

Commit 2ac08cf

Browse files
VirtueMeclaude
andauthored
fix(stateMachine): create layer-aware version when useExactVersion is true (#452) (#757)
When useExactVersion: true, the plugin resolves Lambda refs to a specific AWS::Lambda::Version resource. That resource's logical ID is generated by Serverless Framework from the function's code artifact hash — so when only a layer changes (same function code), the hash stays the same, no new version is published, and the state machine definition is unchanged. CloudFormation sees no diff and skips the update, leaving the step function invoking the stale Lambda version. Fix: after converting function refs to version refs, inspect the function's Layers property. If layers are present, compute an MD5 hash over all layer entries (Ref logical IDs, ARN strings, or any other intrinsic form) and create a new AWS::Lambda::Version resource whose logical ID embeds that hash. When layers change the hash changes, a new resource is created, CloudFormation publishes a new Lambda version, and the state machine DefinitionString changes so CloudFormation updates the state machine. - Handles all layer reference forms: {Ref: logicalId}, plain ARN strings, and any other intrinsic ({Fn::Sub}, {Fn::ImportValue}, etc.) - Functions without layers are unaffected - Add five unit tests covering: Ref layers, Ref layer version change, ARN string layers, ARN version change, and no-op with no layers - Add integration fixture use-exact-version with a layer and verify.test.js asserting the layer-aware version resource and state machine reference appear in the compiled CloudFormation template Part of #452 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ebb7304 commit 2ac08cf

File tree

6 files changed

+362
-0
lines changed

6 files changed

+362
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
'use strict';
2+
3+
module.exports.hello = async () => ({ statusCode: 200 });
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use strict';
2+
3+
// Minimal layer placeholder — content is not exercised by the integration test.
4+
// The layer's presence in serverless.yml is what triggers the useExactVersion
5+
// layer-aware version resource behaviour validated by verify.test.js.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
service: integration-use-exact-version
2+
3+
provider: ${file(../base.yml):provider}
4+
plugins: ${file(../base.yml):plugins}
5+
package: ${file(../base.yml):package}
6+
custom: ${file(../base.yml):custom}
7+
8+
layers:
9+
utils:
10+
path: layers/utils
11+
compatibleRuntimes:
12+
- nodejs22.x
13+
14+
functions:
15+
hello:
16+
handler: handler.hello
17+
layers:
18+
- Ref: UtilsLambdaLayer
19+
20+
stepFunctions:
21+
stateMachines:
22+
exactVersionMachine:
23+
name: integration-exact-version-${opt:stage, 'test'}
24+
useExactVersion: true
25+
definition:
26+
StartAt: InvokeHello
27+
States:
28+
InvokeHello:
29+
Type: Task
30+
Resource:
31+
Fn::GetAtt: [HelloLambdaFunction, Arn]
32+
End: true
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
const path = require('node:path');
5+
const expect = require('chai').expect;
6+
7+
const templatePath = path.join(__dirname, '.serverless', 'cloudformation-template-update-stack.json');
8+
9+
describe('use-exact-version fixture — CloudFormation template', () => {
10+
let resources;
11+
12+
before(() => {
13+
const template = JSON.parse(fs.readFileSync(templatePath, 'utf8'));
14+
resources = template.Resources;
15+
});
16+
17+
it('should create a layer-aware AWS::Lambda::Version resource for the function with layers', () => {
18+
// When useExactVersion: true and the function has layers, the plugin creates a new
19+
// AWS::Lambda::Version resource whose logical ID incorporates a hash of the layer
20+
// version logical IDs. This ensures a new version is published (and the state
21+
// machine DefinitionString changes) whenever layers are updated — even if the
22+
// function's own code artifact is unchanged.
23+
const layerAwareVersionLogicalId = Object.keys(resources).find(
24+
(k) => resources[k].Type === 'AWS::Lambda::Version'
25+
&& /L[0-9a-f]{8}$/.test(k)
26+
&& resources[k].Properties.FunctionName.Ref === 'HelloLambdaFunction',
27+
);
28+
29+
expect(
30+
layerAwareVersionLogicalId,
31+
'should have a layer-aware AWS::Lambda::Version resource (logical ID ending in L + 8 hex chars)',
32+
).to.not.equal(undefined);
33+
});
34+
35+
it('should reference the layer-aware version (not the plain SF version) in the state machine definition', () => {
36+
// Find the layer-aware version resource logical ID.
37+
const layerAwareVersionLogicalId = Object.keys(resources).find(
38+
(k) => resources[k].Type === 'AWS::Lambda::Version'
39+
&& /L[0-9a-f]{8}$/.test(k)
40+
&& resources[k].Properties.FunctionName.Ref === 'HelloLambdaFunction',
41+
);
42+
expect(layerAwareVersionLogicalId, 'layer-aware version resource must exist').to.not.equal(undefined);
43+
44+
// Find the state machine resource.
45+
const stateMachine = Object.values(resources).find(
46+
(r) => r.Type === 'AWS::StepFunctions::StateMachine',
47+
);
48+
expect(stateMachine, 'state machine must exist').to.not.equal(undefined);
49+
50+
const { DefinitionString } = stateMachine.Properties;
51+
expect(DefinitionString, 'DefinitionString must be an Fn::Sub').to.haveOwnProperty('Fn::Sub');
52+
expect(Array.isArray(DefinitionString['Fn::Sub']), 'Fn::Sub must be array form').to.equal(true);
53+
54+
const [, params] = DefinitionString['Fn::Sub'];
55+
const referencedVersionLogicalIds = Object.values(params)
56+
.filter((v) => v && v.Ref)
57+
.map((v) => v.Ref)
58+
.filter((ref) => resources[ref] && resources[ref].Type === 'AWS::Lambda::Version');
59+
60+
expect(
61+
referencedVersionLogicalIds,
62+
'state machine Fn::Sub params must reference at least one Lambda version',
63+
).to.have.length.greaterThan(0);
64+
65+
expect(
66+
referencedVersionLogicalIds,
67+
'state machine must reference the layer-aware version, not the plain SF version',
68+
).to.include(layerAwareVersionLogicalId);
69+
70+
// The plain SF-generated version (without layer hash suffix) must NOT be directly
71+
// referenced by the state machine — the layer-aware one replaces it.
72+
const plainSFVersionLogicalId = layerAwareVersionLogicalId.replace(/L[0-9a-f]{8}$/, '');
73+
expect(
74+
referencedVersionLogicalIds,
75+
'state machine must not reference the plain SF version directly',
76+
).to.not.include(plainSFVersionLogicalId);
77+
});
78+
79+
it('should have the AWS::Lambda::LayerVersion resource for the utils layer', () => {
80+
const layerVersion = Object.values(resources).find(
81+
(r) => r.Type === 'AWS::Lambda::LayerVersion',
82+
);
83+
expect(layerVersion, 'utils layer version resource must exist').to.not.equal(undefined);
84+
});
85+
});

lib/deploy/stepFunctions/compileStateMachines.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,50 @@ module.exports = {
190190
const params = DefinitionString['Fn::Sub'][1];
191191
const f = convertToFunctionVersion.bind(this);
192192
const converted = _.mapValues(params, f);
193+
194+
// When a Lambda function's layers change, the function code hash (and
195+
// therefore the Serverless-generated version logical ID) stays the same,
196+
// so CloudFormation sees no definition change and skips the state machine
197+
// update. To fix this, we create a layer-hash-aware AWS::Lambda::Version
198+
// resource whose logical ID incorporates a hash of the function's layer
199+
// version logical IDs. When layers change, the hash changes, a new version
200+
// resource is created (publishing a new Lambda version), and the state
201+
// machine DefinitionString changes so CloudFormation updates it.
202+
const resources = this.serverless.service.provider.compiledCloudFormationTemplate
203+
.Resources;
204+
Object.keys(converted).forEach((paramKey) => {
205+
const paramValue = converted[paramKey];
206+
if (!_.has(paramValue, 'Ref')) return;
207+
208+
const sfVersionLogicalId = paramValue.Ref;
209+
const sfVersionResource = resources[sfVersionLogicalId];
210+
if (!sfVersionResource || sfVersionResource.Type !== 'AWS::Lambda::Version') return;
211+
212+
const functionLogicalId = _.get(sfVersionResource, 'Properties.FunctionName.Ref');
213+
if (!functionLogicalId) return;
214+
215+
const functionResource = resources[functionLogicalId];
216+
const layers = _.get(functionResource, 'Properties.Layers', []);
217+
if (layers.length === 0) return;
218+
// Serialize each layer entry to a stable string regardless of form:
219+
// {Ref: 'LogicalId'}, 'arn:...', {Fn::Sub: '...'}, {Fn::ImportValue: '...'}, etc.
220+
const layerRefs = layers
221+
.map((l) => (typeof l === 'string' ? l : JSON.stringify(l)))
222+
.sort();
223+
224+
const layerHash = crypto
225+
.createHash('md5')
226+
.update(layerRefs.join(','))
227+
.digest('hex')
228+
.slice(0, 8);
229+
230+
const layerAwareVersionLogicalId = `${sfVersionLogicalId}L${layerHash}`;
231+
if (!resources[layerAwareVersionLogicalId]) {
232+
resources[layerAwareVersionLogicalId] = _.cloneDeep(sfVersionResource);
233+
}
234+
converted[paramKey] = { Ref: layerAwareVersionLogicalId };
235+
});
236+
193237
DefinitionString['Fn::Sub'][1] = converted;
194238
}
195239

lib/deploy/stepFunctions/compileStateMachines.test.js

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,6 +1409,199 @@ describe('#compileStateMachines', () => {
14091409

14101410
expect(topicArn).to.deep.equal({ Ref: 'MyTopic' });
14111411
});
1412+
1413+
it('should create a layer-aware version resource and reference it when function has layers', () => {
1414+
serverlessStepFunctions.serverless.service
1415+
.provider.compiledCloudFormationTemplate.Resources
1416+
.Lambda1Version13579 = {
1417+
Type: 'AWS::Lambda::Version',
1418+
Properties: {
1419+
FunctionName: {
1420+
Ref: 'HelloLambdaFunction',
1421+
},
1422+
},
1423+
};
1424+
1425+
serverlessStepFunctions.serverless.service
1426+
.provider.compiledCloudFormationTemplate.Resources
1427+
.HelloLambdaFunction.Properties = {
1428+
Layers: [{ Ref: 'MyLayerVersion1' }],
1429+
};
1430+
1431+
const { lambda1Param } = compileStateMachines();
1432+
1433+
// The param should reference a layer-aware version, not the original SF version.
1434+
expect(lambda1Param).to.haveOwnProperty('Ref');
1435+
expect(lambda1Param.Ref).to.not.equal('Lambda1Version13579');
1436+
expect(lambda1Param.Ref).to.match(/^Lambda1Version13579L[0-9a-f]{8}$/);
1437+
1438+
// A new layer-aware version resource should exist in the CF template.
1439+
const resources = serverlessStepFunctions.serverless.service
1440+
.provider.compiledCloudFormationTemplate.Resources;
1441+
expect(resources).to.haveOwnProperty(lambda1Param.Ref);
1442+
expect(resources[lambda1Param.Ref].Type).to.equal('AWS::Lambda::Version');
1443+
expect(resources[lambda1Param.Ref].Properties.FunctionName).to.deep.equal({
1444+
Ref: 'HelloLambdaFunction',
1445+
});
1446+
});
1447+
1448+
it('should reference different layer-aware version resources when layers change', () => {
1449+
// Helper to compile with a specific layer version and return the version ref
1450+
// used in the state machine params for the HelloLambdaFunction.
1451+
const compileWithLayer = (layerLogicalId) => {
1452+
const sl = createServerless();
1453+
sl.service.provider.compiledCloudFormationTemplate = { Resources: {}, Outputs: {} };
1454+
sl.service.stepFunctions = {
1455+
stateMachines: {
1456+
myStateMachine1: {
1457+
id: 'Test',
1458+
useExactVersion: true,
1459+
definition: {
1460+
StartAt: 'Lambda1',
1461+
States: {
1462+
Lambda1: {
1463+
Type: 'Task',
1464+
Resource: 'arn:aws:states:::lambda:invoke',
1465+
Parameters: {
1466+
FunctionName: { Ref: 'HelloLambdaFunction' },
1467+
Payload: { 'ExecutionName.$': '$$.Execution.Name' },
1468+
},
1469+
End: true,
1470+
},
1471+
},
1472+
},
1473+
},
1474+
},
1475+
};
1476+
sl.service.provider.compiledCloudFormationTemplate.Resources.HelloLambdaFunction = {
1477+
Type: 'AWS::Lambda::Function',
1478+
Properties: { Layers: [{ Ref: layerLogicalId }] },
1479+
};
1480+
sl.service.provider.compiledCloudFormationTemplate.Resources.Lambda1Version13579 = {
1481+
Type: 'AWS::Lambda::Version',
1482+
Properties: { FunctionName: { Ref: 'HelloLambdaFunction' } },
1483+
};
1484+
1485+
const plugin = new ServerlessStepFunctions(sl);
1486+
plugin.compileStateMachines();
1487+
1488+
const resources = sl.service.provider.compiledCloudFormationTemplate.Resources;
1489+
const stateMachine = resources.Test;
1490+
const [, params] = stateMachine.Properties.DefinitionString['Fn::Sub'];
1491+
const layerAwareKey = Object.keys(params).find(
1492+
(k) => params[k] && params[k].Ref && params[k].Ref.startsWith('Lambda1Version13579L'),
1493+
);
1494+
return { versionRef: params[layerAwareKey].Ref, resources };
1495+
};
1496+
1497+
const { versionRef: versionRefV1 } = compileWithLayer('MyLayerVersion1');
1498+
const { versionRef: versionRefV2, resources: resourcesV2 } = compileWithLayer('MyLayerVersion2');
1499+
1500+
// Different layers → different layer-aware version resource logical IDs.
1501+
expect(versionRefV1).to.not.equal(versionRefV2);
1502+
expect(resourcesV2).to.haveOwnProperty(versionRefV2);
1503+
});
1504+
1505+
it('should create a layer-aware version resource when function has ARN string layers', () => {
1506+
serverlessStepFunctions.serverless.service
1507+
.provider.compiledCloudFormationTemplate.Resources
1508+
.Lambda1Version13579 = {
1509+
Type: 'AWS::Lambda::Version',
1510+
Properties: {
1511+
FunctionName: { Ref: 'HelloLambdaFunction' },
1512+
},
1513+
};
1514+
1515+
serverlessStepFunctions.serverless.service
1516+
.provider.compiledCloudFormationTemplate.Resources
1517+
.HelloLambdaFunction.Properties = {
1518+
Layers: ['arn:aws:lambda:us-east-1:123456789012:layer:my-layer:5'],
1519+
};
1520+
1521+
const { lambda1Param } = compileStateMachines();
1522+
1523+
expect(lambda1Param).to.haveOwnProperty('Ref');
1524+
expect(lambda1Param.Ref).to.match(/^Lambda1Version13579L[0-9a-f]{8}$/);
1525+
});
1526+
1527+
it('should produce different layer hashes for different ARN string layer versions', () => {
1528+
const compileWithArnLayer = (layerArn) => {
1529+
const sl = createServerless();
1530+
sl.service.provider.compiledCloudFormationTemplate = { Resources: {}, Outputs: {} };
1531+
sl.service.stepFunctions = {
1532+
stateMachines: {
1533+
myStateMachine1: {
1534+
id: 'Test',
1535+
useExactVersion: true,
1536+
definition: {
1537+
StartAt: 'Lambda1',
1538+
States: {
1539+
Lambda1: {
1540+
Type: 'Task',
1541+
Resource: 'arn:aws:states:::lambda:invoke',
1542+
Parameters: {
1543+
FunctionName: { Ref: 'HelloLambdaFunction' },
1544+
Payload: { 'ExecutionName.$': '$$.Execution.Name' },
1545+
},
1546+
End: true,
1547+
},
1548+
},
1549+
},
1550+
},
1551+
},
1552+
};
1553+
sl.service.provider.compiledCloudFormationTemplate.Resources.HelloLambdaFunction = {
1554+
Type: 'AWS::Lambda::Function',
1555+
Properties: { Layers: [layerArn] },
1556+
};
1557+
sl.service.provider.compiledCloudFormationTemplate.Resources.Lambda1Version13579 = {
1558+
Type: 'AWS::Lambda::Version',
1559+
Properties: { FunctionName: { Ref: 'HelloLambdaFunction' } },
1560+
};
1561+
1562+
const plugin = new ServerlessStepFunctions(sl);
1563+
plugin.compileStateMachines();
1564+
1565+
const resources = sl.service.provider.compiledCloudFormationTemplate.Resources;
1566+
const stateMachine = resources.Test;
1567+
const [, params] = stateMachine.Properties.DefinitionString['Fn::Sub'];
1568+
const layerAwareKey = Object.keys(params).find(
1569+
(k) => params[k] && params[k].Ref && params[k].Ref.startsWith('Lambda1Version13579L'),
1570+
);
1571+
return { versionRef: params[layerAwareKey].Ref };
1572+
};
1573+
1574+
const { versionRef: v1 } = compileWithArnLayer('arn:aws:lambda:us-east-1:123:layer:my-layer:5');
1575+
const { versionRef: v2 } = compileWithArnLayer('arn:aws:lambda:us-east-1:123:layer:my-layer:6');
1576+
1577+
expect(v1).to.not.equal(v2);
1578+
});
1579+
1580+
it('should not create a layer-aware version resource when function has no layers', () => {
1581+
serverlessStepFunctions.serverless.service
1582+
.provider.compiledCloudFormationTemplate.Resources
1583+
.Lambda1Version13579 = {
1584+
Type: 'AWS::Lambda::Version',
1585+
Properties: {
1586+
FunctionName: { Ref: 'HelloLambdaFunction' },
1587+
},
1588+
};
1589+
1590+
// Function has no Layers property
1591+
serverlessStepFunctions.serverless.service
1592+
.provider.compiledCloudFormationTemplate.Resources
1593+
.HelloLambdaFunction.Properties = {};
1594+
1595+
const { lambda1Param } = compileStateMachines();
1596+
1597+
// With no layers, should still reference the original SF version resource.
1598+
expect(lambda1Param).to.deep.equal({ Ref: 'Lambda1Version13579' });
1599+
1600+
const resources = serverlessStepFunctions.serverless.service
1601+
.provider.compiledCloudFormationTemplate.Resources;
1602+
const layerAwareKeys = Object.keys(resources).filter((k) => k.includes('13579L'));
1603+
expect(layerAwareKeys).to.have.lengthOf(0);
1604+
});
14121605
});
14131606

14141607
it('should not validate definition if not enabled', () => {

0 commit comments

Comments
 (0)