Skip to content

Commit 88fc62d

Browse files
authored
fix(lambda): ever-changing Version hash with LayerVersion from tokens (#23629)
If `LayerVersions` are referenced using tokens (`LayerVersion.fromLayerVersionArn(this, 'Layer', /* some deploy-time value */`) then the version hash would incorrectly use the string representation of the tokenized ARN and be different on every deployment, incorrectly trying to create a new `Version` object on every deployment. Resolve the ARN if we detect this. However, this will not be complete: we now have the problem that a new Version will not be created if it were necessary, since CDK cannot read the deploy-time value of the ARN and cannot mix it into the Version LogicalID if necessary. To fix that, add a: ```ts fn.invalidateVersionBasedOn(...); ``` Function to help invalidate the version using outside information. ---- ### All Submissions: * [ ] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Construct Runtime Dependencies: * [ ] This PR adds new construct runtime dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-construct-runtime-dependencies) ### New Features * [ ] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [ ] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent dce662c commit 88fc62d

File tree

4 files changed

+124
-5
lines changed

4 files changed

+124
-5
lines changed

packages/@aws-cdk/aws-lambda/lib/function-hash.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { CfnResource, FeatureFlags, Stack } from '@aws-cdk/core';
1+
import { CfnResource, FeatureFlags, Stack, Token } from '@aws-cdk/core';
22
import { md5hash } from '@aws-cdk/core/lib/helpers-internal';
33
import { LAMBDA_RECOGNIZE_LAYER_VERSION, LAMBDA_RECOGNIZE_VERSION_PROPS } from '@aws-cdk/cx-api';
44
import { Function as LambdaFunction } from './function';
55
import { ILayerVersion } from './layers';
66

7-
export function calculateFunctionHash(fn: LambdaFunction) {
7+
export function calculateFunctionHash(fn: LambdaFunction, additional: string = '') {
88
const stack = Stack.of(fn);
99

1010
const functionResource = fn.node.defaultChild as CfnResource;
@@ -34,7 +34,7 @@ export function calculateFunctionHash(fn: LambdaFunction) {
3434
stringifiedConfig = stringifiedConfig + calculateLayersHash(fn._layers);
3535
}
3636

37-
return md5hash(stringifiedConfig);
37+
return md5hash(stringifiedConfig + additional);
3838
}
3939

4040
export function trimFromStart(s: string, maxLength: number) {
@@ -130,7 +130,16 @@ function calculateLayersHash(layers: ILayerVersion[]): string {
130130
// if there is no layer resource, then the layer was imported
131131
// and we will include the layer arn and runtimes in the hash
132132
if (layerResource === undefined) {
133-
layerConfig[layer.layerVersionArn] = layer.compatibleRuntimes;
133+
// ARN may have unresolved parts in it, but we didn't deal with this previously
134+
// so deal with it now for backwards compatibility.
135+
if (!Token.isUnresolved(layer.layerVersionArn)) {
136+
layerConfig[layer.layerVersionArn] = layer.compatibleRuntimes;
137+
} else {
138+
layerConfig[layer.node.id] = {
139+
arn: stack.resolve(layer.layerVersionArn),
140+
runtimes: layer.compatibleRuntimes?.map(r => r.name),
141+
};
142+
}
134143
continue;
135144
}
136145
const config = stack.resolve((layerResource as any)._toCloudFormation());

packages/@aws-cdk/aws-lambda/lib/function.ts

+27-1
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ export class Function extends FunctionBase {
436436

437437
cfn.overrideLogicalId(Lazy.uncachedString({
438438
produce: () => {
439-
const hash = calculateFunctionHash(this);
439+
const hash = calculateFunctionHash(this, this.hashMixins.join(''));
440440
const logicalId = trimFromStart(originalLogicalId, 255 - 32);
441441
return `${logicalId}${hash}`;
442442
},
@@ -664,6 +664,7 @@ export class Function extends FunctionBase {
664664
private _currentVersion?: Version;
665665

666666
private _architecture?: Architecture;
667+
private hashMixins = new Array<string>();
667668

668669
constructor(scope: Construct, id: string, props: FunctionProps) {
669670
super(scope, id, {
@@ -940,6 +941,31 @@ export class Function extends FunctionBase {
940941
return this;
941942
}
942943

944+
/**
945+
* Mix additional information into the hash of the Version object
946+
*
947+
* The Lambda Function construct does its best to automatically create a new
948+
* Version when anything about the Function changes (its code, its layers,
949+
* any of the other properties).
950+
*
951+
* However, you can sometimes source information from places that the CDK cannot
952+
* look into, like the deploy-time values of SSM parameters. In those cases,
953+
* the CDK would not force the creation of a new Version object when it actually
954+
* should.
955+
*
956+
* This method can be used to invalidate the current Version object. Pass in
957+
* any string into this method, and make sure the string changes when you know
958+
* a new Version needs to be created.
959+
*
960+
* This method may be called more than once.
961+
*/
962+
public invalidateVersionBasedOn(x: string) {
963+
if (Token.isUnresolved(x)) {
964+
throw new Error('invalidateVersionOn: input may not contain unresolved tokens');
965+
}
966+
this.hashMixins.push(x);
967+
}
968+
943969
/**
944970
* Adds one or more Lambda Layers to this Lambda function.
945971
*

packages/@aws-cdk/aws-lambda/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
"@types/aws-lambda": "^8.10.109",
9595
"@types/jest": "^27.5.2",
9696
"@types/lodash": "^4.14.191",
97+
"@aws-cdk/aws-ssm": "0.0.0",
9798
"jest": "^27.5.1",
9899
"lodash": "^4.17.21"
99100
},

packages/@aws-cdk/aws-lambda/test/function-hash.test.ts

+83
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import * as path from 'path';
2+
import { Template } from '@aws-cdk/assertions';
3+
import * as ssm from '@aws-cdk/aws-ssm';
24
import { resourceSpecification } from '@aws-cdk/cfnspec';
35
import { App, CfnOutput, CfnResource, Stack } from '@aws-cdk/core';
46
import * as cxapi from '@aws-cdk/cx-api';
@@ -440,3 +442,84 @@ describe('function hash', () => {
440442
});
441443
});
442444
});
445+
446+
test('imported layer hashes are consistent', () => {
447+
// GIVEN
448+
const app = new App({
449+
context: {
450+
'@aws-cdk/aws-lambda:recognizeLayerVersion': true,
451+
},
452+
});
453+
454+
// WHEN
455+
const stack1 = new Stack(app, 'Stack1');
456+
const param1 = ssm.StringParameter.fromStringParameterName(stack1, 'Param', 'ParamName');
457+
const fn1 = new lambda.Function(stack1, 'Fn', {
458+
code: lambda.Code.fromInline('asdf'),
459+
handler: 'index.handler',
460+
runtime: lambda.Runtime.NODEJS_18_X,
461+
layers: [
462+
lambda.LayerVersion.fromLayerVersionArn(stack1, 'MyLayer',
463+
`arn:aws:lambda:${stack1.region}:<AccountID>:layer:IndexCFN:${param1.stringValue}`),
464+
],
465+
});
466+
fn1.currentVersion; // Force creation of version
467+
468+
const stack2 = new Stack(app, 'Stack2');
469+
const param2 = ssm.StringParameter.fromStringParameterName(stack2, 'Param', 'ParamName');
470+
const fn2 = new lambda.Function(stack2, 'Fn', {
471+
code: lambda.Code.fromInline('asdf'),
472+
handler: 'index.handler',
473+
runtime: lambda.Runtime.NODEJS_18_X,
474+
layers: [
475+
lambda.LayerVersion.fromLayerVersionArn(stack2, 'MyLayer',
476+
`arn:aws:lambda:${stack1.region}:<AccountID>:layer:IndexCFN:${param2.stringValue}`),
477+
],
478+
});
479+
fn2.currentVersion; // Force creation of version
480+
481+
// THEN
482+
const template1 = Template.fromStack(stack1);
483+
const template2 = Template.fromStack(stack2);
484+
485+
expect(template1.toJSON()).toEqual(template2.toJSON());
486+
});
487+
488+
test.each([false, true])('can invalidate version hash using invalidateVersionBasedOn: %p', (doIt) => {
489+
// GIVEN
490+
const app = new App();
491+
492+
// WHEN
493+
const stack1 = new Stack(app, 'Stack1');
494+
const fn1 = new lambda.Function(stack1, 'Fn', {
495+
code: lambda.Code.fromInline('asdf'),
496+
handler: 'index.handler',
497+
runtime: lambda.Runtime.NODEJS_18_X,
498+
});
499+
if (doIt) {
500+
fn1.invalidateVersionBasedOn('abc');
501+
}
502+
fn1.currentVersion; // Force creation of version
503+
504+
const stack2 = new Stack(app, 'Stack2');
505+
const fn2 = new lambda.Function(stack2, 'Fn', {
506+
code: lambda.Code.fromInline('asdf'),
507+
handler: 'index.handler',
508+
runtime: lambda.Runtime.NODEJS_18_X,
509+
});
510+
if (doIt) {
511+
fn1.invalidateVersionBasedOn('xyz');
512+
}
513+
fn2.currentVersion; // Force creation of version
514+
515+
// THEN
516+
const template1 = Template.fromStack(stack1);
517+
const template2 = Template.fromStack(stack2);
518+
519+
if (doIt) {
520+
expect(template1.toJSON()).not.toEqual(template2.toJSON());
521+
} else {
522+
expect(template1.toJSON()).toEqual(template2.toJSON());
523+
}
524+
525+
});

0 commit comments

Comments
 (0)