Skip to content
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

Merged
merged 44 commits into from Sep 24, 2020

Conversation

pwithams
Copy link
Contributor

@pwithams pwithams commented Aug 9, 2020

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:

  • the contents of each referenced layer zip file
  • the properties of each referenced layer, except S3Key as it timestamped and therefore changes on every deploy

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.

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-commenter
Copy link

codecov-commenter commented Aug 9, 2020

Codecov Report

Merging #8066 into master will increase coverage by 0.08%.
The diff coverage is 98.50%.

Impacted file tree graph

@@            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     
Impacted Files Coverage Δ
lib/plugins/aws/package/compile/functions/index.js 97.29% <98.33%> (+0.26%) ⬆️
lib/plugins/aws/package/compile/layers/index.js 95.60% <100.00%> (ø)
lib/plugins/aws/provider/awsProvider.js 93.18% <100.00%> (+0.07%) ⬆️
lib/plugins/package/lib/packageService.js 90.16% <0.00%> (+4.09%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 73399cd...7c3ac73. Read the comment docs.

@pwithams
Copy link
Contributor Author

pwithams commented Aug 9, 2020

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.

@pwithams
Copy link
Contributor Author

I have refactored the tests and added the windows compatibility fix. Let me know if there's anything else I should change.

Copy link
Contributor

@medikoo medikoo left a 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

lib/plugins/aws/package/compile/functions/index.js Outdated Show resolved Hide resolved
lib/plugins/aws/package/compile/functions/index.js Outdated Show resolved Hide resolved
lib/plugins/aws/package/compile/functions/index.js Outdated Show resolved Hide resolved
lib/plugins/aws/package/compile/functions/index.js Outdated Show resolved Hide resolved
lib/plugins/aws/package/compile/functions/index.js Outdated Show resolved Hide resolved
lib/plugins/aws/package/compile/functions/index.js Outdated Show resolved Hide resolved
Comment on lines 471 to 482
// 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;
});
Copy link
Contributor

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;

Copy link
Contributor

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

Copy link
Contributor Author

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

Copy link
Contributor

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:

const layerConfigurations = extractLayerConfigurationsFromFunction(
functionResource.Properties,
cfTemplate
);

const layerConfigurations = extractLayerConfigurationsFromFunction(
functionResource.Properties,
cfTemplate
);

Copy link
Contributor Author

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.

lib/plugins/aws/package/compile/functions/index.js Outdated Show resolved Hide resolved
lib/plugins/aws/package/compile/functions/index.test.js Outdated Show resolved Hide resolved
lib/plugins/aws/package/compile/functions/index.js Outdated Show resolved Hide resolved
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.
@pwithams
Copy link
Contributor Author

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.

Copy link
Contributor

@medikoo medikoo left a 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 => {
Copy link
Contributor

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

Copy link
Contributor Author

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?

Copy link
Contributor

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

Copy link
Contributor Author

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.

Comment on lines 471 to 482
// 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;
});
Copy link
Contributor

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

Copy link
Contributor

@medikoo medikoo left a 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
Copy link
Contributor

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

Copy link
Contributor Author

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?

Copy link
Contributor

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'])

Copy link
Contributor Author

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.

Copy link
Contributor

@medikoo medikoo left a 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]);
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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

Copy link
Contributor Author

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,
Copy link
Contributor

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

Copy link
Contributor Author

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);
Copy link
Contributor

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

Copy link
Contributor Author

@pwithams pwithams Sep 19, 2020

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?

Copy link
Contributor

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')) {
Copy link
Contributor

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

Comment on lines 2825 to 2829
awsRequestStubMap: {
CloudFormation: {
describeStacks: { Stacks: [{ Outputs: [{ OutputKey: 'test' }] }] },
},
},
Copy link
Contributor

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

Comment on lines 2940 to 2941
await fse.rename(originalLayer, backupLayer);
await fse.rename(sourceChangeLayer, originalLayer);
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Comment on lines 2980 to 2981
await fse.rename(originalLayer, sourceChangeLayer);
await fse.rename(backupLayer, originalLayer);
Copy link
Contributor

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

Copy link
Contributor Author

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.

Copy link
Contributor

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;
Copy link
Contributor

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)

Comment on lines 23 to 31
foo:
handler: index.handler
layers:
- { Ref: TestLayerLambdaLayer }
bar:
handler: index.handler
fooBar:
handler: index.handler
reservedConcurrency: 1
Copy link
Contributor

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

Copy link
Contributor

@medikoo medikoo left a 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,
Copy link
Contributor

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) {
) which now resolves to undefined

Copy link
Contributor Author

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.

Comment on lines 400 to 408
return layerArtifactPaths
.sort()
.reduce(
(previousHashCalculation, artifactName) =>
previousHashCalculation.then(() =>
addFileContentsToHashes(artifactName, [versionHash])
),
BbPromise.resolve()
);
Copy link
Contributor

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])
}

Copy link
Contributor

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?

Copy link
Contributor Author

@pwithams pwithams Sep 23, 2020

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));
Copy link
Contributor

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));
Copy link
Contributor

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) => {
Copy link
Contributor

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

Copy link
Contributor

@medikoo medikoo left a 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!

Comment on lines 400 to 408
return layerArtifactPaths
.sort()
.reduce(
(previousHashCalculation, artifactName) =>
previousHashCalculation.then(() =>
addFileContentsToHashes(artifactName, [versionHash])
),
BbPromise.resolve()
);
Copy link
Contributor

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?

Copy link
Contributor

@medikoo medikoo left a 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

@medikoo medikoo merged commit e43c889 into serverless:master Sep 24, 2020
@pwithams
Copy link
Contributor Author

Thank you @pwithams ! We finally have it, and it seems very solid

🎉 Thanks for the help and patience @medikoo!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Lambda version is not updated when a layer from the same stack has code changes
3 participants