Skip to content

CFNV2: handle AWS::NoValue #13000

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 18 commits into from
Aug 15, 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 @@ -257,10 +257,11 @@ def execute_macro(
formatted_stack_parameters = {}
for key, value in stack_parameters.items():
# TODO: we want to support other types of parameters
if value.get("ParameterType") == "CommaDelimitedList":
formatted_stack_parameters[key] = value.get("ParameterValue").split(",")
parameter_value = value.get("ParameterValue")
if value.get("ParameterType") == "CommaDelimitedList" and isinstance(parameter_value, str):
formatted_stack_parameters[key] = parameter_value.split(",")
else:
formatted_stack_parameters[key] = value.get("ParameterValue")
formatted_stack_parameters[key] = parameter_value

transformation_id = f"{account_id}::{macro['Name']}"
event = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,10 @@ def _visit_intrinsic_function(
arguments = self._visit_value(
scope=arguments_scope, before_value=before_arguments, after_value=after_arguments
)

if intrinsic_function == "Ref" and arguments.value == "AWS::NoValue":
arguments.value = Nothing

if is_created(before=before_arguments, after=after_arguments):
change_type = ChangeType.CREATED
elif is_removed(before=before_arguments, after=after_arguments):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,7 @@ def visit_node_parameter(self, node_parameter: NodeParameter) -> PreprocEntityDe

def _resolve_parameter_type(value: str, type_: str) -> Any:
match type_:
case "List<String>":
case "List<String>" | "CommaDelimitedList":
return [item.strip() for item in value.split(",")]
return value

Expand Down Expand Up @@ -965,6 +965,9 @@ def visit_node_intrinsic_function_ref(
self, node_intrinsic_function: NodeIntrinsicFunction
) -> PreprocEntityDelta:
def _compute_fn_ref(logical_id: str) -> PreprocEntityDelta:
if logical_id == "AWS::NoValue":
return Nothing

reference_delta: PreprocEntityDelta = self._resolve_reference(logical_id=logical_id)
if isinstance(before := reference_delta.before, PreprocResource):
reference_delta.before = before.physical_resource_id
Expand Down
79 changes: 51 additions & 28 deletions localstack-core/localstack/services/cloudformation/v2/provider.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import copy
import json
import logging
import re
from collections import defaultdict
from datetime import UTC, datetime

Expand Down Expand Up @@ -122,6 +123,10 @@

LOG = logging.getLogger(__name__)

SSM_PARAMETER_TYPE_RE = re.compile(
r"^AWS::SSM::Parameter::Value<(?P<listtype>List<)?(?P<innertype>[^>]+)>?>$"
)


def is_stack_arn(stack_name_or_id: str) -> bool:
return ARN_STACK_REGEX.match(stack_name_or_id) is not None
Expand Down Expand Up @@ -224,16 +229,30 @@ def _resolve_parameters(
type_=parameter["Type"], given_value=given_value, default_value=default_value
)

if parameter["Type"] == "AWS::SSM::Parameter::Value<String>":
# TODO: support other parameter types
try:
resolved_parameter["resolved_value"] = resolve_ssm_parameter(
account_id, region_name, given_value or default_value
)
except Exception:
raise ValidationError(
f"Parameter {name} should either have input value or default value"
)
# TODO: support other parameter types
if match := SSM_PARAMETER_TYPE_RE.match(parameter["Type"]):
inner_type = match.group("innertype")
is_list_type = match.group("listtype") is not None
if is_list_type or inner_type == "CommaDelimitedList":
# list types
try:
resolved_value = resolve_ssm_parameter(
account_id, region_name, given_value or default_value
)
resolved_parameter["resolved_value"] = resolved_value.split(",")
except Exception:
raise ValidationError(
f"Parameter {name} should either have input value or default value"
)
else:
try:
resolved_parameter["resolved_value"] = resolve_ssm_parameter(
account_id, region_name, given_value or default_value
)
except Exception:
raise ValidationError(
f"Parameter {name} should either have input value or default value"
)
elif given_value is None and default_value is None:
invalid_parameters.append(name)
continue
Expand Down Expand Up @@ -609,11 +628,24 @@ def _describe_change_set(
NotificationARNs=[],
)
if change_set.resolved_parameters:
result["Parameters"] = [
# TODO: add masking support.
Parameter(ParameterKey=key, ParameterValue=value)
for (key, value) in change_set.resolved_parameters.items()
]
result["Parameters"] = self._render_resolved_parameters(change_set.resolved_parameters)
return result

@staticmethod
def _render_resolved_parameters(
resolved_parameters: dict[str, EngineParameter],
) -> list[Parameter]:
result = []
for name, resolved_parameter in resolved_parameters.items():
parameter = Parameter(
ParameterKey=name,
ParameterValue=resolved_parameter.get("given_value")
or resolved_parameter.get("default_value"),
)
if resolved_value := resolved_parameter.get("resolved_value"):
parameter["ResolvedValue"] = resolved_value
result.append(parameter)

return result

@handler("DescribeChangeSet")
Expand Down Expand Up @@ -818,8 +850,7 @@ def describe_stacks(

return DescribeStacksOutput(Stacks=describe_stack_output)

@staticmethod
def _describe_stack(stack: Stack) -> ApiStack:
def _describe_stack(self, stack: Stack) -> ApiStack:
stack_description = ApiStack(
Description=stack.description,
CreationTime=stack.creation_time,
Expand All @@ -835,7 +866,6 @@ def _describe_stack(stack: Stack) -> ApiStack:
RollbackConfiguration=RollbackConfiguration(),
Tags=[],
NotificationARNs=[],
# "Parameters": stack.resolved_parameters,
)
if stack.status != StackStatus.REVIEW_IN_PROGRESS:
# TODO: actually track updated time
Expand All @@ -847,16 +877,9 @@ def _describe_stack(stack: Stack) -> ApiStack:
stack_description["ChangeSetId"] = change_set_id

if stack.resolved_parameters:
stack_description["Parameters"] = []
for name, resolved_parameter in stack.resolved_parameters.items():
parameter = Parameter(
ParameterKey=name,
ParameterValue=resolved_parameter.get("given_value")
or resolved_parameter.get("default_value"),
)
if resolved_value := resolved_parameter.get("resolved_value"):
parameter["ResolvedValue"] = resolved_value
stack_description["Parameters"].append(parameter)
stack_description["Parameters"] = self._render_resolved_parameters(
stack.resolved_parameters
)

if stack.resolved_outputs:
stack_description["Outputs"] = stack.resolved_outputs
Expand Down
1 change: 0 additions & 1 deletion tests/aws/services/cloudformation/api/test_changesets.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,6 @@ def test_create_change_set_missing_stackname(aws_client):
)


@skip_if_v2_provider("Resolve")
@markers.aws.validated
def test_create_change_set_with_ssm_parameter(
cleanup_changesets,
Expand Down
25 changes: 25 additions & 0 deletions tests/aws/services/cloudformation/engine/test_references.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,28 @@ def test_resolve_transitive_placeholders_in_strings(deploy_cfn_template, aws_cli
snapshot.transform.regex(r"/cdk-bootstrap/(\w+)/", "/cdk-bootstrap/.../")
)
snapshot.match("tags", tags)


@markers.aws.validated
@pytest.mark.parametrize("parameter_value", ["yes", "no"])
def test_aws_novalue(deploy_cfn_template, parameter_value):
"""
Test that AWS::NoValue is correctly executed in the CFn engine
"""
fallback_bucket_name = f"my-bucket-{short_uid()}"
stack = deploy_cfn_template(
template_path=os.path.join(os.path.dirname(__file__), "../../../templates/aws_novalue.yml"),
parameters={
"SetBucketName": parameter_value,
"FallbackBucketName": fallback_bucket_name,
},
)
outputs = stack.outputs

match parameter_value:
case "yes":
assert outputs["BucketName"] == fallback_bucket_name
case "no":
assert outputs["BucketName"] != fallback_bucket_name
case other:
pytest.fail(f"Test setup error, unexpected parameter value: {other}")
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@
"tests/aws/services/cloudformation/engine/test_references.py::TestFnSub::test_non_string_parameter_in_sub": {
"last_validated_date": "2024-10-17T22:49:56+00:00"
},
"tests/aws/services/cloudformation/engine/test_references.py::test_aws_novalue[no]": {
"last_validated_date": "2025-08-12T21:47:08+00:00",
"durations_in_seconds": {
"setup": 0.0,
"call": 22.44,
"teardown": 4.36,
"total": 26.8
}
},
"tests/aws/services/cloudformation/engine/test_references.py::test_aws_novalue[yes]": {
"last_validated_date": "2025-08-12T21:46:41+00:00",
"durations_in_seconds": {
"setup": 0.0,
"call": 23.36,
"teardown": 4.36,
"total": 27.72
}
},
"tests/aws/services/cloudformation/engine/test_references.py::test_resolve_transitive_placeholders_in_strings": {
"last_validated_date": "2024-06-18T19:55:48+00:00"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ class TestParity:
- Negative test: missing required properties
"""

@skip_if_v2_provider("Engine", reason="possible resource dependency issue")
@markers.aws.validated
@markers.snapshot.skip_snapshot_verify(
paths=["$..IsTruncated"]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import os

import pytest
from tests.aws.services.cloudformation.conftest import skip_if_v2_provider

from localstack.services.iam.provider import SERVICE_LINKED_ROLE_PATH_PREFIX
from localstack.testing.pytest import markers
Expand Down Expand Up @@ -202,7 +201,6 @@ def test_update_inline_policy(deploy_cfn_template, snapshot, aws_client):
snapshot.match("role_updated_inline_policy", role_updated_inline_policy_resource)


@skip_if_v2_provider("Engine", reason="Ref: AWS::NoValue")
@markers.aws.validated
@markers.snapshot.skip_snapshot_verify(
paths=[
Expand Down
3 changes: 0 additions & 3 deletions tests/aws/services/cloudformation/test_template_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,6 @@ def test_create_stack_with_ssm_parameters(
)

@markers.aws.validated
@skip_if_v2_provider("Resolve")
def test_resolve_ssm(self, create_parameter, deploy_cfn_template):
parameter_key = f"param-key-{short_uid()}"
parameter_value = f"param-value-{short_uid()}"
Expand Down Expand Up @@ -393,7 +392,6 @@ def test_resolve_ssm_with_version(self, create_parameter, deploy_cfn_template, a
topic_name = result.outputs["TopicName"]
assert topic_name == parameter_value_v1

@skip_if_v2_provider("Resolve")
@markers.aws.needs_fixing
def test_resolve_ssm_secure(self, create_parameter, deploy_cfn_template):
parameter_key = f"param-key-{short_uid()}"
Expand Down Expand Up @@ -471,7 +469,6 @@ def test_ssm_nested_with_nested_stack(self, s3_create_bucket, deploy_cfn_templat

assert ssm_parameter == key_value

@skip_if_v2_provider("Resolve", reason="stringlist type not supported yet")
@markers.aws.validated
def test_create_change_set_with_ssm_parameter_list(
self, deploy_cfn_template, aws_client, region_name, account_id, snapshot
Expand Down
22 changes: 22 additions & 0 deletions tests/aws/templates/aws_novalue.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
Parameters:
SetBucketName:
Type: String
FallbackBucketName:
Type: String
Conditions:
ShouldSetBucketName:
Fn::Equals:
- "yes"
- !Ref SetBucketName
Resources:
MyBucket:
Type: AWS::S3::Bucket
Properties:
BucketName:
Fn::If:
- ShouldSetBucketName
- !Ref FallbackBucketName
- !Ref AWS::NoValue
Outputs:
BucketName:
Value: !Ref MyBucket
Loading