From 6ca51c8007b7d520eb6a2e734d4805fa7f69058a Mon Sep 17 00:00:00 2001 From: Takumi Ishikawa <20138086+polyomino24@users.noreply.github.com> Date: Thu, 14 May 2026 18:49:34 +0900 Subject: [PATCH] fix: preserve Fn::FindInMap with unresolved Ref keys in PARTIAL mode FnFindInMapResolver was unconditionally raising "Fn::FindInMap layout is incorrect" when a key arrived as an unresolved {"Ref": ...} dict in PARTIAL mode, breaking `sam build` for any AWS::LanguageExtensions template using !Ref to a parameter as a FindInMap key when no value was supplied. Mirror FnRefResolver's pattern: preserve the expression in PARTIAL mode and let CloudFormation resolve at deploy time. Real layout errors (non-dict, non-string keys) still raise. Fixes #9004 --- .../resolvers/fn_find_in_map.py | 18 ++++--- .../test_kotlin_compatibility.py | 30 ++++++++++- .../test_fn_find_in_map.py | 50 +++++++++++++++++++ 3 files changed, 91 insertions(+), 7 deletions(-) diff --git a/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py b/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py index 7a73056c40c..47d4cd3bbdd 100644 --- a/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py +++ b/samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py @@ -95,12 +95,18 @@ def resolve(self, value: Dict[str, Any]) -> Any: top_key = top_key_arg second_key = second_key_arg - # Validate resolved keys are strings - if not isinstance(map_name, str): - raise InvalidTemplateException("Fn::FindInMap layout is incorrect") - if not isinstance(top_key, str): - raise InvalidTemplateException("Fn::FindInMap layout is incorrect") - if not isinstance(second_key, str): + # In PARTIAL mode an unresolved nested intrinsic (e.g. {"Ref": "ENV"}) + # arrives here as a dict — preserve the Fn::FindInMap expression so + # CloudFormation can resolve it at deploy time. Non-dict, non-string keys + # (e.g. an int literal) remain a layout error in either mode. + if not isinstance(map_name, str) or not isinstance(top_key, str) or not isinstance(second_key, str): + from samcli.lib.cfn_language_extensions.models import ResolutionMode + + has_unresolved_intrinsic = ( + isinstance(map_name, dict) or isinstance(top_key, dict) or isinstance(second_key, dict) + ) + if has_unresolved_intrinsic and self.context.resolution_mode == ResolutionMode.PARTIAL: + return value raise InvalidTemplateException("Fn::FindInMap layout is incorrect") # Check for DefaultValue option (4th argument) diff --git a/tests/unit/lib/cfn_language_extensions/compatibility/test_kotlin_compatibility.py b/tests/unit/lib/cfn_language_extensions/compatibility/test_kotlin_compatibility.py index 23d901447da..d333abdfd06 100644 --- a/tests/unit/lib/cfn_language_extensions/compatibility/test_kotlin_compatibility.py +++ b/tests/unit/lib/cfn_language_extensions/compatibility/test_kotlin_compatibility.py @@ -32,7 +32,10 @@ from samcli.lib.cfn_language_extensions.api import process_template from samcli.lib.cfn_language_extensions.models import ResolutionMode, PseudoParameterValues -from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException +from samcli.lib.cfn_language_extensions.exceptions import ( + InvalidTemplateException, + UnresolvableReferenceError, +) # Path to test templates KOTLIN_TEMPLATES_DIR = Path(__file__).parent / "templates" @@ -189,6 +192,13 @@ def test_foreach_conditions(test_name: str, input_path: Path, expected_path: Pat "fnFindInMapWithDefaultValueWithMapTopKeyNotResolveToString", "fnFindInMapWithMapNameNotResolveToString", "fnFindInMapWithReferenceToIncorrectParameterType", +] + +# Templates that the Kotlin reference treats as errors, but whose error in +# Python depends on resolution mode: PARTIAL preserves unresolvable resource +# references inside Fn::FindInMap so the expression survives to CloudFormation; +# FULL keeps Kotlin parity and treats them as layout errors. +ERROR_TEMPLATES_FULL_MODE_ONLY = [ "fnFindInMapWithUnsupportedFunctionFnGetAtt", "fnFindInMapWithUnsupportedFunctionFnRef", "fnFindInMapWithUnsupportedFunctionInMapName", @@ -217,6 +227,24 @@ def test_error_templates_passing(template_name: str): process_template(input_template) +@pytest.mark.parametrize("template_name", ERROR_TEMPLATES_FULL_MODE_ONLY) +def test_error_templates_passing_in_full_mode(template_name: str): + """Templates that match Kotlin's strict error behavior only in FULL mode. + + PARTIAL mode preserves these intentionally; FULL mode keeps Kotlin parity + for callers that need strict resolution. + """ + input_path = KOTLIN_TEMPLATES_DIR / f"{template_name}.json" + if not input_path.exists(): + pytest.skip(f"Template {template_name} not available") + input_template = load_json_template(input_path) + # Depending on which resolver fires first, FULL mode raises either + # InvalidTemplateException (FindInMap layout error) or + # UnresolvableReferenceError (Ref to a resource). + with pytest.raises((InvalidTemplateException, UnresolvableReferenceError)): + process_template(input_template, resolution_mode=ResolutionMode.FULL) + + @pytest.mark.parametrize("template_name", ERROR_TEMPLATES_WITH_PLACEHOLDER) def test_error_templates_with_placeholder(template_name: str): """Test templates with $RESOURCE_ATTRIBUTE placeholder that should raise errors.""" diff --git a/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py b/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py index b334618305d..a526a56586c 100644 --- a/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py +++ b/tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py @@ -38,6 +38,7 @@ IntrinsicResolver, ) from samcli.lib.cfn_language_extensions.resolvers.fn_find_in_map import FnFindInMapResolver +from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException # ============================================================================= @@ -711,6 +712,55 @@ def test_fn_find_in_map_with_preserved_intrinsic(self, orchestrator: IntrinsicRe "preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]}, } + @pytest.fixture + def partial_orchestrator_with_ref(self, partial_context: TemplateProcessingContext) -> IntrinsicResolver: + """Orchestrator in partial mode with both FnFindInMap and FnRef registered.""" + orchestrator = IntrinsicResolver(partial_context) + orchestrator.register_resolver(FnFindInMapResolver) + orchestrator.register_resolver(FnRefResolver) + return orchestrator + + def test_fn_find_in_map_preserves_expression_when_top_key_unresolved_in_partial_mode( + self, partial_orchestrator_with_ref: IntrinsicResolver + ): + """Unresolved Ref in the top-level key position is preserved in partial mode.""" + value = {"Fn::FindInMap": ["RegionMap", {"Ref": "ENV"}, "AMI"]} + result = partial_orchestrator_with_ref.resolve_value(value) + + assert result == {"Fn::FindInMap": ["RegionMap", {"Ref": "ENV"}, "AMI"]} + + def test_fn_find_in_map_preserves_expression_when_map_name_unresolved_in_partial_mode( + self, partial_orchestrator_with_ref: IntrinsicResolver + ): + """Unresolved Ref in the map-name position is also preserved in partial mode.""" + value = {"Fn::FindInMap": [{"Ref": "ENV"}, "us-east-1", "AMI"]} + result = partial_orchestrator_with_ref.resolve_value(value) + + assert result == {"Fn::FindInMap": [{"Ref": "ENV"}, "us-east-1", "AMI"]} + + def test_fn_find_in_map_preserves_expression_when_second_key_unresolved_in_partial_mode( + self, partial_orchestrator_with_ref: IntrinsicResolver + ): + """Unresolved Ref in the second-level key position is also preserved in partial mode.""" + value = {"Fn::FindInMap": ["RegionMap", "us-east-1", {"Ref": "ENV"}]} + result = partial_orchestrator_with_ref.resolve_value(value) + + assert result == {"Fn::FindInMap": ["RegionMap", "us-east-1", {"Ref": "ENV"}]} + + def test_fn_find_in_map_still_raises_for_non_string_key_in_full_mode(self, mappings: Dict[str, Any]): + """FULL mode keeps raising InvalidTemplateException for a non-string key.""" + full_context = TemplateProcessingContext( + fragment={"Resources": {}, "Mappings": mappings}, + resolution_mode=ResolutionMode.FULL, + ) + full_context.parsed_template = ParsedTemplate(resources={}, mappings=mappings) + resolver = FnFindInMapResolver(full_context) + + value = {"Fn::FindInMap": ["RegionMap", {"Ref": "ENV"}, "AMI"]} + with pytest.raises(InvalidTemplateException) as exc_info: + resolver.resolve(value) + assert "Fn::FindInMap layout is incorrect" in str(exc_info.value) + # ============================================================================= # Unit Tests for Edge Cases