Skip to content

Commit 339ad5c

Browse files
authored
CFn: handle resolving parameter names constructed in intrinsics (#13192)
1 parent 15fc427 commit 339ad5c

File tree

7 files changed

+76
-10
lines changed

7 files changed

+76
-10
lines changed

localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
ChangeSetModelVisitor,
4646
)
4747
from localstack.services.cloudformation.engine.v2.resolving import (
48+
REGEX_DYNAMIC_REF,
4849
extract_dynamic_reference,
4950
perform_dynamic_reference_lookup,
5051
)
@@ -432,14 +433,15 @@ def _maybe_perform_on_delta(
432433
def _perform_dynamic_replacements(self, value: _T) -> _T:
433434
if not isinstance(value, str):
434435
return value
436+
435437
if dynamic_ref := extract_dynamic_reference(value):
436438
new_value = perform_dynamic_reference_lookup(
437439
reference=dynamic_ref,
438440
account_id=self._change_set.account_id,
439441
region_name=self._change_set.region_name,
440442
)
441443
if new_value:
442-
return new_value
444+
return REGEX_DYNAMIC_REF.sub(new_value, value)
443445

444446
return value
445447

localstack-core/localstack/services/cloudformation/engine/v2/resolving.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
LOG = logging.getLogger(__name__)
1212

13-
REGEX_DYNAMIC_REF = re.compile(r"{{resolve:([^:]+):(.+)}}")
13+
# CloudFormation allows using dynamic references in `Fn::Sub` expressions, so we must make sure
14+
# we don't capture the parameter usage by excluding ${} characters
15+
REGEX_DYNAMIC_REF = re.compile(r"{{resolve:([^:]+):([^${}]+)}}")
1416

1517

1618
@dataclass
@@ -21,7 +23,7 @@ class DynamicReference:
2123

2224
def extract_dynamic_reference(value: Any) -> DynamicReference | None:
2325
if isinstance(value, str):
24-
if dynamic_ref_match := REGEX_DYNAMIC_REF.match(value):
26+
if dynamic_ref_match := REGEX_DYNAMIC_REF.search(value):
2527
return DynamicReference(dynamic_ref_match[1], dynamic_ref_match[2])
2628
return None
2729

tests/aws/services/cloudformation/test_template_engine.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -349,20 +349,21 @@ def test_create_stack_with_ssm_parameters(
349349
)
350350

351351
@markers.aws.validated
352-
def test_resolve_ssm(self, create_parameter, deploy_cfn_template):
352+
@skip_if_legacy_engine()
353+
def test_resolve_ssm(self, create_parameter, deploy_cfn_template, snapshot):
353354
parameter_key = f"param-key-{short_uid()}"
354355
parameter_value = f"param-value-{short_uid()}"
356+
snapshot.add_transformer(snapshot.transform.regex(parameter_value, "<parameter-value>"))
355357
create_parameter(Name=parameter_key, Value=parameter_value, Type="String")
356358

357359
result = deploy_cfn_template(
358-
parameters={"DynamicParameter": parameter_key},
360+
parameters={"DynamicParameter": parameter_key, "ParameterName": parameter_key},
359361
template_path=os.path.join(
360362
os.path.dirname(__file__), "../../templates/resolve_ssm.yaml"
361363
),
362364
)
363365

364-
topic_name = result.outputs["TopicName"]
365-
assert topic_name == parameter_value
366+
snapshot.match("results", result.outputs)
366367

367368
@markers.aws.validated
368369
def test_resolve_ssm_with_version(self, create_parameter, deploy_cfn_template, aws_client):
@@ -380,8 +381,12 @@ def test_resolve_ssm_with_version(self, create_parameter, deploy_cfn_template, a
380381
Name=parameter_key, Overwrite=True, Type="String", Value=parameter_value_v2
381382
)
382383

384+
versioned_parameter_reference = f"{parameter_key}:{v1['Version']}"
383385
result = deploy_cfn_template(
384-
parameters={"DynamicParameter": f"{parameter_key}:{v1['Version']}"},
386+
parameters={
387+
"DynamicParameter": versioned_parameter_reference,
388+
"ParameterName": versioned_parameter_reference,
389+
},
385390
template_path=os.path.join(
386391
os.path.dirname(__file__), "../../templates/resolve_ssm.yaml"
387392
),
@@ -515,7 +520,7 @@ def test_resolve_secretsmanager(self, create_secret, deploy_cfn_template, templa
515520
create_secret(Name=parameter_key, SecretString=parameter_value)
516521

517522
result = deploy_cfn_template(
518-
parameters={"DynamicParameter": f"{parameter_key}"},
523+
parameters={"DynamicParameter": parameter_key},
519524
template_path=os.path.join(
520525
os.path.dirname(__file__),
521526
"../../templates",

tests/aws/services/cloudformation/test_template_engine.snapshot.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,5 +691,14 @@
691691
"recorded-content": {
692692
"error-response": "An error occurred (ValidationError) when calling the CreateChangeSet operation: Parameter InputValue should either have input value or default value"
693693
}
694+
},
695+
"tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_resolve_ssm": {
696+
"recorded-date": "24-09-2025, 22:06:32",
697+
"recorded-content": {
698+
"results": {
699+
"ParameterValue": "abc:<parameter-value>",
700+
"TopicName": "<parameter-value>"
701+
}
702+
}
694703
}
695704
}

tests/aws/services/cloudformation/test_template_engine.validation.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@
110110
"total": 61.74
111111
}
112112
},
113+
"tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_resolve_ssm": {
114+
"last_validated_date": "2025-09-24T22:07:22+00:00",
115+
"durations_in_seconds": {
116+
"setup": 0.95,
117+
"call": 10.67,
118+
"teardown": 50.0,
119+
"total": 61.62
120+
}
121+
},
113122
"tests/aws/services/cloudformation/test_template_engine.py::TestSsmParameters::test_resolve_ssm_missing_parameter": {
114123
"last_validated_date": "2025-08-06T09:34:07+00:00",
115124
"durations_in_seconds": {

tests/aws/templates/resolve_ssm.yaml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,25 @@ Parameters:
22
DynamicParameter:
33
Type: String
44

5+
ParameterName:
6+
Type: String
7+
58
Resources:
69
topic69831491:
710
Type: AWS::SNS::Topic
811
Properties:
9-
TopicName: !Join [ "", [ "{{resolve:ssm:", !Ref DynamicParameter, "}}" ] ]
12+
TopicName: !Join [ "", [ "{{resolve:ssm:", !Ref DynamicParameter, "}}" ] ]
13+
MyParameter:
14+
Type: AWS::SSM::Parameter
15+
Properties:
16+
Type: String
17+
Value: !Sub "abc:{{resolve:ssm:${ParameterName}}}"
1018
Outputs:
1119
TopicName:
1220
Value:
1321
Fn::GetAtt:
1422
- topic69831491
1523
- TopicName
24+
25+
ParameterValue:
26+
Value: !GetAtt MyParameter.Value

tests/unit/services/cloudformation/test_cloudformation.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import pytest
2+
13
from localstack.services.cloudformation.api_utils import is_local_service_url
24
from localstack.services.cloudformation.deployment_utils import (
35
PLACEHOLDER_AWS_NO_VALUE,
46
remove_none_values,
57
)
68
from localstack.services.cloudformation.engine.template_deployer import order_resources
9+
from localstack.services.cloudformation.engine.v2.resolving import REGEX_DYNAMIC_REF
710

811

912
def test_is_local_service_url():
@@ -60,3 +63,28 @@ def test_order_resources():
6063
)
6164

6265
assert list(sorted_resources.keys()) == ["A", "B"]
66+
67+
68+
class TestDynamicResolving:
69+
@pytest.mark.parametrize(
70+
"value,matches",
71+
[
72+
pytest.param("{{resolve:ssm:abc123}}", True, id="ssm-basic"),
73+
pytest.param("abc:{{resolve:ssm:abc123}}:foo", True, id="ssm-with-surrounding"),
74+
pytest.param("{{resolve:ssm:${ParameterName}}}", False, id="ssm-in-sub"),
75+
pytest.param("{{resolve:secretsmanager:foo}}", True, id="secrets-basic"),
76+
pytest.param(
77+
"{{resolve:secretsmanager:arn:aws:secretsmanager:us-east-1:000000000000:secret:foo:SecretString:}}",
78+
True,
79+
id="secrets-partial",
80+
),
81+
pytest.param(
82+
"{{resolve:secretsmanager:arn:aws:secretsmanager:us-east-1:000000000000:secret:foo:SecretString:::}}",
83+
True,
84+
id="secrets-full",
85+
),
86+
pytest.param("{{resolve:secretsmanager:${SecretName}}}", False, id="secrets-in-sub"),
87+
],
88+
)
89+
def test_dynamic_ref_regex_matches(self, value, matches):
90+
assert bool(REGEX_DYNAMIC_REF.search(value)) == matches

0 commit comments

Comments
 (0)