Skip to content

Commit a7f4742

Browse files
authored
CFNV2: implement NoEcho support (#13011)
1 parent a69f74c commit a7f4742

File tree

6 files changed

+216
-56
lines changed

6 files changed

+216
-56
lines changed

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

Lines changed: 40 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,10 @@ def _resolve_parameters(
226226
given_value = parameters.get(name)
227227
default_value = parameter.get("Default")
228228
resolved_parameter = EngineParameter(
229-
type_=parameter["Type"], given_value=given_value, default_value=default_value
229+
type_=parameter["Type"],
230+
given_value=given_value,
231+
default_value=default_value,
232+
no_echo=parameter.get("NoEcho"),
230233
)
231234

232235
# TODO: support other parameter types
@@ -501,9 +504,7 @@ def create_change_set(
501504
change_set.set_execution_status(ExecutionStatus.UNAVAILABLE)
502505
change_set.status_reason = "The submitted information didn't contain changes. Submit different information to create a change set."
503506
else:
504-
if stack.status in [StackStatus.CREATE_COMPLETE, StackStatus.UPDATE_COMPLETE]:
505-
stack.set_stack_status(StackStatus.UPDATE_IN_PROGRESS)
506-
else:
507+
if stack.status not in [StackStatus.CREATE_COMPLETE, StackStatus.UPDATE_COMPLETE]:
507508
stack.set_stack_status(StackStatus.REVIEW_IN_PROGRESS)
508509

509510
change_set.set_change_set_status(ChangeSetStatus.CREATE_COMPLETE)
@@ -600,42 +601,6 @@ def _run(*args):
600601

601602
return ExecuteChangeSetOutput()
602603

603-
def _describe_change_set(
604-
self, change_set: ChangeSet, include_property_values: bool
605-
) -> DescribeChangeSetOutput:
606-
# TODO: The ChangeSetModelDescriber currently matches AWS behavior by listing
607-
# resource changes in the order they appear in the template. However, when
608-
# a resource change is triggered indirectly (e.g., via Ref or GetAtt), the
609-
# dependency's change appears first in the list.
610-
# Snapshot tests using the `capture_update_process` fixture rely on a
611-
# normalizer to account for this ordering. This should be removed in the
612-
# future by enforcing a consistently correct change ordering at the source.
613-
change_set_describer = ChangeSetModelDescriber(
614-
change_set=change_set, include_property_values=include_property_values
615-
)
616-
changes: Changes = change_set_describer.get_changes()
617-
618-
result = DescribeChangeSetOutput(
619-
Status=change_set.status,
620-
ChangeSetId=change_set.change_set_id,
621-
ChangeSetName=change_set.change_set_name,
622-
ExecutionStatus=change_set.execution_status,
623-
RollbackConfiguration=RollbackConfiguration(),
624-
StackId=change_set.stack.stack_id,
625-
StackName=change_set.stack.stack_name,
626-
CreationTime=change_set.creation_time,
627-
Changes=changes,
628-
Capabilities=change_set.stack.capabilities,
629-
StatusReason=change_set.status_reason,
630-
Description=change_set.description,
631-
# TODO: static information
632-
IncludeNestedStacks=False,
633-
NotificationARNs=[],
634-
)
635-
if change_set.resolved_parameters:
636-
result["Parameters"] = self._render_resolved_parameters(change_set.resolved_parameters)
637-
return result
638-
639604
@staticmethod
640605
def _render_resolved_parameters(
641606
resolved_parameters: dict[str, EngineParameter],
@@ -649,6 +614,10 @@ def _render_resolved_parameters(
649614
)
650615
if resolved_value := resolved_parameter.get("resolved_value"):
651616
parameter["ResolvedValue"] = resolved_value
617+
618+
# TODO :what happens to the resolved value?
619+
if resolved_parameter.get("no_echo", False):
620+
parameter["ParameterValue"] = "****"
652621
result.append(parameter)
653622

654623
return result
@@ -670,9 +639,38 @@ def describe_change_set(
670639

671640
if not change_set:
672641
raise ChangeSetNotFoundException(f"ChangeSet [{change_set_name}] does not exist")
673-
result = self._describe_change_set(
674-
change_set=change_set, include_property_values=include_property_values or False
642+
643+
# TODO: The ChangeSetModelDescriber currently matches AWS behavior by listing
644+
# resource changes in the order they appear in the template. However, when
645+
# a resource change is triggered indirectly (e.g., via Ref or GetAtt), the
646+
# dependency's change appears first in the list.
647+
# Snapshot tests using the `capture_update_process` fixture rely on a
648+
# normalizer to account for this ordering. This should be removed in the
649+
# future by enforcing a consistently correct change ordering at the source.
650+
change_set_describer = ChangeSetModelDescriber(
651+
change_set=change_set, include_property_values=include_property_values
675652
)
653+
changes: Changes = change_set_describer.get_changes()
654+
655+
result = DescribeChangeSetOutput(
656+
Status=change_set.status,
657+
ChangeSetId=change_set.change_set_id,
658+
ChangeSetName=change_set.change_set_name,
659+
ExecutionStatus=change_set.execution_status,
660+
RollbackConfiguration=RollbackConfiguration(),
661+
StackId=change_set.stack.stack_id,
662+
StackName=change_set.stack.stack_name,
663+
CreationTime=change_set.creation_time,
664+
Changes=changes,
665+
Capabilities=change_set.stack.capabilities,
666+
StatusReason=change_set.status_reason,
667+
Description=change_set.description,
668+
# TODO: static information
669+
IncludeNestedStacks=False,
670+
NotificationARNs=[],
671+
)
672+
if change_set.resolved_parameters:
673+
result["Parameters"] = self._render_resolved_parameters(change_set.resolved_parameters)
676674
return result
677675

678676
@handler("DeleteChangeSet")

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class EngineParameter(TypedDict):
1313
given_value: NotRequired[str | None]
1414
resolved_value: NotRequired[str | None]
1515
default_value: NotRequired[str | None]
16+
no_echo: NotRequired[bool | None]
1617

1718

1819
def engine_parameter_value(parameter: EngineParameter) -> str:

tests/aws/services/cloudformation/api/test_stacks.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from localstack_snapshot.snapshots.transformer import SortingTransformer
1212
from tests.aws.services.cloudformation.conftest import (
1313
skip_if_v1_provider,
14-
skip_if_v2_provider,
1514
skipped_v2_items,
1615
)
1716

@@ -1032,7 +1031,6 @@ def test_stack_deletion_order(
10321031
snapshot.match("all-events", to_snapshot)
10331032

10341033

1035-
@skip_if_v2_provider("DescribeStack")
10361034
@markers.snapshot.skip_snapshot_verify(
10371035
paths=[
10381036
# TODO: this property is present in the response from LocalStack when
@@ -1047,6 +1045,10 @@ def test_stack_deletion_order(
10471045
"$..ResourceChange",
10481046
"$..StackResourceDetail.Metadata",
10491047
]
1048+
+ skipped_v2_items(
1049+
"$..Stacks..Outputs..Description",
1050+
"$..StackResourceDetail.DriftInformation",
1051+
)
10501052
)
10511053
@markers.aws.validated
10521054
def test_no_echo_parameter(snapshot, aws_client, deploy_cfn_template):
@@ -1082,6 +1084,28 @@ def test_no_echo_parameter(snapshot, aws_client, deploy_cfn_template):
10821084
describe_stack_resource_details,
10831085
)
10841086

1087+
# create a change set
1088+
change_set_name = f"CreateChangeSetSecretParameterValue-{short_uid()}"
1089+
aws_client.cloudformation.create_change_set(
1090+
StackName=stack_name,
1091+
TemplateBody=template,
1092+
ChangeSetName=change_set_name,
1093+
Parameters=[
1094+
{"ParameterKey": "SecretParameter", "ParameterValue": "NewSecretValue1"},
1095+
],
1096+
)
1097+
aws_client.cloudformation.get_waiter("change_set_create_complete").wait(
1098+
StackName=stack_name,
1099+
ChangeSetName=change_set_name,
1100+
)
1101+
change_sets = aws_client.cloudformation.describe_change_set(
1102+
StackName=stack_id,
1103+
ChangeSetName=change_set_name,
1104+
)
1105+
snapshot.match("describe_change_set_on_create_complete", change_sets)
1106+
describe_stacks = aws_client.cloudformation.describe_stacks(StackName=stack_id)
1107+
snapshot.match("describe_stack_on_create_complete", describe_stacks)
1108+
10851109
# Update stack via update_stack (and change the value of SecretParameter)
10861110
aws_client.cloudformation.update_stack(
10871111
StackName=stack_name,

tests/aws/services/cloudformation/api/test_stacks.snapshot.json

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1573,7 +1573,7 @@
15731573
}
15741574
},
15751575
"tests/aws/services/cloudformation/api/test_stacks.py::test_no_echo_parameter": {
1576-
"recorded-date": "19-12-2024, 11:35:19",
1576+
"recorded-date": "15-08-2025, 12:01:58",
15771577
"recorded-content": {
15781578
"describe_stacks": {
15791579
"Stacks": [
@@ -1646,6 +1646,141 @@
16461646
"HTTPStatusCode": 200
16471647
}
16481648
},
1649+
"describe_change_set_on_create_complete": {
1650+
"Capabilities": [],
1651+
"ChangeSetId": "arn:<partition>:cloudformation:<region>:111111111111:changeSet/<resource:3>",
1652+
"ChangeSetName": "<change-set-name:1>",
1653+
"Changes": [
1654+
{
1655+
"ResourceChange": {
1656+
"Action": "Modify",
1657+
"Details": [
1658+
{
1659+
"CausingEntity": "SecretParameter",
1660+
"ChangeSource": "ParameterReference",
1661+
"Evaluation": "Static",
1662+
"Target": {
1663+
"Attribute": "Metadata",
1664+
"RequiresRecreation": "Never"
1665+
}
1666+
},
1667+
{
1668+
"CausingEntity": "SecretParameter",
1669+
"ChangeSource": "ParameterReference",
1670+
"Evaluation": "Static",
1671+
"Target": {
1672+
"Attribute": "Properties",
1673+
"Name": "Tags",
1674+
"RequiresRecreation": "Never"
1675+
}
1676+
},
1677+
{
1678+
"ChangeSource": "DirectModification",
1679+
"Evaluation": "Dynamic",
1680+
"Target": {
1681+
"Attribute": "Metadata",
1682+
"RequiresRecreation": "Never"
1683+
}
1684+
},
1685+
{
1686+
"ChangeSource": "DirectModification",
1687+
"Evaluation": "Dynamic",
1688+
"Target": {
1689+
"Attribute": "Properties",
1690+
"Name": "Tags",
1691+
"RequiresRecreation": "Never"
1692+
}
1693+
}
1694+
],
1695+
"LogicalResourceId": "LocalBucket",
1696+
"PhysicalResourceId": "cfn-noecho-bucket",
1697+
"Replacement": "False",
1698+
"ResourceType": "AWS::S3::Bucket",
1699+
"Scope": [
1700+
"Metadata",
1701+
"Properties"
1702+
]
1703+
},
1704+
"Type": "Resource"
1705+
}
1706+
],
1707+
"CreationTime": "datetime",
1708+
"ExecutionStatus": "AVAILABLE",
1709+
"IncludeNestedStacks": false,
1710+
"NotificationARNs": [],
1711+
"Parameters": [
1712+
{
1713+
"ParameterKey": "NormalParameter",
1714+
"ParameterValue": "Some default value here"
1715+
},
1716+
{
1717+
"ParameterKey": "SecretParameter",
1718+
"ParameterValue": "****"
1719+
},
1720+
{
1721+
"ParameterKey": "SecretParameterWithDefault",
1722+
"ParameterValue": "****"
1723+
}
1724+
],
1725+
"RollbackConfiguration": {},
1726+
"StackId": "arn:<partition>:cloudformation:<region>:111111111111:stack/<stack-name:1>/<resource:2>",
1727+
"StackName": "<stack-name:1>",
1728+
"Status": "CREATE_COMPLETE",
1729+
"ResponseMetadata": {
1730+
"HTTPHeaders": {},
1731+
"HTTPStatusCode": 200
1732+
}
1733+
},
1734+
"describe_stack_on_create_complete": {
1735+
"Stacks": [
1736+
{
1737+
"Capabilities": [
1738+
"CAPABILITY_AUTO_EXPAND",
1739+
"CAPABILITY_IAM",
1740+
"CAPABILITY_NAMED_IAM"
1741+
],
1742+
"ChangeSetId": "arn:<partition>:cloudformation:<region>:111111111111:changeSet/<resource:1>",
1743+
"CreationTime": "datetime",
1744+
"DisableRollback": false,
1745+
"DriftInformation": {
1746+
"StackDriftStatus": "NOT_CHECKED"
1747+
},
1748+
"EnableTerminationProtection": false,
1749+
"LastUpdatedTime": "datetime",
1750+
"NotificationARNs": [],
1751+
"Outputs": [
1752+
{
1753+
"Description": "Secret value from parameter",
1754+
"OutputKey": "SecretValue",
1755+
"OutputValue": "SecretValue"
1756+
}
1757+
],
1758+
"Parameters": [
1759+
{
1760+
"ParameterKey": "NormalParameter",
1761+
"ParameterValue": "Some default value here"
1762+
},
1763+
{
1764+
"ParameterKey": "SecretParameter",
1765+
"ParameterValue": "****"
1766+
},
1767+
{
1768+
"ParameterKey": "SecretParameterWithDefault",
1769+
"ParameterValue": "****"
1770+
}
1771+
],
1772+
"RollbackConfiguration": {},
1773+
"StackId": "arn:<partition>:cloudformation:<region>:111111111111:stack/<stack-name:1>/<resource:2>",
1774+
"StackName": "<stack-name:1>",
1775+
"StackStatus": "CREATE_COMPLETE",
1776+
"Tags": []
1777+
}
1778+
],
1779+
"ResponseMetadata": {
1780+
"HTTPHeaders": {},
1781+
"HTTPStatusCode": 200
1782+
}
1783+
},
16491784
"describe_updated_stacks": {
16501785
"Stacks": [
16511786
{
@@ -1697,8 +1832,8 @@
16971832
},
16981833
"describe_updated_change_set": {
16991834
"Capabilities": [],
1700-
"ChangeSetId": "arn:<partition>:cloudformation:<region>:111111111111:changeSet/<resource:3>",
1701-
"ChangeSetName": "<change-set-name:1>",
1835+
"ChangeSetId": "arn:<partition>:cloudformation:<region>:111111111111:changeSet/<resource:4>",
1836+
"ChangeSetName": "<change-set-name:2>",
17021837
"Changes": [
17031838
{
17041839
"ResourceChange": {
@@ -1831,8 +1966,8 @@
18311966
},
18321967
"describe_updated_change_set_no_echo_true": {
18331968
"Capabilities": [],
1834-
"ChangeSetId": "arn:<partition>:cloudformation:<region>:111111111111:changeSet/<resource:4>",
1835-
"ChangeSetName": "<change-set-name:2>",
1969+
"ChangeSetId": "arn:<partition>:cloudformation:<region>:111111111111:changeSet/<resource:5>",
1970+
"ChangeSetName": "<change-set-name:3>",
18361971
"Changes": [
18371972
{
18381973
"ResourceChange": {
@@ -1965,8 +2100,8 @@
19652100
},
19662101
"describe_updated_change_set_no_echo_false": {
19672102
"Capabilities": [],
1968-
"ChangeSetId": "arn:<partition>:cloudformation:<region>:111111111111:changeSet/<resource:5>",
1969-
"ChangeSetName": "<change-set-name:3>",
2103+
"ChangeSetId": "arn:<partition>:cloudformation:<region>:111111111111:changeSet/<resource:6>",
2104+
"ChangeSetName": "<change-set-name:4>",
19702105
"Changes": [
19712106
{
19722107
"ResourceChange": {

tests/aws/services/cloudformation/api/test_stacks.validation.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,13 @@
7575
"last_validated_date": "2024-03-26T17:59:43+00:00"
7676
},
7777
"tests/aws/services/cloudformation/api/test_stacks.py::test_no_echo_parameter": {
78-
"last_validated_date": "2024-12-19T11:35:15+00:00"
78+
"last_validated_date": "2025-08-15T12:01:58+00:00",
79+
"durations_in_seconds": {
80+
"setup": 1.28,
81+
"call": 67.64,
82+
"teardown": 4.43,
83+
"total": 73.35
84+
}
7985
},
8086
"tests/aws/services/cloudformation/api/test_stacks.py::test_no_parameters_given": {
8187
"last_validated_date": "2025-07-31T16:12:14+00:00",

tests/aws/services/cloudformation/api/test_transformers.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,6 @@ def test_duplicate_resources(deploy_cfn_template, s3_bucket, snapshot, aws_clien
7272
snapshot.match("api-resources", resources)
7373

7474

75-
@skip_if_v2_provider(
76-
"AWS::Include",
77-
reason="The transformation is run however the physical resource id for the resource is not available",
78-
)
7975
@markers.aws.validated
8076
def test_transformer_property_level(deploy_cfn_template, s3_bucket, aws_client, snapshot):
8177
api_spec = textwrap.dedent("""

0 commit comments

Comments
 (0)