Skip to content

Commit fa0bbcd

Browse files
authored
CloudFormation V2 Engine: Support for Pseudo Parameter References (#12595)
1 parent ada991e commit fa0bbcd

File tree

9 files changed

+652
-60
lines changed

9 files changed

+652
-60
lines changed

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

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from localstack.services.cloudformation.engine.v2.change_set_model import (
88
NodeIntrinsicFunction,
99
NodeResource,
10-
NodeTemplate,
1110
PropertiesKey,
1211
)
1312
from localstack.services.cloudformation.engine.v2.change_set_model_preproc import (
@@ -16,6 +15,7 @@
1615
PreprocProperties,
1716
PreprocResource,
1817
)
18+
from localstack.services.cloudformation.v2.entities import ChangeSet
1919

2020
CHANGESET_KNOWN_AFTER_APPLY: Final[str] = "{{changeSet:KNOWN_AFTER_APPLY}}"
2121

@@ -26,13 +26,10 @@ class ChangeSetModelDescriber(ChangeSetModelPreproc):
2626

2727
def __init__(
2828
self,
29-
node_template: NodeTemplate,
30-
before_resolved_resources: dict,
29+
change_set: ChangeSet,
3130
include_property_values: bool,
3231
):
33-
super().__init__(
34-
node_template=node_template, before_resolved_resources=before_resolved_resources
35-
)
32+
super().__init__(change_set=change_set)
3633
self._include_property_values = include_property_values
3734
self._changes = list()
3835

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

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,13 @@ class ChangeSetModelExecutorResult:
3838

3939

4040
class ChangeSetModelExecutor(ChangeSetModelPreproc):
41-
_change_set: Final[ChangeSet]
4241
# TODO: add typing for resolved resources and parameters.
4342
resources: Final[dict]
4443
outputs: Final[dict]
4544
resolved_parameters: Final[dict]
4645

4746
def __init__(self, change_set: ChangeSet):
48-
super().__init__(
49-
node_template=change_set.update_graph,
50-
before_resolved_resources=change_set.stack.resolved_resources,
51-
)
52-
self._change_set = change_set
47+
super().__init__(change_set=change_set)
5348
self.resources = dict()
5449
self.outputs = dict()
5550
self.resolved_parameters = dict()

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

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@
2828
from localstack.services.cloudformation.engine.v2.change_set_model_visitor import (
2929
ChangeSetModelVisitor,
3030
)
31+
from localstack.services.cloudformation.v2.entities import ChangeSet
32+
from localstack.utils.aws.arns import get_partition
33+
from localstack.utils.urls import localstack_host
34+
35+
_AWS_URL_SUFFIX = localstack_host().host # The value in AWS is "amazonaws.com"
36+
37+
_PSEUDO_PARAMETERS: Final[set[str]] = {
38+
"AWS::Partition",
39+
"AWS::AccountId",
40+
"AWS::Region",
41+
"AWS::StackName",
42+
"AWS::StackId",
43+
"AWS::URLSuffix",
44+
"AWS::NoValue",
45+
"AWS::NotificationARNs",
46+
}
3147

3248
TBefore = TypeVar("TBefore")
3349
TAfter = TypeVar("TAfter")
@@ -126,13 +142,15 @@ def __eq__(self, other):
126142

127143

128144
class ChangeSetModelPreproc(ChangeSetModelVisitor):
145+
_change_set: Final[ChangeSet]
129146
_node_template: Final[NodeTemplate]
130147
_before_resolved_resources: Final[dict]
131148
_processed: dict[Scope, Any]
132149

133-
def __init__(self, node_template: NodeTemplate, before_resolved_resources: dict):
134-
self._node_template = node_template
135-
self._before_resolved_resources = before_resolved_resources
150+
def __init__(self, change_set: ChangeSet):
151+
self._change_set = change_set
152+
self._node_template = change_set.update_graph
153+
self._before_resolved_resources = change_set.stack.resolved_resources
136154
self._processed = dict()
137155

138156
def process(self) -> None:
@@ -157,11 +175,20 @@ def _get_node_property_for(
157175
return node_property
158176
return None
159177

160-
@staticmethod
161178
def _deployed_property_value_of(
162-
resource_logical_id: str, property_name: str, resolved_resources: dict
179+
self, resource_logical_id: str, property_name: str, resolved_resources: dict
163180
) -> Any:
164181
# TODO: typing around resolved resources is needed and should be reflected here.
182+
183+
# Before we can obtain deployed value for a resource, we need to first ensure to
184+
# process the resource if this wasn't processed already. Ideally, values should only
185+
# be accessible through delta objects, to ensure computation is always complete at
186+
# every level.
187+
node_resource = self._get_node_resource_for(
188+
resource_name=resource_logical_id, node_template=self._node_template
189+
)
190+
self.visit(node_resource)
191+
165192
resolved_resource = resolved_resources.get(resource_logical_id)
166193
if resolved_resource is None:
167194
raise RuntimeError(
@@ -223,7 +250,38 @@ def _resolve_condition(self, logical_id: str) -> PreprocEntityDelta:
223250
return condition_delta
224251
raise RuntimeError(f"No condition '{logical_id}' was found.")
225252

253+
def _resolve_pseudo_parameter(self, pseudo_parameter_name: str) -> PreprocEntityDelta:
254+
match pseudo_parameter_name:
255+
case "AWS::Partition":
256+
after = get_partition(self._change_set.region_name)
257+
case "AWS::AccountId":
258+
after = self._change_set.stack.account_id
259+
case "AWS::Region":
260+
after = self._change_set.stack.region_name
261+
case "AWS::StackName":
262+
after = self._change_set.stack.stack_name
263+
case "AWS::StackId":
264+
after = self._change_set.stack.stack_id
265+
case "AWS::URLSuffix":
266+
after = _AWS_URL_SUFFIX
267+
case "AWS::NoValue":
268+
# TODO: add support for NoValue, None cannot be used to communicate a Null value in preproc classes.
269+
raise NotImplementedError("The use of AWS:NoValue is currently unsupported")
270+
case "AWS::NotificationARNs":
271+
raise NotImplementedError(
272+
"The use of AWS::NotificationARNs is currently unsupported"
273+
)
274+
case _:
275+
raise RuntimeError(f"Unknown pseudo parameter value '{pseudo_parameter_name}'")
276+
return PreprocEntityDelta(before=after, after=after)
277+
226278
def _resolve_reference(self, logical_id: str) -> PreprocEntityDelta:
279+
if logical_id in _PSEUDO_PARAMETERS:
280+
pseudo_parameter_delta = self._resolve_pseudo_parameter(
281+
pseudo_parameter_name=logical_id
282+
)
283+
return pseudo_parameter_delta
284+
227285
node_parameter = self._get_node_parameter_if_exists(parameter_name=logical_id)
228286
if isinstance(node_parameter, NodeParameter):
229287
parameter_delta = self.visit(node_parameter)

localstack-core/localstack/services/cloudformation/v2/entities.py

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@
22
from typing import TypedDict
33

44
from localstack.aws.api.cloudformation import (
5-
Changes,
65
ChangeSetStatus,
76
ChangeSetType,
87
CreateChangeSetInput,
9-
DescribeChangeSetOutput,
108
ExecutionStatus,
119
Output,
1210
Parameter,
@@ -26,9 +24,6 @@
2624
ChangeSetModel,
2725
NodeTemplate,
2826
)
29-
from localstack.services.cloudformation.engine.v2.change_set_model_describer import (
30-
ChangeSetModelDescriber,
31-
)
3227
from localstack.utils.aws import arns
3328
from localstack.utils.strings import short_uid
3429

@@ -187,35 +182,3 @@ def populate_update_graph(
187182
after_parameters=after_parameters,
188183
)
189184
self.update_graph = change_set_model.get_update_model()
190-
191-
def describe_details(self, include_property_values: bool) -> DescribeChangeSetOutput:
192-
change_set_describer = ChangeSetModelDescriber(
193-
node_template=self.update_graph,
194-
before_resolved_resources=self.stack.resolved_resources,
195-
include_property_values=include_property_values,
196-
)
197-
changes: Changes = change_set_describer.get_changes()
198-
199-
result = {
200-
"Status": self.status,
201-
"ChangeSetType": self.change_set_type,
202-
"ChangeSetId": self.change_set_id,
203-
"ChangeSetName": self.change_set_name,
204-
"ExecutionStatus": self.execution_status,
205-
"RollbackConfiguration": {},
206-
"StackId": self.stack.stack_id,
207-
"StackName": self.stack.stack_name,
208-
"StackStatus": self.stack.status,
209-
"CreationTime": self.creation_time,
210-
"LastUpdatedTime": "",
211-
"DisableRollback": "",
212-
"EnableTerminationProtection": "",
213-
"Transform": "",
214-
# TODO: mask no echo
215-
"Parameters": [
216-
Parameter(ParameterKey=key, ParameterValue=value)
217-
for (key, value) in self.stack.resolved_parameters.items()
218-
],
219-
"Changes": changes,
220-
}
221-
return result

localstack-core/localstack/services/cloudformation/v2/provider.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from localstack.aws.api import RequestContext, handler
55
from localstack.aws.api.cloudformation import (
6+
Changes,
67
ChangeSetNameOrId,
78
ChangeSetNotFoundException,
89
ChangeSetStatus,
@@ -24,12 +25,16 @@
2425
RetainExceptOnCreate,
2526
RetainResources,
2627
RoleARN,
28+
RollbackConfiguration,
2729
StackName,
2830
StackNameOrId,
2931
StackStatus,
3032
)
3133
from localstack.services.cloudformation import api_utils
3234
from localstack.services.cloudformation.engine import template_preparer
35+
from localstack.services.cloudformation.engine.v2.change_set_model_describer import (
36+
ChangeSetModelDescriber,
37+
)
3338
from localstack.services.cloudformation.engine.v2.change_set_model_executor import (
3439
ChangeSetModelExecutor,
3540
)
@@ -296,6 +301,32 @@ def _run(*args):
296301

297302
return ExecuteChangeSetOutput()
298303

304+
def _describe_change_set(
305+
self, change_set: ChangeSet, include_property_values: bool
306+
) -> DescribeChangeSetOutput:
307+
change_set_describer = ChangeSetModelDescriber(
308+
change_set=change_set, include_property_values=include_property_values
309+
)
310+
changes: Changes = change_set_describer.get_changes()
311+
312+
result = DescribeChangeSetOutput(
313+
Status=change_set.status,
314+
ChangeSetId=change_set.change_set_id,
315+
ChangeSetName=change_set.change_set_name,
316+
ExecutionStatus=change_set.execution_status,
317+
RollbackConfiguration=RollbackConfiguration(),
318+
StackId=change_set.stack.stack_id,
319+
StackName=change_set.stack.stack_name,
320+
CreationTime=change_set.creation_time,
321+
Parameters=[
322+
# TODO: add masking support.
323+
Parameter(ParameterKey=key, ParameterValue=value)
324+
for (key, value) in change_set.stack.resolved_parameters.items()
325+
],
326+
Changes=changes,
327+
)
328+
return result
329+
299330
@handler("DescribeChangeSet")
300331
def describe_change_set(
301332
self,
@@ -312,9 +343,8 @@ def describe_change_set(
312343
change_set = find_change_set_v2(state, change_set_name, stack_name)
313344
if not change_set:
314345
raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
315-
316-
result = change_set.describe_details(
317-
include_property_values=include_property_values or False
346+
result = self._describe_change_set(
347+
change_set=change_set, include_property_values=include_property_values or False
318348
)
319349
return result
320350

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@
5959
"""
6060

6161

62+
@pytest.mark.skip(reason="no support for DependsOn")
6263
# this is an `only_localstack` test because it makes use of _custom_id_ tag
63-
@pytest.mark.skip(reason="no support for pseudo-parameters")
6464
@markers.aws.only_localstack
6565
def test_cfn_apigateway_aws_integration(deploy_cfn_template, aws_client):
6666
api_name = f"rest-api-{short_uid()}"
@@ -143,7 +143,7 @@ def test_cfn_apigateway_swagger_import(deploy_cfn_template, echo_http_server_pos
143143
assert content["url"].endswith("/post")
144144

145145

146-
@pytest.mark.skip(reason="No support for pseudo-parameters")
146+
@pytest.mark.skip(reason="No support for DependsOn")
147147
@markers.aws.only_localstack
148148
def test_url_output(httpserver, deploy_cfn_template):
149149
httpserver.expect_request("").respond_with_data(b"", 200)
@@ -396,7 +396,6 @@ def test_cfn_apigateway_rest_api(deploy_cfn_template, aws_client):
396396
# assert not apis
397397

398398

399-
@pytest.mark.skip(reason="no support for pseudo-parameters")
400399
@markers.aws.validated
401400
def test_account(deploy_cfn_template, aws_client):
402401
stack = deploy_cfn_template(

tests/aws/services/cloudformation/v2/test_change_set_ref.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,40 @@ def test_immutable_property_update_causes_resource_replacement(
309309
}
310310
}
311311
capture_update_process(snapshot, template_1, template_2)
312+
313+
@markers.aws.validated
314+
def test_supported_pseudo_parameter(
315+
self,
316+
snapshot,
317+
capture_update_process,
318+
):
319+
topic_name_1 = f"topic-name-1-{long_uid()}"
320+
snapshot.add_transformer(RegexTransformer(topic_name_1, "topic_name_1"))
321+
topic_name_2 = f"topic-name-2-{long_uid()}"
322+
snapshot.add_transformer(RegexTransformer(topic_name_2, "topic_name_2"))
323+
snapshot.add_transformer(RegexTransformer("amazonaws.com", "url_suffix"))
324+
snapshot.add_transformer(RegexTransformer("localhost.localstack.cloud", "url_suffix"))
325+
template_1 = {
326+
"Resources": {
327+
"Topic1": {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": topic_name_1}},
328+
}
329+
}
330+
template_2 = {
331+
"Resources": {
332+
"Topic2": {
333+
"Type": "AWS::SNS::Topic",
334+
"Properties": {
335+
"TopicName": topic_name_2,
336+
"Tags": [
337+
{"Key": "Partition", "Value": {"Ref": "AWS::Partition"}},
338+
{"Key": "AccountId", "Value": {"Ref": "AWS::AccountId"}},
339+
{"Key": "Region", "Value": {"Ref": "AWS::Region"}},
340+
{"Key": "StackName", "Value": {"Ref": "AWS::StackName"}},
341+
{"Key": "StackId", "Value": {"Ref": "AWS::StackId"}},
342+
{"Key": "URLSuffix", "Value": {"Ref": "AWS::URLSuffix"}},
343+
],
344+
},
345+
},
346+
}
347+
}
348+
capture_update_process(snapshot, template_1, template_2)

0 commit comments

Comments
 (0)