Skip to content
24 changes: 9 additions & 15 deletions samcli/lib/intrinsic_resolver/intrinsic_property_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,10 @@ def intrinsic_property_resolver(self, intrinsic, ignore_errors, parent_function=
sanitized_key, parent_function
),
)
sanitized_dict[sanitized_key] = sanitized_val
# A resolved value of None means the property was Fn::If-ed to AWS::NoValue.
# CloudFormation drops such properties entirely rather than keeping a null value.
if sanitized_val is not None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] The AWS::NoValue handling is only applied in the dictionary branch. The list branch of intrinsic_property_resolver (around line 196) is unchanged:

if isinstance(intrinsic, list):
   return [self.intrinsic_property_resolver(item, ignore_errors) for item in intrinsic]

So a per-element Fn::If that selects AWS::NoValue — a common CloudFormation pattern — now resolves to a list containing a literal None:

Layers:
  - !Ref BaseLayer
  - !If [UseExtraLayer, !Ref ExtraLayer, !Ref "AWS::NoValue"]

Before this PR the Fn::If raised while resolving the unselected AWS::NoValue branch, so with ignore_errors=True the whole Layers property was left raw. After this PR it resolves to [{"Ref": "BaseLayer"}, None]. CloudFormation removes the element from the list, so the resolved value should be a single-element list.

Concrete consequences of the None element:

  • SamFunctionProvider._parse_layer_info falls through to the final else and logs layer "None" is not recognizable, it might be using intrinsic functions that we don't support yet. Skipping. — misleading, since nothing unsupported was used.
  • List properties consumed positionally get None. For Architectures: [!If [UseArm, arm64, !Ref "AWS::NoValue"]], Function.architectures becomes [None], and validate_architecture_runtime (samcli/lib/utils/architecture.py:71) raises UnsupportedRuntimeArchitectureError: Runtime ... is not supported on 'None' architecture. _get_function_architecture (samcli/lib/build/utils.py:51) returns None instead of defaulting to X86_64.

Applying the same rule the new comment states ("CloudFormation drops such properties entirely") to lists keeps the two branches consistent:

if isinstance(intrinsic, list):
   resolved_items = [self.intrinsic_property_resolver(item, ignore_errors) for item in intrinsic]
   # An item that resolves to None was Fn::If-ed to AWS::NoValue; CloudFormation
   # removes the item from the list rather than keeping a null entry.
   return [item for item in resolved_items if item is not None]

This is safe: a literal null element in a template never reaches the filter, because intrinsic_property_resolver raises InvalidIntrinsicException on None input and the list comprehension has no try/except, so None elements can only originate from AWS::NoValue.

sanitized_dict[sanitized_key] = sanitized_val
# On any exception, leave the key:val of the orginal intact and continue on.
# https://github.com/awslabs/aws-sam-cli/issues/1386
except Exception:
Expand Down Expand Up @@ -716,24 +719,14 @@ def handle_fn_if(self, intrinsic_value, ignore_errors):
-------
This will return value_if_true and value_if_false depending on how the condition is evaluated
"""
arguments = self.intrinsic_property_resolver(
intrinsic_value, ignore_errors, parent_function=IntrinsicResolver.FN_IF
)
verify_intrinsic_type_list(arguments, IntrinsicResolver.FN_IF)
verify_number_arguments(arguments, IntrinsicResolver.FN_IF, num=3)
verify_intrinsic_type_list(intrinsic_value, IntrinsicResolver.FN_IF)
verify_number_arguments(intrinsic_value, IntrinsicResolver.FN_IF, num=3)

condition_name = self.intrinsic_property_resolver(
arguments[0], ignore_errors, parent_function=IntrinsicResolver.FN_IF
intrinsic_value[0], ignore_errors, parent_function=IntrinsicResolver.FN_IF
)
verify_intrinsic_type_str(condition_name, IntrinsicResolver.FN_IF)

value_if_true = self.intrinsic_property_resolver(
arguments[1], ignore_errors, parent_function=IntrinsicResolver.FN_IF
)
value_if_false = self.intrinsic_property_resolver(
arguments[2], ignore_errors, parent_function=IntrinsicResolver.FN_IF
)

condition = self._conditions.get(condition_name)
verify_intrinsic_type_dict(
condition,
Expand All @@ -750,7 +743,8 @@ def handle_fn_if(self, intrinsic_value, ignore_errors):
message="The result of {} must evaluate to bool".format(IntrinsicResolver.FN_IF),
)

return value_if_true if condition_evaluated else value_if_false
selected_value = intrinsic_value[1] if condition_evaluated else intrinsic_value[2]
return self.intrinsic_property_resolver(selected_value, ignore_errors, parent_function=IntrinsicResolver.FN_IF)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] handle_fn_if can now return None, which it could never do before, and at least one downstream consumer is not null-safe.

Before this change, a branch containing !Ref AWS::NoValue was pre-resolved to None by the outer intrinsic_property_resolver(intrinsic_value, ...) call, and the subsequent resolve(arguments[1|2]) hit the if intrinsic is None: raise InvalidIntrinsicException guard at the top of intrinsic_property_resolver. So Fn::If always raised, and with ignore_errors=True the enclosing dict loop left the property as the raw {"Fn::If": [...]} dict.

Now the selected branch is resolved directly, so !Ref AWS::NoValue reaches IntrinsicsSymbolTable.handle_pseudo_no_value() and None is returned and assigned as the property value (the generic dict branch does sanitized_dict[sanitized_key] = sanitized_val with no None filtering).

Concrete failure — the common "conditionally omit a property" idiom:

Properties:
 Layers: !If [UseLayers, [!Ref MyLayer], !Ref "AWS::NoValue"]

When UseLayers is false, Properties["Layers"] becomes None. In samcli/lib/providers/sam_function_provider.py:265, resource_properties.get("Layers", []) returns None (the default only applies when the key is absent), and _parse_layer_info then does for layer in list_of_layersTypeError: 'NoneType' object is not iterable, an unhandled traceback instead of a domain error.

Note the element-level form Layers: [!If [Cond, !Ref MyLayer, !Ref "AWS::NoValue"]] is fine — it yields [None] and _parse_layer_info skips unrecognized entries. Only the whole-property form breaks, and that is exactly one of the scenarios this PR sets out to fix, so it is worth closing here rather than leaving it as a newly reachable crash.

Two options:

  • Make the resolver match CloudFormation's AWS::NoValue semantics by dropping keys whose resolved value is None in the generic dict branch of intrinsic_property_resolver. This is the semantically correct fix but has wider blast radius, so it needs its own tests.
  • Harden the consumer, e.g. resource_properties.get("Layers") or [] in both call sites in sam_function_provider.py.

Either way, please add a unit test covering an Fn::If whose selected branch is !Ref AWS::NoValue — the four new tests only cover unresolvable Fn::GetAtt in the unselected branch, so this path is currently untested.

Note on the previous review comment: the handle_fn_getatt change that forwarded ignore_errors into resolve_symbols is no longer in the diff, and test_template_ignore_errors_leaves_unresolvable_layer_getatt_as_dict was added as a guard for the nested-stack layer behavior. That finding is resolved and I did not re-raise it. The PR description still describes the handle_fn_getatt change, so it is now out of date.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed
b63826a

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] handle_fn_if can now return None, and the or [] patches in sam_function_provider.py only cover two of the affected call sites.

When the selected branch is {"Ref": "AWS::NoValue"}, resolve_symbols routes to IntrinsicsSymbolTable.handle_pseudo_no_value(), which returns Python None. Before this change that None hit the if intrinsic is None: raise InvalidIntrinsicException guard, so handle_fn_if always raised and the enclosing dict branch (with ignore_errors=True) left the raw {"Fn::If": [...]} in place. Now None is returned and stored as the property value:

# intrinsic_property_resolver, dict branch
sanitized_val = self.intrinsic_property_resolver(val, ignore_errors, parent_function=parent_function)
...
sanitized_dict[sanitized_key] = sanitized_val   # key kept, value is None

The key is retained rather than dropped, which does not match CloudFormation's AWS::NoValue semantics (the property is removed). A concrete crash path is SamApiProvider, which reads Events off the intrinsic-resolved stack.resources:

Events:
 ApiEvent: !If [UseApi, {Type: Api, Properties: {Path: /, Method: get}}, !Ref "AWS::NoValue"]

With UseApi false this resolves to {"ApiEvent": None}, and sam local start-api then hits samcli/lib/providers/sam_api_provider.py:469:

for , event in serverlessfunction_events.items():
   event_type = event.get(self._EVENT_TYPE)   # AttributeError: 'NoneType' object has no attribute 'get'

Previously that entry stayed as the raw Fn::If dict and was silently skipped. The same shape applies to properties.get("Events", {}) / properties.get(AUTHORIZER_TYPE, "").lower() style lookups elsewhere — a .get(key, default) default never fires when the key is present with a None value, so patching individual consumers with or [] will keep surfacing new failures.

Normalizing at the resolver would fix the whole class at once, and there is already precedent for exactly this in the codebase — CfnLanguageExtensionsApi._remove_no_value in samcli/lib/cfn_language_extensions/api.py:482, whose docstring notes "When Fn::If returns AWS::NoValue, the resolver returns None" and which skips such keys and list items (while deliberately preserving AWS::NoValue inside intrinsic-function arguments).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed
ace7482


def handle_fn_equals(self, intrinsic_value, ignore_errors):
"""
Expand Down
4 changes: 2 additions & 2 deletions samcli/lib/providers/sam_function_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ def _extract_functions(
if resource_type == AWS_SERVERLESS_FUNCTION:
layers = SamFunctionProvider._parse_layer_info(
stack,
resource_properties.get("Layers", []),
resource_properties.get("Layers") or [],
use_raw_codeuri,
ignore_code_extraction_warnings=ignore_code_extraction_warnings,
locate_layer_nested=locate_layer_nested,
Expand All @@ -281,7 +281,7 @@ def _extract_functions(
elif resource_type == AWS_LAMBDA_FUNCTION:
layers = SamFunctionProvider._parse_layer_info(
stack,
resource_properties.get("Layers", []),
resource_properties.get("Layers") or [],
use_raw_codeuri,
ignore_code_extraction_warnings=ignore_code_extraction_warnings,
locate_layer_nested=locate_layer_nested,
Expand Down
21 changes: 21 additions & 0 deletions tests/integration/local/invoke/test_integrations_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,27 @@ def test_invoke_with_env_using_parameters(self):
self.assertEqual(environ["MyRuntimeVersion"], "v0")
self.assertEqual(environ["EmptyDefaultParameter"], "")

@pytest.mark.flaky(reruns=3)
def test_invoke_with_env_using_fn_if_ignores_unresolvable_branch(self):
command_list = InvokeIntegBase.get_command_list(
"EchoEnvWithFnIf",
template_path=self.template_path,
event_path=self.event_path,
)

process = Popen(command_list, stdout=PIPE)
try:
stdout, _ = process.communicate(timeout=TIMEOUT)
except TimeoutExpired:
process.kill()
raise

self.assertEqual(process.returncode, 0)
process_stdout = stdout.strip()
environ = json.loads(process_stdout.decode("utf-8"))

self.assertEqual(environ["FunctionUrl"], "https://custom.example.com/")

@pytest.mark.flaky(reruns=3)
def test_invoke_multi_tenant_function(self):
command_list = InvokeIntegBase.get_command_list(
Expand Down
35 changes: 35 additions & 0 deletions tests/integration/testdata/invoke/template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ Parameters:
Type: String
Default: "2"

UseCustomFunctionUrl:
Type: String
Default: "true"

CustomFunctionUrl:
Type: String
Default: "https://custom.example.com/"

Conditions:
ShouldUseCustomFunctionUrl: !Equals [!Ref UseCustomFunctionUrl, "true"]

Mappings:
common:
LambdaFunction:
Expand Down Expand Up @@ -202,6 +213,30 @@ Resources:
MyRuntimeVersion: !Ref MyRuntimeVersion
EmptyDefaultParameter: !Ref EmptyDefaultParameter

FunctionWithUrlConfig:
Type: AWS::Serverless::Function
Properties:
Handler: main.handler
Runtime: python3.9
CodeUri: .
Timeout: 600
FunctionUrlConfig:
AuthType: NONE

EchoEnvWithFnIf:
Type: AWS::Serverless::Function
Properties:
Handler: main.env_var_echo_hanler
Runtime: python3.9
CodeUri: .
Timeout: 600
Environment:
Variables:
FunctionUrl: !If
- ShouldUseCustomFunctionUrl
- !Ref CustomFunctionUrl
- !GetAtt FunctionWithUrlConfigUrl.FunctionUrl

TimeoutFunctionWithStringParameter:
Type: AWS::Serverless::Function
Properties:
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/commands/local/lib/test_sam_function_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,26 @@ def test_must_work_with_no_properties(self, convert_mock, resources_mock):
False,
)

@patch("samcli.lib.providers.sam_function_provider.Stack.resources", new_callable=PropertyMock)
@patch.object(SamFunctionProvider, "_convert_sam_function_resource")
def test_must_treat_none_layers_as_empty_list(self, convert_mock, resources_mock):
# Layers resolves to None when it is defined as Fn::If selecting AWS::NoValue,
# e.g. Layers: !If [UseLayers, [!Ref MyLayer], !Ref "AWS::NoValue"]
convertion_result = Mock()
convertion_result.full_path = "A/B/C/Func1"
convert_mock.return_value = convertion_result

resources_mock.return_value = {
"Func1": {"Type": "AWS::Serverless::Function", "Properties": {"Layers": None}}
}

expected = {"A/B/C/Func1": convertion_result}

stack = make_root_stack(None)
result = SamFunctionProvider._extract_functions([stack])
self.assertEqual(expected, result)
convert_mock.assert_called_with(stack, "Func1", {"Layers": None}, [], False)

@patch("samcli.lib.providers.sam_function_provider.Stack.resources", new_callable=PropertyMock)
@patch.object(SamFunctionProvider, "_convert_lambda_function_resource")
def test_must_work_for_lambda_function(self, convert_mock, resources_mock):
Expand Down
82 changes: 81 additions & 1 deletion tests/unit/lib/intrinsic_resolver/test_intrinsic_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from copy import deepcopy
from pathlib import Path
from unittest import TestCase
from unittest.mock import patch
from unittest.mock import MagicMock, patch

from parameterized import parameterized

Expand Down Expand Up @@ -906,6 +906,38 @@ def test_fn_if_condition_not_bool_fail(self):
with self.assertRaises(InvalidIntrinsicException, msg="Invalid Condition"):
self.resolver.intrinsic_property_resolver({"Fn::If": ["InvalidCondition", "test", "test"]}, True)

def test_fn_if_selects_resolvable_true_branch_ignoring_unresolvable_false_branch(self):
intrinsic = {"Fn::If": ["TestCondition", "resolved-value", {"Fn::GetAtt": ["Function2Url", "FunctionUrl"]}]}

result = self.resolver.intrinsic_property_resolver(intrinsic, False)
self.assertEqual(result, "resolved-value")

def test_fn_if_selects_resolvable_false_branch_ignoring_unresolvable_true_branch(self):
intrinsic = {"Fn::If": ["NotTestCondition", {"Fn::GetAtt": ["Function2Url", "FunctionUrl"]}, "resolved-value"]}

result = self.resolver.intrinsic_property_resolver(intrinsic, False)
self.assertEqual(result, "resolved-value")

def test_fn_if_does_not_evaluate_unselected_false_branch(self):
mock_handle_fn_getatt = MagicMock()
self.resolver.intrinsic_key_function_map[IntrinsicResolver.FN_GET_ATT] = mock_handle_fn_getatt
intrinsic = {"Fn::If": ["TestCondition", "resolved-value", {"Fn::GetAtt": ["Function2Url", "FunctionUrl"]}]}

result = self.resolver.intrinsic_property_resolver(intrinsic, False)

self.assertEqual(result, "resolved-value")
mock_handle_fn_getatt.assert_not_called()

def test_fn_if_does_not_evaluate_unselected_true_branch(self):
mock_handle_fn_getatt = MagicMock()
self.resolver.intrinsic_key_function_map[IntrinsicResolver.FN_GET_ATT] = mock_handle_fn_getatt
intrinsic = {"Fn::If": ["NotTestCondition", {"Fn::GetAtt": ["Function2Url", "FunctionUrl"]}, "resolved-value"]}

result = self.resolver.intrinsic_property_resolver(intrinsic, False)

self.assertEqual(result, "resolved-value")
mock_handle_fn_getatt.assert_not_called()


class TestIntrinsicAttribteResolution(TestCase):
def setUp(self):
Expand Down Expand Up @@ -1013,6 +1045,54 @@ def test_template_ignore_errors(self):
}
self.assertEqual(expected_template, dict(result))

def test_template_ignore_errors_leaves_unresolvable_layer_getatt_as_dict(self):
resources = deepcopy(self.resources)
resources["ReferenceLambdaLayerVersionLambdaFunction"]["Properties"]["Layers"] = [
{"Fn::GetAtt": ["NestedStack", "Outputs.MyDepLayer"]}
]
template = {"Mappings": self.mappings, "Conditions": self.conditions, "Resources": resources}
symbol_resolver = IntrinsicsSymbolTable(template=template, logical_id_translator=self.logical_id_translator)
resolver = IntrinsicResolver(template=template, symbol_resolver=symbol_resolver)

result = resolver.resolve_attribute(resources, ignore_errors=True)

self.assertEqual(
result["ReferenceLambdaLayerVersionLambdaFunction"]["Properties"]["Layers"],
[{"Fn::GetAtt": ["NestedStack", "Outputs.MyDepLayer"]}],
)

def test_fn_if_no_value_drops_whole_property(self):
resources = deepcopy(self.resources)
resources["ReferenceLambdaLayerVersionLambdaFunction"]["Properties"]["Layers"] = {
"Fn::If": ["TestCondition", [{"Ref": "MyCustomLambdaLayer"}], {"Ref": "AWS::NoValue"}]
}
template = {"Mappings": self.mappings, "Conditions": self.conditions, "Resources": resources}
symbol_resolver = IntrinsicsSymbolTable(template=template, logical_id_translator=self.logical_id_translator)
resolver = IntrinsicResolver(template=template, symbol_resolver=symbol_resolver)

result = resolver.resolve_attribute(resources, ignore_errors=True)

self.assertNotIn("Layers", result["ReferenceLambdaLayerVersionLambdaFunction"]["Properties"])

def test_fn_if_no_value_drops_nested_dict_key(self):
resources = deepcopy(self.resources)
resources["ReferenceLambdaLayerVersionLambdaFunction"]["Properties"]["Events"] = {
"ApiEvent": {
"Fn::If": [
"TestCondition",
{"Type": "Api", "Properties": {"Path": "/", "Method": "get"}},
{"Ref": "AWS::NoValue"},
]
}
}
template = {"Mappings": self.mappings, "Conditions": self.conditions, "Resources": resources}
symbol_resolver = IntrinsicsSymbolTable(template=template, logical_id_translator=self.logical_id_translator)
resolver = IntrinsicResolver(template=template, symbol_resolver=symbol_resolver)

result = resolver.resolve_attribute(resources, ignore_errors=True)

self.assertEqual(result["ReferenceLambdaLayerVersionLambdaFunction"]["Properties"]["Events"], {})


class TestResolveTemplate(TestCase):
def test_parameter_not_resolved(self):
Expand Down