diff --git a/docs/cloudformation_compatibility.rst b/docs/cloudformation_compatibility.rst index b431d9d27..ef104fad7 100644 --- a/docs/cloudformation_compatibility.rst +++ b/docs/cloudformation_compatibility.rst @@ -62,6 +62,7 @@ DeadLetterQueue All DeploymentPreference All Layers All AutoPublishAlias Ref of a CloudFormation Parameter Alias resources created by SAM uses a LocicalId . 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 ============================ ================================== ======================== diff --git a/docs/safe_lambda_deployments.rst b/docs/safe_lambda_deployments.rst index d965163b7..437b9352a 100644 --- a/docs/safe_lambda_deployments.rst +++ b/docs/safe_lambda_deployments.rst @@ -62,7 +62,9 @@ This will: - Create an Alias with ```` - 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, diff --git a/samtranslator/model/sam_resources.py b/samtranslator/model/sam_resources.py index f219fd807..d8e0ceef2 100644 --- a/samtranslator/model/sam_resources.py +++ b/samtranslator/model/sam_resources.py @@ -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)), } @@ -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) @@ -596,7 +600,7 @@ 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. @@ -604,6 +608,7 @@ def _construct_version(self, function, intrinsics_resolver): :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 @@ -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: diff --git a/samtranslator/translator/logical_id_generator.py b/samtranslator/translator/logical_id_generator.py index bbebdb737..1a2a4b409 100644 --- a/samtranslator/translator/logical_id_generator.py +++ b/samtranslator/translator/logical_id_generator.py @@ -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 @@ -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): """ @@ -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 diff --git a/tests/translator/input/function_with_alias_and_code_sha256.yaml b/tests/translator/input/function_with_alias_and_code_sha256.yaml new file mode 100644 index 000000000..0da76e5ac --- /dev/null +++ b/tests/translator/input/function_with_alias_and_code_sha256.yaml @@ -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 + diff --git a/tests/translator/output/function_with_alias_and_code_sha256.json b/tests/translator/output/function_with_alias_and_code_sha256.json new file mode 100644 index 000000000..5f89a1632 --- /dev/null +++ b/tests/translator/output/function_with_alias_and_code_sha256.json @@ -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" + ] + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/tests/translator/test_function_resources.py b/tests/translator/test_function_resources.py index 2ed2fc4c4..bfa0cda1e 100644 --- a/tests/translator/test_function_resources.py +++ b/tests/translator/test_function_resources.py @@ -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) @@ -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) @@ -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") @@ -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") @@ -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") @@ -467,7 +489,7 @@ 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 @@ -475,7 +497,7 @@ def test_version_logical_id_changes(self, LogicalIdGeneratorMock): 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") @@ -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): diff --git a/tests/translator/test_logical_id_generator.py b/tests/translator/test_logical_id_generator.py index 7205bc6e0..65607defb 100644 --- a/tests/translator/test_logical_id_generator.py +++ b/tests/translator/test_logical_id_generator.py @@ -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)