Skip to content

Commit 9006343

Browse files
authored
fix(cfn-lang-ext): preserve template-time intrinsics with unresolved param Refs in PARTIAL mode (#9004) (#9010)
* fix: preserve Fn::FindInMap with unresolved param Ref keys in PARTIAL mode (#9004) * fix: preserve Fn::Join with unresolved param Ref args in PARTIAL mode * fix: widen Fn::Join resolve() return type to Any for PARTIAL preservation * fix: preserve Fn::Select with unresolved param Ref index in PARTIAL mode * fix: preserve Fn::Base64 with unresolved param Ref arg in PARTIAL mode * test: end-to-end regression test for issue #9004 * test: rename new test classes in test_fn_find_in_map.py to avoid name collision * refactor: consolidate is_unresolved_param_or_pseudo_ref into utils Hoist the helper duplicated in four resolvers (fn_find_in_map, fn_join, fn_select, fn_base64) up to samcli/lib/cfn_language_extensions/utils.py next to PSEUDO_PARAMETERS. Each resolver now imports the shared helper and drops its private copy. Adds focused unit tests in test_utils.py covering parameter Refs, pseudo-parameter Refs, resource Refs, Fn::GetAtt, multi-key dicts, non-string Ref targets, and contexts without a parsed_template. * style: apply black formatting
1 parent c406852 commit 9006343

10 files changed

Lines changed: 360 additions & 15 deletions

File tree

samcli/lib/cfn_language_extensions/resolvers/fn_base64.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
from typing import Any, Dict
1010

1111
from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException
12+
from samcli.lib.cfn_language_extensions.models import ResolutionMode
1213
from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver
14+
from samcli.lib.cfn_language_extensions.utils import is_unresolved_param_or_pseudo_ref
1315

1416

1517
class FnBase64Resolver(IntrinsicFunctionResolver):
@@ -32,7 +34,7 @@ class FnBase64Resolver(IntrinsicFunctionResolver):
3234

3335
FUNCTION_NAMES = ["Fn::Base64"]
3436

35-
def resolve(self, value: Dict[str, Any]) -> str:
37+
def resolve(self, value: Dict[str, Any]) -> Any:
3638
"""
3739
Resolve the Fn::Base64 intrinsic function.
3840
@@ -46,7 +48,10 @@ def resolve(self, value: Dict[str, Any]) -> str:
4648
{"Fn::Base64": {"Ref": "MyStringParam"}}
4749
4850
Returns:
49-
The base64-encoded string.
51+
The base64-encoded string. In PARTIAL resolution mode, if the
52+
argument is an unresolved Ref to a declared template parameter or a
53+
pseudo-parameter, the original Fn::Base64 call is returned verbatim
54+
so CloudFormation can resolve it at deploy time.
5055
5156
Raises:
5257
InvalidTemplateException: If the resolved value is not a string.
@@ -64,6 +69,15 @@ def resolve(self, value: Dict[str, Any]) -> str:
6469
# If no parent resolver, use args as-is (for testing)
6570
resolved_args = args
6671

72+
# In PARTIAL mode, if the argument resolved to a deferred parameter Ref
73+
# (a Ref to a declared parameter without a default/override, or to a
74+
# pseudo-parameter), preserve the call so CloudFormation can resolve it
75+
# at deploy time. Resource refs / GetAtt / etc. still raise.
76+
if is_unresolved_param_or_pseudo_ref(resolved_args, self.context):
77+
if self.context.resolution_mode == ResolutionMode.PARTIAL:
78+
return {"Fn::Base64": resolved_args}
79+
raise InvalidTemplateException("Fn::Base64 layout is incorrect")
80+
6781
# Validate that the resolved value is a string
6882
if not isinstance(resolved_args, str):
6983
raise InvalidTemplateException("Fn::Base64 layout is incorrect")

samcli/lib/cfn_language_extensions/resolvers/fn_find_in_map.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
from typing import Any, Dict
1313

1414
from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException
15+
from samcli.lib.cfn_language_extensions.models import ResolutionMode
1516
from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver
17+
from samcli.lib.cfn_language_extensions.utils import is_unresolved_param_or_pseudo_ref
1618

1719

1820
class FnFindInMapResolver(IntrinsicFunctionResolver):
@@ -95,12 +97,23 @@ def resolve(self, value: Dict[str, Any]) -> Any:
9597
top_key = top_key_arg
9698
second_key = second_key_arg
9799

98-
# Validate resolved keys are strings
99-
if not isinstance(map_name, str):
100-
raise InvalidTemplateException("Fn::FindInMap layout is incorrect")
101-
if not isinstance(top_key, str):
102-
raise InvalidTemplateException("Fn::FindInMap layout is incorrect")
103-
if not isinstance(second_key, str):
100+
# In PARTIAL mode, if a key didn't resolve to a string but did resolve
101+
# to a deferred parameter/pseudo-parameter Ref (Ref to a declared
102+
# parameter without a default value and no override, or to a
103+
# pseudo-parameter without a provided value), preserve the call so
104+
# CloudFormation can resolve it at deploy time. See GitHub issue #9004.
105+
# Resource refs and other intrinsics (Fn::GetAtt, etc.) continue to
106+
# raise because they can't be resolved by the template-time
107+
# Fn::FindInMap function at any stage — matching Kotlin compat.
108+
keys = (map_name, top_key, second_key)
109+
if not all(isinstance(k, str) for k in keys):
110+
if self.context.resolution_mode == ResolutionMode.PARTIAL and all(
111+
isinstance(k, str) or is_unresolved_param_or_pseudo_ref(k, self.context) for k in keys
112+
):
113+
preserved = [map_name, top_key, second_key]
114+
if len(args) >= self._ARGS_WITH_DEFAULT:
115+
preserved.append(args[3])
116+
return {"Fn::FindInMap": preserved}
104117
raise InvalidTemplateException("Fn::FindInMap layout is incorrect")
105118

106119
# Check for DefaultValue option (4th argument)

samcli/lib/cfn_language_extensions/resolvers/fn_join.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from typing import Any, Dict
1111

1212
from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException
13+
from samcli.lib.cfn_language_extensions.models import ResolutionMode
1314
from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver
15+
from samcli.lib.cfn_language_extensions.utils import is_unresolved_param_or_pseudo_ref
1416

1517

1618
class FnJoinResolver(IntrinsicFunctionResolver):
@@ -37,7 +39,7 @@ class FnJoinResolver(IntrinsicFunctionResolver):
3739

3840
_EXPECTED_ARGS = 2
3941

40-
def resolve(self, value: Dict[str, Any]) -> str:
42+
def resolve(self, value: Dict[str, Any]) -> Any:
4143
"""
4244
Resolve the Fn::Join intrinsic function.
4345
@@ -50,7 +52,9 @@ def resolve(self, value: Dict[str, Any]) -> str:
5052
E.g., {"Fn::Join": [",", ["a", "b", "c"]]}
5153
5254
Returns:
53-
A string with all list elements joined by the delimiter.
55+
A string with all list elements joined by the delimiter, or - in
56+
PARTIAL mode when an arg is an unresolved parameter Ref - the
57+
preserved Fn::Join call as a dict.
5458
5559
Raises:
5660
InvalidTemplateException: If the layout is incorrect.
@@ -70,14 +74,25 @@ def resolve(self, value: Dict[str, Any]) -> str:
7074
if self.parent is not None:
7175
delimiter = self.parent.resolve_value(delimiter)
7276

73-
# Validate delimiter is a string
74-
if not isinstance(delimiter, str):
75-
raise InvalidTemplateException("Fn::Join layout is incorrect")
76-
7777
# Resolve any nested intrinsic functions in the list
7878
if self.parent is not None:
7979
list_to_join = self.parent.resolve_value(list_to_join)
8080

81+
# In PARTIAL mode, preserve the call when either argument is still an
82+
# unresolved Ref to a declared template parameter or a pseudo-parameter
83+
# (i.e. CloudFormation will resolve it at deploy time). Resource refs
84+
# and other intrinsics still raise — they aren't valid Fn::Join inputs.
85+
delim_is_param_ref = is_unresolved_param_or_pseudo_ref(delimiter, self.context)
86+
list_is_param_ref = is_unresolved_param_or_pseudo_ref(list_to_join, self.context)
87+
if delim_is_param_ref or list_is_param_ref:
88+
if self.context.resolution_mode == ResolutionMode.PARTIAL:
89+
return {"Fn::Join": [delimiter, list_to_join]}
90+
raise InvalidTemplateException("Fn::Join layout is incorrect")
91+
92+
# Validate delimiter is a string
93+
if not isinstance(delimiter, str):
94+
raise InvalidTemplateException("Fn::Join layout is incorrect")
95+
8196
# Validate the list
8297
if not isinstance(list_to_join, list):
8398
raise InvalidTemplateException("Fn::Join layout is incorrect")

samcli/lib/cfn_language_extensions/resolvers/fn_select.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
from typing import Any, Dict
1111

1212
from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException
13+
from samcli.lib.cfn_language_extensions.models import ResolutionMode
1314
from samcli.lib.cfn_language_extensions.resolvers.base import IntrinsicFunctionResolver
15+
from samcli.lib.cfn_language_extensions.utils import is_unresolved_param_or_pseudo_ref
1416

1517

1618
class FnSelectResolver(IntrinsicFunctionResolver):
@@ -73,6 +75,18 @@ def resolve(self, value: Dict[str, Any]) -> Any:
7375
if self.parent is not None:
7476
index = self.parent.resolve_value(index)
7577

78+
# In PARTIAL mode, if the index resolved to a deferred parameter Ref,
79+
# preserve the call. Resource refs / GetAtt / etc. still raise.
80+
# We must still resolve the source list below in case it contains
81+
# resolvable intrinsics — that way, partial expansion makes maximal
82+
# progress.
83+
if is_unresolved_param_or_pseudo_ref(index, self.context):
84+
if self.context.resolution_mode != ResolutionMode.PARTIAL:
85+
raise InvalidTemplateException("Fn::Select layout is incorrect")
86+
if self.parent is not None:
87+
source_list = self.parent.resolve_value(source_list)
88+
return {"Fn::Select": [index, source_list]}
89+
7690
# Validate index is an integer (or can be converted to one)
7791
if isinstance(index, str):
7892
try:

samcli/lib/cfn_language_extensions/utils.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
for working with templates that may contain Fn::ForEach blocks.
66
"""
77

8-
from typing import Dict, Iterator, Tuple
8+
from typing import TYPE_CHECKING, Any, Dict, Iterator, Tuple
9+
10+
if TYPE_CHECKING:
11+
from samcli.lib.cfn_language_extensions.models import TemplateProcessingContext
912

1013
FOREACH_PREFIX = "Fn::ForEach::"
1114

@@ -65,6 +68,31 @@ def is_intrinsic_key(key: str) -> bool:
6568
return key.startswith("Fn::") or key in _INTRINSIC_SINGLE_KEYS
6669

6770

71+
def is_unresolved_param_or_pseudo_ref(value: Any, context: "TemplateProcessingContext") -> bool:
72+
"""Return True if *value* is ``{"Ref": <name>}`` where ``<name>`` is a declared
73+
template parameter or a pseudo-parameter — i.e. an unresolved reference that
74+
CloudFormation will resolve at deploy time.
75+
76+
Used by template-time intrinsic resolvers (Fn::FindInMap, Fn::Join, Fn::Select,
77+
Fn::Base64) to decide, in PARTIAL resolution mode, whether to preserve the
78+
enclosing call instead of raising. Resource refs and other intrinsics return
79+
False so they continue to raise — matching Kotlin compatibility for
80+
template-time intrinsics that genuinely cannot accept deploy-time inputs.
81+
"""
82+
if not isinstance(value, dict) or len(value) != 1:
83+
return False
84+
if "Ref" not in value:
85+
return False
86+
ref_target = value["Ref"]
87+
if not isinstance(ref_target, str):
88+
return False
89+
if ref_target in PSEUDO_PARAMETERS:
90+
return True
91+
if context.parsed_template is not None and ref_target in context.parsed_template.parameters:
92+
return True
93+
return False
94+
95+
6896
# Mapping-name prefixes that SAM CLI emits for dynamic Fn::ForEach handling:
6997
# - SAM + <packageable artifact property> + <nesting path> + [resource suffix]
7098
# (see language_extensions_packaging._compute_mapping_name)

tests/unit/lib/cfn_language_extensions/test_fn_base64.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
IntrinsicResolver,
2626
)
2727
from samcli.lib.cfn_language_extensions.resolvers.fn_base64 import FnBase64Resolver
28+
from samcli.lib.cfn_language_extensions.resolvers.fn_ref import FnRefResolver
2829
from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException
2930

3031

@@ -478,6 +479,22 @@ def test_fn_base64_with_preserved_intrinsic(self, orchestrator: IntrinsicResolve
478479
"preserved": {"Fn::GetAtt": ["MyBucket", "Arn"]},
479480
}
480481

482+
def test_unresolved_arg_is_preserved_in_partial_mode(self):
483+
from samcli.lib.cfn_language_extensions.models import ParsedTemplate
484+
485+
ctx = TemplateProcessingContext(
486+
fragment={"Resources": {}},
487+
resolution_mode=ResolutionMode.PARTIAL,
488+
parsed_template=ParsedTemplate(parameters={"UserData": {"Type": "String"}}),
489+
)
490+
orch = IntrinsicResolver(ctx)
491+
orch.register_resolver(FnRefResolver)
492+
orch.register_resolver(FnBase64Resolver)
493+
494+
value = {"Fn::Base64": {"Ref": "UserData"}}
495+
result = orch.resolve_value(value)
496+
assert result == {"Fn::Base64": {"Ref": "UserData"}}
497+
481498

482499
class TestFnBase64ResolverRealWorldScenarios:
483500
"""Tests for real-world CloudFormation scenarios using Fn::Base64."""

0 commit comments

Comments
 (0)