Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion samtranslator/model/sam_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -1202,7 +1202,28 @@ def _construct_version( # noqa: PLR0912
if publish_lambda_version:
properties.update({layer_logical_id: layer_properties})

logical_dict = properties
# Resolve template parameter references for the same reason the CodeUri path above
# does: an unresolved `{"Ref": "SomeParameter"}` hashes identically no matter what
# value is supplied, so a parameter-driven property change would not produce a new
# version logical id and no version would be published.
#
# Pseudo parameters (AWS::Region, AWS::Partition, ...) are deliberately excluded.
# They are present in the resolver's parameter map but their values do not represent
# a template change, and resolving them would rewrite `Fn::Sub` strings that
# reference them -- shifting the version logical id of existing templates that have
# not changed at all.
#
# Resolve against a deep copy. `resolve_parameter_refs` mutates the dict it is given
# (`_traverse_dict` assigns back into `input_dict`), and the values reachable from
# `_generate_resource_dict()` are the live objects from the user's template -- as are
# the layer properties above, which come from the output template via
# ResourceResolver. Resolving in place would inline parameter values into the emitted
# resources, replacing `{"Ref": "Param"}` with the literal (leaking NoEcho values and
# dropping the parameter reference from the deployed resource). The resolver's own
# docstring warns against passing its result into the transform's output.
logical_dict = IntrinsicsResolver(
{key: value for key, value in intrinsics_resolver.parameters.items() if not key.startswith("AWS::")}
).resolve_parameter_refs(copy.deepcopy(properties))
else:
with suppress(AttributeError, UnboundLocalError):
logical_dict = code_dict.copy()
Expand Down
94 changes: 94 additions & 0 deletions tests/translator/test_function_resources.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from unittest import TestCase
from unittest.mock import Mock, call, patch

Expand All @@ -7,6 +8,8 @@
from samtranslator.model.preferences.deployment_preference import DeploymentPreference
from samtranslator.model.sam_resources import SamFunction
from samtranslator.model.update_policy import UpdatePolicy
from samtranslator.parser.parser import Parser
from samtranslator.translator.translator import Translator


class TestVersionsAndAliases(TestCase):
Expand Down Expand Up @@ -893,3 +896,94 @@ def test_must_not_break_support(self):
self.assertEqual(func.referable_properties["Version"], "AWS::Lambda::Version")
self.assertEqual(func.referable_properties["DestinationTopic"], "AWS::SNS::Topic")
self.assertEqual(func.referable_properties["DestinationQueue"], "AWS::SQS::Queue")


class TestAutoPublishAliasAllPropertiesParameterHash(TestCase):
"""Regression tests for GitHub issue #3820.

With AutoPublishAliasAllProperties, a property whose value comes from a
template parameter (e.g. an Environment variable set to `!Ref SomeParam`)
must produce a different Lambda Version logical id when the parameter value
changes, otherwise `sam deploy` publishes no new version. Pseudo parameters
(AWS::Region, AWS::Partition, ...) must NOT influence the id, since they do
not represent a template change.
"""

def _translate(self, parameter_values, no_echo=False):
parameter = {"Type": "String"}
if no_echo:
parameter["NoEcho"] = True
template = {
"AWSTemplateFormatVersion": "2010-09-09",
"Transform": "AWS::Serverless-2016-10-31",
"Parameters": {"TestParameter": parameter},
"Resources": {
"HelloWorldFunction": {
"Type": "AWS::Serverless::Function",
"Properties": {
"InlineCode": "def handler(event, context): pass",
"Handler": "app.handler",
"Runtime": "python3.12",
"Architectures": ["x86_64"],
"AutoPublishAlias": "live",
"AutoPublishAliasAllProperties": True,
"Environment": {"Variables": {"TEST_PARAMETER": {"Ref": "TestParameter"}}},
},
}
},
}
return Translator({}, Parser()).translate(json.loads(json.dumps(template)), parameter_values=parameter_values)

def _version_logical_ids(self, parameter_values):
output = self._translate(parameter_values)
return sorted(
logical_id
for logical_id, resource in output["Resources"].items()
if resource["Type"] == "AWS::Lambda::Version"
)

@patch("boto3.session.Session.region_name", "us-east-1")
def test_parameter_value_change_produces_new_version(self):
ids_a = self._version_logical_ids({"TestParameter": "value-a"})
ids_b = self._version_logical_ids({"TestParameter": "value-b"})

self.assertEqual(len(ids_a), 1)
self.assertEqual(len(ids_b), 1)
self.assertNotEqual(
ids_a,
ids_b,
"AutoPublishAliasAllProperties must publish a new version when a "
"referenced parameter value changes (issue #3820)",
)

@patch("boto3.session.Session.region_name", "us-east-1")
def test_same_parameter_value_is_stable(self):
self.assertEqual(
self._version_logical_ids({"TestParameter": "value-a"}),
self._version_logical_ids({"TestParameter": "value-a"}),
)

@patch("boto3.session.Session.region_name", "us-east-1")
def test_parameter_reference_is_preserved_in_output_template(self):
"""Resolving parameter refs for the hash must not leak into the emitted template.

`resolve_parameter_refs` mutates the dict it is given, and the values reachable
from `_generate_resource_dict()` are the live objects from the user's template.
Resolving in place would replace `{"Ref": "TestParameter"}` with the literal
value on the emitted AWS::Lambda::Function. A version-logical-id assertion
cannot see that, so assert on the output resource directly.
"""
output = self._translate({"TestParameter": "some-value"})

self.assertEqual(
output["Resources"]["HelloWorldFunction"]["Properties"]["Environment"]["Variables"]["TEST_PARAMETER"],
{"Ref": "TestParameter"},
)

@patch("boto3.session.Session.region_name", "us-east-1")
def test_no_echo_parameter_value_is_not_inlined(self):
"""A NoEcho parameter value must never appear as plaintext in the output."""
secret = "super-secret-value"
output = self._translate({"TestParameter": secret}, no_echo=True)

self.assertNotIn(secret, json.dumps(output))