New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Fix lambda logical version id for layers in same stack #8066
Fix lambda logical version id for layers in same stack #8066
Conversation
Currently the lambda version logical id is created by detecting any changes in either the code or the lambda config. However, if the lambda uses a layer that has updated then there will be no change to the config as it just contains a reference to the resource name. To fix this, the layer details are extracted which will include the timestamped S3 layer zip name and therefore generate a new digest if the layer zip file name changes.
Codecov Report
@@ Coverage Diff @@
## master #8066 +/- ##
==========================================
+ Coverage 88.02% 88.11% +0.08%
==========================================
Files 248 248
Lines 9312 9338 +26
==========================================
+ Hits 8197 8228 +31
+ Misses 1115 1110 -5
Continue to review full report at Codecov.
|
The windows tests are failing, so I will look into that soon. My guess it that maybe the generated digests are different on windows, although there was a similar existing test 'should version only function that is flagged to be versioned'. Either way, refactoring the tests to just compare the version id values rather than explicit matches is probably preferable so I will change that. |
I have refactored the tests and added the windows compatibility fix. Let me know if there's anything else I should change. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pwithams great thanks for that! It's a very valuable improvement, please see my remarks
// Include function and layer configuration in version id hash (without the Code part) | ||
const functionProperties = _.omit(_.get(functionResource, 'Properties', {}), 'Code'); | ||
const layerProperties = this.extractLayerPropertiesFromFunction( | ||
functionProperties, | ||
cfTemplate | ||
); | ||
|
||
const fileDigest = fileHash.read(); | ||
const versionDigest = versionHash.read(); | ||
const mappedLayerFunctionProperties = layerProperties.map(layer => { | ||
const clonedLayer = _.cloneDeep(layer); | ||
clonedLayer.properties.Properties.Content.S3Key = ''; | ||
return clonedLayer.properties; | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's reuse already resolved layerProperties
(nice if named layerConfigurations
) in previous block.
Also if I see correctly this logic simply removes S3Key
properties, that can be done simpler way via:
for (const layerProperties of layerConfigurations) delete layerProperties.S3Key;
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's reuse already resolved layerProperties (nice if named layerConfigurations) in previous block.
Doesn't seem to be addressed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I understand correctly I think this should be covered by 14de011#diff-771a8504e96d8db95f70f2cca6e3a906R475
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It doesn't seem. Note that layerConfigurations
are still resolved twice in code as proposed:
serverless/lib/plugins/aws/package/compile/functions/index.js
Lines 452 to 455 in bccd773
const layerConfigurations = extractLayerConfigurationsFromFunction( | |
functionResource.Properties, | |
cfTemplate | |
); |
serverless/lib/plugins/aws/package/compile/functions/index.js
Lines 476 to 479 in bccd773
const layerConfigurations = extractLayerConfigurationsFromFunction( | |
functionResource.Properties, | |
cfTemplate | |
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, it is now moved out of the block and used twice.
The functions compile logic was refactored. The tests were updated to use the new runServerless utility. Additionally, old tests that used explicit hash references were updated to use comparisons instead as the compile logic now generates slightly different hashes.
Thanks for the feedback! I think I've managed to address most of them but in doing so I ended up changing quite a bit, especially with the tests, so let me know if there is anything in these new changes I can improve. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pwithams great thanks for update! Still I think one of the most important suggestions I proposed was missed.
} else { | ||
versionHash.write(value != null ? String(value) : ''); | ||
|
||
return BbPromise.map(layerZipFilePaths, zipFileName => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's not use Bluebird` functions which do not have native counterparts.
We should use BbPromise.all
instead
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This has been updated now, but I realized that both approaches in their current state will run asynchronously on the same hash object. I'm not sure if there could be a race condition or something if hash.write(chunk)
calls on the same object collide.
A potential fix would be to rewrite addFileContentsToHash
to return a new string or hash object representing the file. Once all promises finish, the array of strings/hashes could then be combined and added to the versionId hash. What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This has been updated now, but I realized that both approaches in their current state will run asynchronously on the same hash object.
That's true, it won't be deterministic then. We need to run it sequentially, I believe you can achieve it as:
return layerZipFilePaths.reduce(
(previousHashCalculation, zipFileName) => previousHashCalculation.then(() => addFileContentsToHashes(zipFileName, [versionHash]),
BbPromise.resolve()
)
Note that also layerZipFilePaths
order needs to also be deterministic
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, this should be done now.
// Include function and layer configuration in version id hash (without the Code part) | ||
const functionProperties = _.omit(_.get(functionResource, 'Properties', {}), 'Code'); | ||
const layerProperties = this.extractLayerPropertiesFromFunction( | ||
functionProperties, | ||
cfTemplate | ||
); | ||
|
||
const fileDigest = fileHash.read(); | ||
const versionDigest = versionHash.read(); | ||
const mappedLayerFunctionProperties = layerProperties.map(layer => { | ||
const clonedLayer = _.cloneDeep(layer); | ||
clonedLayer.properties.Properties.Content.S3Key = ''; | ||
return clonedLayer.properties; | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's reuse already resolved layerProperties (nice if named layerConfigurations) in previous block.
Doesn't seem to be addressed
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pwithams great thanks for update. I believe we're definitely closer. See my remarks and let me know what you think
@@ -0,0 +1,16 @@ | |||
service: service |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'll be nice to introduce just one fixture
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't sure exactly how to do this with one fixture. The test is to check that if only a line of the layer code is changed then a deploy will create a new lambda version that points to the new layer created. Everything else (serverless.yml, directory/file names etc.) all stays the same.
One way I considered was extending the fixture to point to a second layer directory that was slightly different. However, in that case the layer properties would be different and so it wouldn't recreate the exact scenario being tested for, i.e. the serverless.yml would be different.
I looked to see if there was a way I could update the code in the fixture's layer at runtime but that doesn't seem right. Any ideas?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So if I understand correctly, in test, we should modify same layer file and proceed with second deployment. It's important to not change the filename of a layer as that wouldn't properly reflect what we want to test.
If my understanding is correct, then maybe we could create two different layer versions (e.g. testLayer1
, and testLayer2
) directly in a fixture, and before first deployment copy testLayer1
into testLayer
and before second deployment remove testLayer
and copy testLayer2
into testLayer
)
Cleanup of that can be done through fixtures.cleanup({ extraPaths: ['testLayer'])
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, this should be done now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pwithams that looks great! We're very close. New tests look very good. Please see my comments, it's last final remarks
if (!functionProperties.Layers) return layerConfigurations; | ||
functionProperties.Layers.forEach(potentialLocalLayerObject => { | ||
if (potentialLocalLayerObject.Ref) { | ||
const configuration = _.cloneDeep(cfTemplate.Resources[potentialLocalLayerObject.Ref]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need to clone it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Main reason was I assumed this line https://github.com/pwithams/serverless/blob/c8be293c97dd8550f2a5d02ee8d61e6032da3335/lib/plugins/aws/package/compile/functions/index.js#L513 would delete part of the already created cloudformation template if the reference was returned.
Also, in general it seemed like cfTemplate
is such an important object that if part of it is being returned then it should be a copy so that anything using the return value doesn't accidentally alter it.
However, if the delete doesn't actually affect anything (I think removing the clone only fails one of my tests?) then it can be taken out, especially if lodash usage is being minimized.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cfTemplate
is such an important object that if part of it is being returned then it should be a copy so that anything using the return value doesn't accidentally alter it.
In fact, if we're taking it as reference, then indeed we should never attempt to modify it. However implying a practice that whenever we take it, we copy it (just in case), could be noisy and difficult to follow, it's definitely not natural in JS.
It's more natural to purely stay away from modifying such reference objects.
So I'd say, let's not clone and let's ensure we do not modify it in further processing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That makes sense. I think it still has to be modified later in some way as S3Key
can't be part of the hash otherwise it will change every time. I've now moved the clone out of the function and applied it to the returned result instead - this solves the configuration._serverlessLayerName
naming issue below too.
Let me know if you have any other ideas how to handle keeping S3Key
out of the hash without a clone.
const configuration = _.cloneDeep(cfTemplate.Resources[potentialLocalLayerObject.Ref]); | ||
layerConfigurations.push({ | ||
serverlessLayerName: | ||
cfTemplate.Resources[potentialLocalLayerObject.Ref]._serverlessLayerName, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It could be configuration._serverlessLayerName
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the reason I didn't use that is because cloneDeep
only clones enumerable attributes. Not sure if there's another cleaner way to do it.
expect( | ||
awsCompileFunctions.serverless.service.provider.compiledCloudFormationTemplate.Outputs | ||
).to.deep.equal(expectedOutputs); | ||
).to.not.equal(firstOutputs); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we need to use deep.equal
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the idea here was that the previous tests were relying on direct digest comparisons. So to support the new hash changes and make the tests avoid using hardcoded digests, more general comparisons were used.
Before, the test ran compileFunctions
and checked that it gave a specific hardcoded output. Then it changed the input, updated the hardcoded expected output, and then checked that those new values matched.
Now the test just runs the first case, saves the first output, makes configuration changes, and then tests that the output to the second does not equal the first. To your point though I think it should probably be using to.not.deep.equal
actually as Outputs
is an object.
Does that make sense?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To your point though I think it should probably be using to.not.deep.equal actually as Outputs is an object.
Yes I actually meant to replace equal
with deep.equal
(so in result it's to.not.deep.equal
). Sorry that was probably not clear enough
.FuncLambdaVersionUF9qY6un3qUVhv62q1QPfZm4qaYxyEJXKjFTLIZ5Ig.Properties.Description | ||
).to.equal('Lambda function description'); | ||
)) { | ||
if (key.includes('FuncLambdaVersion')) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Slightly more precise would be startsWith
awsRequestStubMap: { | ||
CloudFormation: { | ||
describeStacks: { Stacks: [{ Outputs: [{ OutputKey: 'test' }] }] }, | ||
}, | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'll be nice to seclude this mock to var, and reuse it in each runServerless
call
await fse.rename(originalLayer, backupLayer); | ||
await fse.rename(sourceChangeLayer, originalLayer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To test it properly I believe we should change the layer path in a config (so point a file at different location with a same content).
Here we're replacing file with file of very same content, which technically brings no change in environement. which seems equal to not do any renames.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes re-reading this I think it is redundant. Changing the path in the config would also cause a version id change as the config is part of the hash. I think what I was trying to do here is test that if nothing is changed in the layer config (including source) then the version id stays the same.
This is kind of already covered in the should ignore changing character of S3Key paths when generating layer version id
, but I've kept the test and simplified it to be should create same version id when layer source and config are unchanged
and just ran runServerless
twice. The benefit of keeping it would be to make clear cases where the S3Key test failed due to a more general and S3Key-unrelated issue.
await fse.rename(originalLayer, sourceChangeLayer); | ||
await fse.rename(backupLayer, originalLayer); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would be nice to construct this with before
and after
, as otherwise if tests fail, the changed state will be leftover, possibly affecting results of other tests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wasn't too sure exactly how to do this as the before
and after
need to be run specifically for this test only. I ended up using a nested describe
with beforeEach
and afterEach
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, in that case it'll require nesting extra describe
block (and imo it's nothing wrong with that)
'.serverless', | ||
this.provider.naming.getLayerArtifactName(layerName) | ||
); | ||
return layerArtifactName; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe we can return resolved value directly (there seems no value in creating layerArtifactName
variable)
foo: | ||
handler: index.handler | ||
layers: | ||
- { Ref: TestLayerLambdaLayer } | ||
bar: | ||
handler: index.handler | ||
fooBar: | ||
handler: index.handler | ||
reservedConcurrency: 1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'll be nice to give meaningful names (referencing what we test through them) to those lambdas, as we did with below ones
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pwithams great thanks for update. It looks very good now!
I've just spotted one error, and also proposed few small improvements to use native interfaces on which we can rely on with v2 (as we've released it in a meantime)
if (potentialLocalLayerObject.Ref) { | ||
const configuration = cfTemplate.Resources[potentialLocalLayerObject.Ref]; | ||
layerConfigurations.push({ | ||
serverlessLayerName: configuration._serverlessLayerName, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should name property name
. Firstly in this context serverlessLayer
prefix is not justified, and when processing it, in one place we assume it's name (
for (const { name, properties: layerProperties } of layerConfigurations) { |
undefined
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, good catch. I think it must have been name
at one point, so I've changed it back. I've added the other improvements and merged in this change from master (1fceb89) so let me know me if anything looks wrong with that.
return layerArtifactPaths | ||
.sort() | ||
.reduce( | ||
(previousHashCalculation, artifactName) => | ||
previousHashCalculation.then(() => | ||
addFileContentsToHashes(artifactName, [versionHash]) | ||
), | ||
BbPromise.resolve() | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since we're on v2 we can safely rely on async/await and native promises, so now we can construct it way more simply as:
for (const layerArtifactPath of layerArtifactPaths) {
await addFileContentsToHashes(layerArtifactPath, [versionHash])
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we refactor that one, to form as proposed in above comment?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had refactored but I'd forgotten to remove the old logic... it should be fixed now.
const sortedLayerConfigurations = {}; | ||
const byKey = ([key1], [key2]) => key1.localeCompare(key2); | ||
for (const { name, properties: layerProperties } of layerConfigurations) { | ||
sortedLayerConfigurations[name] = _.fromPairs(_.entries(layerProperties).sort(byKey)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use Object.entries
instead of _.entries
(as in a meantime we've dropped support for Node.js v8)
|
||
const versionResource = this.cfLambdaVersionTemplate(); | ||
const sortedFunctionProperties = _.fromPairs(_.entries(functionProperties).sort(byKey)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same here
@@ -623,4 +646,38 @@ class AwsCompileFunctions { | |||
} | |||
} | |||
|
|||
function addFileContentsToHashes(filePath, hashes) { | |||
return new BbPromise((cb, reject) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's use native Promise
, and for consistency let's use resolve
name instead cb
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@pwithams looks great! One last, thing, can we have that reduce
construct refactored to simple for.. of await
? See my comment, in previous review, I've proposed on how it can look.
Thank you!
return layerArtifactPaths | ||
.sort() | ||
.reduce( | ||
(previousHashCalculation, artifactName) => | ||
previousHashCalculation.then(() => | ||
addFileContentsToHashes(artifactName, [versionHash]) | ||
), | ||
BbPromise.resolve() | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we refactor that one, to form as proposed in above comment?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @pwithams ! We finally have it, and it seems very solid
Closes: #8053
Problem
Lambda version ids were created only using the function properties. If a layer is referenced in the same stack, the property value (i.e. layer: [{'Ref': 'ExampleLambdaLayer'}]) will only change if the layer name is changed. This means that code changes in a layer will not trigger a new lambda version to be created.
Proposed fix
In addition to the function properties, the following have been added to the version id hash:
Note
I tried to avoid both bluebird and Lodash where possible but did use them in a few cases, especially where they were already being used. Unfortunately I am not a JS developer so any suggestions for where those can be removed or improvements in general are more than welcome.