Skip to content

Commit e42e683

Browse files
authored
CloudFormation V2 Engine: Support for Fn::Sub (#12650)
1 parent 1a9469e commit e42e683

File tree

7 files changed

+4088
-11
lines changed

7 files changed

+4088
-11
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@ def __init__(self, scope: Scope, value: Any):
385385
FnGetAttKey: Final[str] = "Fn::GetAtt"
386386
FnEqualsKey: Final[str] = "Fn::Equals"
387387
FnFindInMapKey: Final[str] = "Fn::FindInMap"
388+
FnSubKey: Final[str] = "Fn::Sub"
388389
INTRINSIC_FUNCTIONS: Final[set[str]] = {
389390
RefKey,
390391
FnIfKey,
@@ -393,6 +394,7 @@ def __init__(self, scope: Scope, value: Any):
393394
FnEqualsKey,
394395
FnGetAttKey,
395396
FnFindInMapKey,
397+
FnSubKey,
396398
}
397399

398400

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

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import re
34
from typing import Any, Final, Generic, Optional, TypeVar
45

56
from localstack.services.cloudformation.engine.v2.change_set_model import (
@@ -254,20 +255,20 @@ def _resolve_condition(self, logical_id: str) -> PreprocEntityDelta:
254255
return condition_delta
255256
raise RuntimeError(f"No condition '{logical_id}' was found.")
256257

257-
def _resolve_pseudo_parameter(self, pseudo_parameter_name: str) -> PreprocEntityDelta:
258+
def _resolve_pseudo_parameter(self, pseudo_parameter_name: str) -> Any:
258259
match pseudo_parameter_name:
259260
case "AWS::Partition":
260-
after = get_partition(self._change_set.region_name)
261+
return get_partition(self._change_set.region_name)
261262
case "AWS::AccountId":
262-
after = self._change_set.stack.account_id
263+
return self._change_set.stack.account_id
263264
case "AWS::Region":
264-
after = self._change_set.stack.region_name
265+
return self._change_set.stack.region_name
265266
case "AWS::StackName":
266-
after = self._change_set.stack.stack_name
267+
return self._change_set.stack.stack_name
267268
case "AWS::StackId":
268-
after = self._change_set.stack.stack_id
269+
return self._change_set.stack.stack_id
269270
case "AWS::URLSuffix":
270-
after = _AWS_URL_SUFFIX
271+
return _AWS_URL_SUFFIX
271272
case "AWS::NoValue":
272273
# TODO: add support for NoValue, None cannot be used to communicate a Null value in preproc classes.
273274
raise NotImplementedError("The use of AWS:NoValue is currently unsupported")
@@ -277,14 +278,14 @@ def _resolve_pseudo_parameter(self, pseudo_parameter_name: str) -> PreprocEntity
277278
)
278279
case _:
279280
raise RuntimeError(f"Unknown pseudo parameter value '{pseudo_parameter_name}'")
280-
return PreprocEntityDelta(before=after, after=after)
281281

282282
def _resolve_reference(self, logical_id: str) -> PreprocEntityDelta:
283283
if logical_id in _PSEUDO_PARAMETERS:
284-
pseudo_parameter_delta = self._resolve_pseudo_parameter(
284+
pseudo_parameter_value = self._resolve_pseudo_parameter(
285285
pseudo_parameter_name=logical_id
286286
)
287-
return pseudo_parameter_delta
287+
# Pseudo parameters are constants within the lifecycle of a template.
288+
return PreprocEntityDelta(before=pseudo_parameter_value, after=pseudo_parameter_value)
288289

289290
node_parameter = self._get_node_parameter_if_exists(parameter_name=logical_id)
290291
if isinstance(node_parameter, NodeParameter):
@@ -477,6 +478,73 @@ def visit_node_intrinsic_function_fn_not(
477478
# Implicit change type computation.
478479
return PreprocEntityDelta(before=before, after=after)
479480

481+
def visit_node_intrinsic_function_fn_sub(
482+
self, node_intrinsic_function: NodeIntrinsicFunction
483+
) -> PreprocEntityDelta:
484+
arguments_delta = self.visit(node_intrinsic_function.arguments)
485+
arguments_before = arguments_delta.before
486+
arguments_after = arguments_delta.after
487+
488+
def _compute_sub(args: str | list[Any], select_before: bool = False) -> str:
489+
# TODO: add further schema validation.
490+
string_template: str
491+
sub_parameters: dict
492+
if isinstance(args, str):
493+
string_template = args
494+
sub_parameters = dict()
495+
elif (
496+
isinstance(args, list)
497+
and len(args) == 2
498+
and isinstance(args[0], str)
499+
and isinstance(args[1], dict)
500+
):
501+
string_template = args[0]
502+
sub_parameters = args[1]
503+
else:
504+
raise RuntimeError(
505+
"Invalid arguments shape for Fn::Sub, expected a String "
506+
f"or a Tuple of String and Map but got '{args}'"
507+
)
508+
sub_string = string_template
509+
template_variable_names = re.findall("\\${([^}]+)}", string_template)
510+
for template_variable_name in template_variable_names:
511+
if template_variable_name in _PSEUDO_PARAMETERS:
512+
template_variable_value = self._resolve_pseudo_parameter(
513+
pseudo_parameter_name=template_variable_name
514+
)
515+
elif template_variable_name in sub_parameters:
516+
template_variable_value = sub_parameters[template_variable_name]
517+
else:
518+
try:
519+
reference_delta = self._resolve_reference(logical_id=template_variable_name)
520+
template_variable_value = (
521+
reference_delta.before if select_before else reference_delta.after
522+
)
523+
except RuntimeError:
524+
raise RuntimeError(
525+
f"Undefined variable name in Fn::Sub string template '{template_variable_name}'"
526+
)
527+
sub_string = sub_string.replace(
528+
f"${{{template_variable_name}}}", template_variable_value
529+
)
530+
return sub_string
531+
532+
before = None
533+
if (
534+
isinstance(arguments_before, str)
535+
or isinstance(arguments_before, list)
536+
and len(arguments_before) == 2
537+
):
538+
before = _compute_sub(args=arguments_before, select_before=True)
539+
after = None
540+
if (
541+
isinstance(arguments_after, str)
542+
or isinstance(arguments_after, list)
543+
and len(arguments_after) == 2
544+
):
545+
after = _compute_sub(args=arguments_after)
546+
return PreprocEntityDelta(before=before, after=after)
547+
480548
def visit_node_intrinsic_function_fn_join(
481549
self, node_intrinsic_function: NodeIntrinsicFunction
482550
) -> PreprocEntityDelta:

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ def visit_node_intrinsic_function_fn_equals(
108108
):
109109
self.visit_children(node_intrinsic_function)
110110

111+
def visit_node_intrinsic_function_fn_sub(self, node_intrinsic_function: NodeIntrinsicFunction):
112+
self.visit_children(node_intrinsic_function)
113+
111114
def visit_node_intrinsic_function_fn_if(self, node_intrinsic_function: NodeIntrinsicFunction):
112115
self.visit_children(node_intrinsic_function)
113116

tests/aws/services/cloudformation/v2/ported_from_v1/resources/test_apigateway.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ def test_api_gateway_with_policy_as_dict(deploy_cfn_template, snapshot, aws_clie
553553
snapshot.match("rest-api", rest_api)
554554

555555

556-
@pytest.mark.skip(reason="No support for Fn::Sub")
556+
@pytest.mark.skip(reason="No resource provider found for AWS::Serverless::Api")
557557
@markers.aws.validated
558558
@markers.snapshot.skip_snapshot_verify(
559559
paths=[

0 commit comments

Comments
 (0)