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
18 changes: 12 additions & 6 deletions samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."""
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/lib/cfn_language_extensions/test_fn_find_in_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# =============================================================================
Expand Down Expand Up @@ -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
Expand Down