Skip to content

Commit

Permalink
Introduce AutoPublishCodeSha256 to allow overriding the publish versi…
Browse files Browse the repository at this point in the history
…on resource identifier
  • Loading branch information
jmnarloch committed Jan 14, 2020
1 parent eabe27d commit eca5538
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 14 deletions.
1 change: 1 addition & 0 deletions docs/cloudformation_compatibility.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ DeadLetterQueue All
DeploymentPreference All
Layers All
AutoPublishAlias Ref of a CloudFormation Parameter Alias resources created by SAM uses a LocicalId <FunctionLogicalId+AliasName>. So SAM either needs a string for alias name, or a Ref to template Parameter that SAM can resolve into a string.
AutoPublishCodeSha256 All
ReservedConcurrentExecutions All
EventInvokeConfig All
============================ ================================== ========================
Expand Down
4 changes: 3 additions & 1 deletion docs/safe_lambda_deployments.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ This will:

- Create an Alias with ``<alias-name>``
- Create & publish a Lambda version with the latest code & configuration
derived from the ``CodeUri`` property
derived from the ``CodeUri`` property. Optionally it is possible to specify
property `AutoPublishCodeSha256` that will override the hash computed for
Lambda ``CodeUri`` property.
- Point the Alias to the latest published version
- Point all event sources to the Alias & not to the function
- When the ``CodeUri`` property of ``AWS::Serverless::Function`` changes,
Expand Down
11 changes: 8 additions & 3 deletions samtranslator/model/sam_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class SamFunction(SamResourceMacro):
"EventInvokeConfig": PropertyType(False, is_type(dict)),
# Intrinsic functions in value of Alias property are not supported, yet
"AutoPublishAlias": PropertyType(False, one_of(is_str())),
"AutoPublishCodeSha256": PropertyType(False, one_of(is_str())),
"VersionDescription": PropertyType(False, is_str()),
"ProvisionedConcurrencyConfig": PropertyType(False, is_type(dict)),
}
Expand Down Expand Up @@ -132,7 +133,10 @@ def to_cloudformation(self, **kwargs):
alias_name = ""
if self.AutoPublishAlias:
alias_name = self._get_resolved_alias_name("AutoPublishAlias", self.AutoPublishAlias, intrinsics_resolver)
lambda_version = self._construct_version(lambda_function, intrinsics_resolver=intrinsics_resolver)
code_sha256 = self.AutoPublishCodeSha256
lambda_version = self._construct_version(
lambda_function, intrinsics_resolver=intrinsics_resolver, code_sha256=code_sha256
)
lambda_alias = self._construct_alias(alias_name, lambda_function, lambda_version)
resources.append(lambda_version)
resources.append(lambda_alias)
Expand Down Expand Up @@ -596,14 +600,15 @@ def _construct_code_dict(self):
else:
raise InvalidResourceException(self.logical_id, "Either 'InlineCode' or 'CodeUri' must be set")

def _construct_version(self, function, intrinsics_resolver):
def _construct_version(self, function, intrinsics_resolver, code_sha256=None):
"""Constructs a Lambda Version resource that will be auto-published when CodeUri of the function changes.
Old versions will not be deleted without a direct reference from the CloudFormation template.
:param model.lambda_.LambdaFunction function: Lambda function object that is being connected to a version
:param model.intrinsics.resolver.IntrinsicsResolver intrinsics_resolver: Class that can help resolve
references to parameters present in CodeUri. It is a common usecase to set S3Key of Code to be a
template parameter. Need to resolve the values otherwise we will never detect a change in Code dict
:param str code_sha256: User predefined hash of the Lambda function code
:return: Lambda function Version resource
"""
code_dict = function.Code
Expand Down Expand Up @@ -635,7 +640,7 @@ def _construct_version(self, function, intrinsics_resolver):
# SHA Collisions: For purposes of triggering a new update, we are concerned about just the difference previous
# and next hashes. The chances that two subsequent hashes collide is fairly low.
prefix = "{id}Version".format(id=self.logical_id)
logical_id = logical_id_generator.LogicalIdGenerator(prefix, code_dict).gen()
logical_id = logical_id_generator.LogicalIdGenerator(prefix, code_dict, code_sha256).gen()

attributes = self.get_passthrough_resource_attributes()
if attributes is None:
Expand Down
6 changes: 5 additions & 1 deletion samtranslator/translator/logical_id_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class LogicalIdGenerator(object):
# given by this class
HASH_LENGTH = 10

def __init__(self, prefix, data_obj=None):
def __init__(self, prefix, data_obj=None, data_hash=None):
"""
Generate logical IDs for resources that are stable, deterministic and platform independent
Expand All @@ -24,6 +24,7 @@ def __init__(self, prefix, data_obj=None):

self._prefix = prefix
self.data_str = data_str
self.data_hash = data_hash

def gen(self):
"""
Expand Down Expand Up @@ -54,6 +55,9 @@ def get_hash(self, length=HASH_LENGTH):
:rtype string
"""

if self.data_hash:
return self.data_hash[:length]

data_hash = ""
if not self.data_str:
return data_hash
Expand Down
11 changes: 11 additions & 0 deletions tests/translator/input/function_with_alias_and_code_sha256.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Resources:
MinimalFunction:
Type: 'AWS::Serverless::Function'
Properties:
CodeUri: s3://sam-demo-bucket/hello.zip
Handler: hello.handler
Runtime: python2.7
AutoPublishAlias: live
AutoPublishCodeSha256: 6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b
VersionDescription: sam-testing

82 changes: 82 additions & 0 deletions tests/translator/output/function_with_alias_and_code_sha256.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
{
"Resources": {
"MinimalFunctionVersion6b86b273ff": {
"DeletionPolicy": "Retain",
"Type": "AWS::Lambda::Version",
"Properties": {
"Description": "sam-testing",
"FunctionName": {
"Ref": "MinimalFunction"
}
}
},
"MinimalFunctionAliaslive": {
"Type": "AWS::Lambda::Alias",
"Properties": {
"FunctionVersion": {
"Fn::GetAtt": [
"MinimalFunctionVersion6b86b273ff",
"Version"
]
},
"FunctionName": {
"Ref": "MinimalFunction"
},
"Name": "live"
}
},
"MinimalFunction": {
"Type": "AWS::Lambda::Function",
"Properties": {
"Code": {
"S3Bucket": "sam-demo-bucket",
"S3Key": "hello.zip"
},
"Handler": "hello.handler",
"Role": {
"Fn::GetAtt": [
"MinimalFunctionRole",
"Arn"
]
},
"Runtime": "python2.7",
"Tags": [
{
"Value": "SAM",
"Key": "lambda:createdBy"
}
]
}
},
"MinimalFunctionRole": {
"Type": "AWS::IAM::Role",
"Properties": {
"ManagedPolicyArns": [
"arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
],
"Tags": [
{
"Value": "SAM",
"Key": "lambda:createdBy"
}
],
"AssumeRolePolicyDocument": {
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"sts:AssumeRole"
],
"Effect": "Allow",
"Principal": {
"Service": [
"lambda.amazonaws.com"
]
}
}
]
}
}
}
}
}
40 changes: 31 additions & 9 deletions tests/translator/test_function_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,29 @@ def test_version_creation(self, LogicalIdGeneratorMock):
self.assertEqual(version.get_resource_attribute("DeletionPolicy"), "Retain")

expected_prefix = self.sam_func.logical_id + "Version"
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code)
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code, None)
generator_mock.gen.assert_called_once_with()
self.intrinsics_resolver_mock.resolve_parameter_refs.assert_called_once_with(self.lambda_func.Code)

@patch("samtranslator.translator.logical_id_generator.LogicalIdGenerator")
def test_version_creation_with_code_sha(self, LogicalIdGeneratorMock):
generator_mock = LogicalIdGeneratorMock.return_value
prefix = "SomeLogicalId"
hash_code = "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b"
id_val = "{}{}".format(prefix, hash_code[:10])
generator_mock.gen.return_value = id_val

self.intrinsics_resolver_mock.resolve_parameter_refs.return_value = self.lambda_func.Code
self.sam_func.AutoPublishCodeSha256 = hash_code
version = self.sam_func._construct_version(self.lambda_func, self.intrinsics_resolver_mock, hash_code)

self.assertEqual(version.logical_id, id_val)
self.assertEqual(version.Description, None)
self.assertEqual(version.FunctionName, {"Ref": self.lambda_func.logical_id})
self.assertEqual(version.get_resource_attribute("DeletionPolicy"), "Retain")

expected_prefix = self.sam_func.logical_id + "Version"
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code, hash_code)
generator_mock.gen.assert_called_once_with()
self.intrinsics_resolver_mock.resolve_parameter_refs.assert_called_once_with(self.lambda_func.Code)

Expand All @@ -397,7 +419,7 @@ def test_version_creation_without_s3_object_version(self, LogicalIdGeneratorMock
self.assertEqual(version.logical_id, id_val)

expected_prefix = self.sam_func.logical_id + "Version"
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code)
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code, None)
generator_mock.gen.assert_called_once_with()
self.intrinsics_resolver_mock.resolve_parameter_refs.assert_called_once_with(self.lambda_func.Code)

Expand All @@ -421,7 +443,7 @@ def test_version_creation_intrinsic_function_in_code_s3key(self, LogicalIdGenera
self.assertEqual(version.logical_id, id_val)

expected_prefix = self.sam_func.logical_id + "Version"
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code)
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code, None)
self.intrinsics_resolver_mock.resolve_parameter_refs.assert_called_once_with(self.lambda_func.Code)

@patch("samtranslator.translator.logical_id_generator.LogicalIdGenerator")
Expand All @@ -437,7 +459,7 @@ def test_version_creation_intrinsic_function_in_code_s3bucket(self, LogicalIdGen
self.assertEqual(version.logical_id, id_val)

expected_prefix = self.sam_func.logical_id + "Version"
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code)
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code, None)
self.intrinsics_resolver_mock.resolve_parameter_refs.assert_called_once_with(self.lambda_func.Code)

@patch("samtranslator.translator.logical_id_generator.LogicalIdGenerator")
Expand All @@ -453,7 +475,7 @@ def test_version_creation_intrinsic_function_in_code_s3version(self, LogicalIdGe
self.assertEqual(version.logical_id, id_val)

expected_prefix = self.sam_func.logical_id + "Version"
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code)
LogicalIdGeneratorMock.assert_called_once_with(expected_prefix, self.lambda_func.Code, None)
self.intrinsics_resolver_mock.resolve_parameter_refs.assert_called_once_with(self.lambda_func.Code)

@patch("samtranslator.translator.logical_id_generator.LogicalIdGenerator")
Expand All @@ -467,15 +489,15 @@ def test_version_logical_id_changes(self, LogicalIdGeneratorMock):
self.intrinsics_resolver_mock.resolve_parameter_refs.return_value = self.lambda_func.Code
self.sam_func._construct_version(self.lambda_func, self.intrinsics_resolver_mock)

LogicalIdGeneratorMock.assert_called_once_with(prefix, self.lambda_func.Code)
LogicalIdGeneratorMock.assert_called_once_with(prefix, self.lambda_func.Code, None)
self.intrinsics_resolver_mock.resolve_parameter_refs.assert_called_with(self.lambda_func.Code)

# Modify Code of the lambda function
self.lambda_func.Code["S3ObjectVersion"] = "new object version"
new_code = self.lambda_func.Code.copy()
self.intrinsics_resolver_mock.resolve_parameter_refs.return_value = new_code
self.sam_func._construct_version(self.lambda_func, self.intrinsics_resolver_mock)
LogicalIdGeneratorMock.assert_called_with(prefix, new_code)
LogicalIdGeneratorMock.assert_called_with(prefix, new_code, None)
self.intrinsics_resolver_mock.resolve_parameter_refs.assert_called_with(new_code)

@patch("samtranslator.translator.logical_id_generator.LogicalIdGenerator")
Expand All @@ -490,14 +512,14 @@ def test_version_logical_id_changes_with_intrinsic_functions(self, LogicalIdGene
self.intrinsics_resolver_mock.resolve_parameter_refs.return_value = self.lambda_func.Code
self.sam_func._construct_version(self.lambda_func, self.intrinsics_resolver_mock)

LogicalIdGeneratorMock.assert_called_once_with(prefix, self.lambda_func.Code)
LogicalIdGeneratorMock.assert_called_once_with(prefix, self.lambda_func.Code, None)
self.intrinsics_resolver_mock.resolve_parameter_refs.assert_called_with(self.lambda_func.Code)

# Now, just let the intrinsics resolver return a different value. Let's make sure the new value gets wired up properly
new_code = {"S3Bucket": "bucket", "S3Key": "some new value"}
self.intrinsics_resolver_mock.resolve_parameter_refs.return_value = new_code
self.sam_func._construct_version(self.lambda_func, self.intrinsics_resolver_mock)
LogicalIdGeneratorMock.assert_called_with(prefix, new_code)
LogicalIdGeneratorMock.assert_called_with(prefix, new_code, None)
self.intrinsics_resolver_mock.resolve_parameter_refs.assert_called_with(self.lambda_func.Code)

def test_alias_creation(self):
Expand Down
27 changes: 27 additions & 0 deletions tests/translator/test_logical_id_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,33 @@ def test_gen_dict_data(self, stringify_mock, get_hash_mock):

self.assertEqual(generator.gen(), generator.gen())

@patch.object(LogicalIdGenerator, "_stringify")
def test_gen_hash_data_override(self, stringify_mock):
data = {"foo": "bar"}
stringified_data = "stringified data"
hash_value = "6b86b273ff"
stringify_mock.return_value = stringified_data

generator = LogicalIdGenerator(self.prefix, data_obj=data, data_hash=hash_value)

expected = "{}{}".format(self.prefix, hash_value)
self.assertEqual(expected, generator.gen())
stringify_mock.assert_called_once_with(data)

self.assertEqual(generator.gen(), generator.gen())

@patch.object(LogicalIdGenerator, "_stringify")
def test_gen_hash_data_empty(self, stringify_mock):
data = {"foo": "bar"}
stringified_data = "stringified data"
hash_value = ""
stringify_mock.return_value = stringified_data

generator = LogicalIdGenerator(self.prefix, data_obj=data, data_hash=hash_value)

stringify_mock.assert_called_once_with(data)
self.assertEqual(generator.gen(), generator.gen())

def test_gen_stability_with_copy(self):
data = {"foo": "bar", "a": "b"}
generator = LogicalIdGenerator(self.prefix, data_obj=data)
Expand Down

0 comments on commit eca5538

Please sign in to comment.