Thanks to visit codestin.com
Credit goes to github.com

Skip to content

CloudFormation V2 Engine: Support for Pseudo Parameter References #12595

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
May 19, 2025
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from localstack.services.cloudformation.engine.v2.change_set_model import (
NodeIntrinsicFunction,
NodeResource,
NodeTemplate,
PropertiesKey,
)
from localstack.services.cloudformation.engine.v2.change_set_model_preproc import (
Expand All @@ -16,6 +15,7 @@
PreprocProperties,
PreprocResource,
)
from localstack.services.cloudformation.v2.entities import ChangeSet

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

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

def __init__(
self,
node_template: NodeTemplate,
before_resolved_resources: dict,
change_set: ChangeSet,
include_property_values: bool,
):
super().__init__(
node_template=node_template, before_resolved_resources=before_resolved_resources
)
super().__init__(change_set=change_set)
self._include_property_values = include_property_values
self._changes = list()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,13 @@ class ChangeSetModelExecutorResult:


class ChangeSetModelExecutor(ChangeSetModelPreproc):
_change_set: Final[ChangeSet]
# TODO: add typing for resolved resources and parameters.
resources: Final[dict]
outputs: Final[dict]
resolved_parameters: Final[dict]

def __init__(self, change_set: ChangeSet):
super().__init__(
node_template=change_set.update_graph,
before_resolved_resources=change_set.stack.resolved_resources,
)
self._change_set = change_set
super().__init__(change_set=change_set)
self.resources = dict()
self.outputs = dict()
self.resolved_parameters = dict()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,22 @@
from localstack.services.cloudformation.engine.v2.change_set_model_visitor import (
ChangeSetModelVisitor,
)
from localstack.services.cloudformation.v2.entities import ChangeSet
from localstack.utils.aws.arns import get_partition
from localstack.utils.urls import localstack_host

_AWS_URL_SUFFIX = localstack_host().host # The value in AWS is "amazonaws.com"

_PSEUDO_PARAMETERS: Final[set[str]] = {
"AWS::Partition",
"AWS::AccountId",
"AWS::Region",
"AWS::StackName",
"AWS::StackId",
"AWS::URLSuffix",
"AWS::NoValue",
"AWS::NotificationARNs",
}

TBefore = TypeVar("TBefore")
TAfter = TypeVar("TAfter")
Expand Down Expand Up @@ -126,13 +142,15 @@ def __eq__(self, other):


class ChangeSetModelPreproc(ChangeSetModelVisitor):
_change_set: Final[ChangeSet]
_node_template: Final[NodeTemplate]
_before_resolved_resources: Final[dict]
_processed: dict[Scope, Any]

def __init__(self, node_template: NodeTemplate, before_resolved_resources: dict):
self._node_template = node_template
self._before_resolved_resources = before_resolved_resources
def __init__(self, change_set: ChangeSet):
self._change_set = change_set
self._node_template = change_set.update_graph
self._before_resolved_resources = change_set.stack.resolved_resources
self._processed = dict()

def process(self) -> None:
Expand All @@ -157,11 +175,20 @@ def _get_node_property_for(
return node_property
return None

@staticmethod
def _deployed_property_value_of(
resource_logical_id: str, property_name: str, resolved_resources: dict
self, resource_logical_id: str, property_name: str, resolved_resources: dict
) -> Any:
# TODO: typing around resolved resources is needed and should be reflected here.

# Before we can obtain deployed value for a resource, we need to first ensure to
# process the resource if this wasn't processed already. Ideally, values should only
# be accessible through delta objects, to ensure computation is always complete at
# every level.
node_resource = self._get_node_resource_for(
resource_name=resource_logical_id, node_template=self._node_template
)
self.visit(node_resource)

resolved_resource = resolved_resources.get(resource_logical_id)
if resolved_resource is None:
raise RuntimeError(
Expand Down Expand Up @@ -223,7 +250,38 @@ def _resolve_condition(self, logical_id: str) -> PreprocEntityDelta:
return condition_delta
raise RuntimeError(f"No condition '{logical_id}' was found.")

def _resolve_pseudo_parameter(self, pseudo_parameter_name: str) -> PreprocEntityDelta:
match pseudo_parameter_name:
case "AWS::Partition":
after = get_partition(self._change_set.region_name)
case "AWS::AccountId":
after = self._change_set.stack.account_id
case "AWS::Region":
after = self._change_set.stack.region_name
case "AWS::StackName":
after = self._change_set.stack.stack_name
case "AWS::StackId":
after = self._change_set.stack.stack_id
case "AWS::URLSuffix":
after = _AWS_URL_SUFFIX
case "AWS::NoValue":
# TODO: add support for NoValue, None cannot be used to communicate a Null value in preproc classes.
raise NotImplementedError("The use of AWS:NoValue is currently unsupported")
case "AWS::NotificationARNs":
raise NotImplementedError(
"The use of AWS::NotificationARNs is currently unsupported"
)
case _:
raise RuntimeError(f"Unknown pseudo parameter value '{pseudo_parameter_name}'")
return PreprocEntityDelta(before=after, after=after)

def _resolve_reference(self, logical_id: str) -> PreprocEntityDelta:
if logical_id in _PSEUDO_PARAMETERS:
pseudo_parameter_delta = self._resolve_pseudo_parameter(
pseudo_parameter_name=logical_id
)
return pseudo_parameter_delta

node_parameter = self._get_node_parameter_if_exists(parameter_name=logical_id)
if isinstance(node_parameter, NodeParameter):
parameter_delta = self.visit(node_parameter)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@
from typing import TypedDict

from localstack.aws.api.cloudformation import (
Changes,
ChangeSetStatus,
ChangeSetType,
CreateChangeSetInput,
DescribeChangeSetOutput,
ExecutionStatus,
Output,
Parameter,
Expand All @@ -26,9 +24,6 @@
ChangeSetModel,
NodeTemplate,
)
from localstack.services.cloudformation.engine.v2.change_set_model_describer import (
ChangeSetModelDescriber,
)
from localstack.utils.aws import arns
from localstack.utils.strings import short_uid

Expand Down Expand Up @@ -187,35 +182,3 @@ def populate_update_graph(
after_parameters=after_parameters,
)
self.update_graph = change_set_model.get_update_model()

def describe_details(self, include_property_values: bool) -> DescribeChangeSetOutput:
change_set_describer = ChangeSetModelDescriber(
node_template=self.update_graph,
before_resolved_resources=self.stack.resolved_resources,
include_property_values=include_property_values,
)
changes: Changes = change_set_describer.get_changes()

result = {
"Status": self.status,
"ChangeSetType": self.change_set_type,
"ChangeSetId": self.change_set_id,
"ChangeSetName": self.change_set_name,
"ExecutionStatus": self.execution_status,
"RollbackConfiguration": {},
"StackId": self.stack.stack_id,
"StackName": self.stack.stack_name,
"StackStatus": self.stack.status,
"CreationTime": self.creation_time,
"LastUpdatedTime": "",
"DisableRollback": "",
"EnableTerminationProtection": "",
"Transform": "",
# TODO: mask no echo
"Parameters": [
Parameter(ParameterKey=key, ParameterValue=value)
for (key, value) in self.stack.resolved_parameters.items()
],
"Changes": changes,
}
return result
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from localstack.aws.api import RequestContext, handler
from localstack.aws.api.cloudformation import (
Changes,
ChangeSetNameOrId,
ChangeSetNotFoundException,
ChangeSetStatus,
Expand All @@ -24,12 +25,16 @@
RetainExceptOnCreate,
RetainResources,
RoleARN,
RollbackConfiguration,
StackName,
StackNameOrId,
StackStatus,
)
from localstack.services.cloudformation import api_utils
from localstack.services.cloudformation.engine import template_preparer
from localstack.services.cloudformation.engine.v2.change_set_model_describer import (
ChangeSetModelDescriber,
)
from localstack.services.cloudformation.engine.v2.change_set_model_executor import (
ChangeSetModelExecutor,
)
Expand Down Expand Up @@ -296,6 +301,32 @@ def _run(*args):

return ExecuteChangeSetOutput()

def _describe_change_set(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great I'm glad it's in the provider.

self, change_set: ChangeSet, include_property_values: bool
) -> DescribeChangeSetOutput:
change_set_describer = ChangeSetModelDescriber(
change_set=change_set, include_property_values=include_property_values
)
changes: Changes = change_set_describer.get_changes()

result = DescribeChangeSetOutput(
Status=change_set.status,
ChangeSetId=change_set.change_set_id,
ChangeSetName=change_set.change_set_name,
ExecutionStatus=change_set.execution_status,
RollbackConfiguration=RollbackConfiguration(),
StackId=change_set.stack.stack_id,
StackName=change_set.stack.stack_name,
CreationTime=change_set.creation_time,
Parameters=[
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we keep the todo about masking ?

# TODO: add masking support.
Parameter(ParameterKey=key, ParameterValue=value)
for (key, value) in change_set.stack.resolved_parameters.items()
],
Changes=changes,
)
return result

@handler("DescribeChangeSet")
def describe_change_set(
self,
Expand All @@ -312,9 +343,8 @@ def describe_change_set(
change_set = find_change_set_v2(state, change_set_name, stack_name)
if not change_set:
raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")

result = change_set.describe_details(
include_property_values=include_property_values or False
result = self._describe_change_set(
change_set=change_set, include_property_values=include_property_values or False
)
return result

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@
"""


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


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


@pytest.mark.skip(reason="no support for pseudo-parameters")
@markers.aws.validated
def test_account(deploy_cfn_template, aws_client):
stack = deploy_cfn_template(
Expand Down
37 changes: 37 additions & 0 deletions tests/aws/services/cloudformation/v2/test_change_set_ref.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,40 @@ def test_immutable_property_update_causes_resource_replacement(
}
}
capture_update_process(snapshot, template_1, template_2)

@markers.aws.validated
def test_supported_pseudo_parameter(
self,
snapshot,
capture_update_process,
):
topic_name_1 = f"topic-name-1-{long_uid()}"
snapshot.add_transformer(RegexTransformer(topic_name_1, "topic_name_1"))
topic_name_2 = f"topic-name-2-{long_uid()}"
snapshot.add_transformer(RegexTransformer(topic_name_2, "topic_name_2"))
snapshot.add_transformer(RegexTransformer("amazonaws.com", "url_suffix"))
snapshot.add_transformer(RegexTransformer("localhost.localstack.cloud", "url_suffix"))
template_1 = {
"Resources": {
"Topic1": {"Type": "AWS::SNS::Topic", "Properties": {"TopicName": topic_name_1}},
}
}
template_2 = {
"Resources": {
"Topic2": {
"Type": "AWS::SNS::Topic",
"Properties": {
"TopicName": topic_name_2,
"Tags": [
{"Key": "Partition", "Value": {"Ref": "AWS::Partition"}},
{"Key": "AccountId", "Value": {"Ref": "AWS::AccountId"}},
{"Key": "Region", "Value": {"Ref": "AWS::Region"}},
{"Key": "StackName", "Value": {"Ref": "AWS::StackName"}},
{"Key": "StackId", "Value": {"Ref": "AWS::StackId"}},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't cover urlsuffix here. I realise that by definition it will be different from AWS to LocalStack but at least it would be execute the code path

{"Key": "URLSuffix", "Value": {"Ref": "AWS::URLSuffix"}},
],
},
},
}
}
capture_update_process(snapshot, template_1, template_2)
Loading
Loading