From e0c26d1895f043ec92f82d5a76871527972af607 Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Wed, 28 May 2025 14:31:12 +0200 Subject: [PATCH 1/2] fix CloudFormation SNS Subscribe with Region parameter --- .../aws_sns_subscription.py | 15 ++++++- .../cloudformation/resources/test_sns.py | 41 +++++++++++++++++++ .../resources/test_sns.snapshot.json | 22 ++++++++++ .../resources/test_sns.validation.json | 3 ++ .../sns_subscription_cross_region.yml | 24 +++++++++++ 5 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/aws/templates/sns_subscription_cross_region.yml diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py b/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py index af59bbde4f0aa..e270f50a0994a 100644 --- a/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py @@ -6,7 +6,9 @@ from typing import Optional, TypedDict import localstack.services.cloudformation.provider_utils as util +from localstack import config from localstack.services.cloudformation.resource_provider import ( + ConvertingInternalClientFactory, OperationStatus, ProgressEvent, ResourceProvider, @@ -62,7 +64,18 @@ def create( """ model = request.desired_state - sns = request.aws_client_factory.sns + if subscription_region := model.get("Region"): + # FIXME: this is hacky, maybe we should have access to the original parameters for the `aws_client_factory` + # as we now need to manually use them + # Not all internal CloudFormation requests will be directed to the same region and account + # maybe we could need to expose a proper client factory where we can override some parameters like the + # Region + factory = ConvertingInternalClientFactory(use_ssl=config.DISTRIBUTED_MODE) + client_params = dict(request.aws_client_factory._client_creation_params) + client_params["region_name"] = subscription_region + sns = factory(**client_params).sns + else: + sns = request.aws_client_factory.sns params = util.select_attributes(model=model, params=["TopicArn", "Protocol", "Endpoint"]) diff --git a/tests/aws/services/cloudformation/resources/test_sns.py b/tests/aws/services/cloudformation/resources/test_sns.py index cf3beb6d792aa..8cf8bfa5f0a66 100644 --- a/tests/aws/services/cloudformation/resources/test_sns.py +++ b/tests/aws/services/cloudformation/resources/test_sns.py @@ -150,3 +150,44 @@ def test_sns_topic_with_attributes(infrastructure_setup, aws_client, snapshot): TopicArn=outputs["TopicArn"], ) snapshot.match("topic-archive-policy", response["Attributes"]["ArchivePolicy"]) + + +@markers.aws.validated +def test_sns_subscription_region( + snapshot, + deploy_cfn_template, + aws_client, + sqs_queue, + aws_client_factory, + region_name, + secondary_region_name, + cleanups, +): + snapshot.add_transformer(snapshot.transform.cloudformation_api()) + snapshot.add_transformer(snapshot.transform.regex(secondary_region_name, "")) + topic_name = f"topic-{short_uid()}" + # we create a topic in a secondary region, different from the stack + sns_client = aws_client_factory(region_name=secondary_region_name).sns + topic_arn = sns_client.create_topic(Name=topic_name)["TopicArn"] + cleanups.append(lambda: sns_client.delete_topic(TopicArn=topic_arn)) + + queue_url = sqs_queue + queue_arn = aws_client.sqs.get_queue_attributes( + QueueUrl=queue_url, AttributeNames=["QueueArn"] + )["Attributes"]["QueueArn"] + + # we want to deploy the Stack in a different region than the Topic, to see how CloudFormation properly does the + # `Subscribe` call in the `Region` parameter of the Subscription resource + stack = deploy_cfn_template( + parameters={ + "TopicArn": topic_arn, + "QueueArn": queue_arn, + "TopicRegion": secondary_region_name, + }, + template_path=os.path.join( + os.path.dirname(__file__), "../../../templates/sns_subscription_cross_region.yml" + ), + ) + sub_arn = stack.outputs["SubscriptionArn"] + subscription = sns_client.get_subscription_attributes(SubscriptionArn=sub_arn) + snapshot.match("subscription-1", subscription) diff --git a/tests/aws/services/cloudformation/resources/test_sns.snapshot.json b/tests/aws/services/cloudformation/resources/test_sns.snapshot.json index 6d36dbe9e1f5b..1ffe9aa381b61 100644 --- a/tests/aws/services/cloudformation/resources/test_sns.snapshot.json +++ b/tests/aws/services/cloudformation/resources/test_sns.snapshot.json @@ -112,5 +112,27 @@ "MessageRetentionPeriod": "30" } } + }, + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_subscription_region": { + "recorded-date": "28-05-2025, 10:47:01", + "recorded-content": { + "subscription-1": { + "Attributes": { + "ConfirmationWasAuthenticated": "true", + "Endpoint": "arn::sqs::111111111111:", + "Owner": "111111111111", + "PendingConfirmation": "false", + "Protocol": "sqs", + "RawMessageDelivery": "true", + "SubscriptionArn": "arn::sns::111111111111::", + "SubscriptionPrincipal": "arn::iam::111111111111:user/", + "TopicArn": "arn::sns::111111111111:" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } } } diff --git a/tests/aws/services/cloudformation/resources/test_sns.validation.json b/tests/aws/services/cloudformation/resources/test_sns.validation.json index 4f2b5f8cb5424..43940c1fee010 100644 --- a/tests/aws/services/cloudformation/resources/test_sns.validation.json +++ b/tests/aws/services/cloudformation/resources/test_sns.validation.json @@ -1,4 +1,7 @@ { + "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_subscription_region": { + "last_validated_date": "2025-05-28T10:46:56+00:00" + }, "tests/aws/services/cloudformation/resources/test_sns.py::test_sns_topic_fifo_with_deduplication": { "last_validated_date": "2023-11-27T20:27:29+00:00" }, diff --git a/tests/aws/templates/sns_subscription_cross_region.yml b/tests/aws/templates/sns_subscription_cross_region.yml new file mode 100644 index 0000000000000..773f708547eb6 --- /dev/null +++ b/tests/aws/templates/sns_subscription_cross_region.yml @@ -0,0 +1,24 @@ +Parameters: + TopicArn: + Type: String + Description: The ARN of the SNS topic to subscribe to + QueueArn: + Type: String + Description: The URL of the SQS queue to send messages to + TopicRegion: + Type: String + Description: The region of the SNS Topic +Resources: + SnsSubscription: + Type: AWS::SNS::Subscription + Properties: + Protocol: sqs + TopicArn: !Ref TopicArn + Endpoint: !Ref QueueArn + RawMessageDelivery: true + Region: !Ref TopicRegion + +Outputs: + SubscriptionArn: + Value: !Ref SnsSubscription + Description: The ARN of the SNS subscription From 300ca0c434cb27e2ceb5821d456187da8af4209c Mon Sep 17 00:00:00 2001 From: Benjamin Simon Date: Wed, 28 May 2025 14:45:59 +0200 Subject: [PATCH 2/2] refactor to be used in Update as well --- .../aws_sns_subscription.py | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py b/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py index e270f50a0994a..650df889dff02 100644 --- a/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py +++ b/localstack-core/localstack/services/sns/resource_providers/aws_sns_subscription.py @@ -7,6 +7,7 @@ import localstack.services.cloudformation.provider_utils as util from localstack import config +from localstack.aws.connect import ServiceLevelClientFactory from localstack.services.cloudformation.resource_provider import ( ConvertingInternalClientFactory, OperationStatus, @@ -64,18 +65,7 @@ def create( """ model = request.desired_state - if subscription_region := model.get("Region"): - # FIXME: this is hacky, maybe we should have access to the original parameters for the `aws_client_factory` - # as we now need to manually use them - # Not all internal CloudFormation requests will be directed to the same region and account - # maybe we could need to expose a proper client factory where we can override some parameters like the - # Region - factory = ConvertingInternalClientFactory(use_ssl=config.DISTRIBUTED_MODE) - client_params = dict(request.aws_client_factory._client_creation_params) - client_params["region_name"] = subscription_region - sns = factory(**client_params).sns - else: - sns = request.aws_client_factory.sns + sns = self._get_client(request).sns params = util.select_attributes(model=model, params=["TopicArn", "Protocol", "Endpoint"]) @@ -141,7 +131,7 @@ def update( """ model = request.desired_state model["Id"] = request.previous_state["Id"] - sns = request.aws_client_factory.sns + sns = self._get_client(request).sns attrs = [ "DeliveryPolicy", @@ -166,3 +156,23 @@ def update( @staticmethod def attr_val(val): return json.dumps(val) if isinstance(val, dict) else str(val) + + @staticmethod + def _get_client( + request: ResourceRequest[SNSSubscriptionProperties], + ) -> ServiceLevelClientFactory: + model = request.desired_state + if subscription_region := model.get("Region"): + # FIXME: this is hacky, maybe we should have access to the original parameters for the `aws_client_factory` + # as we now need to manually use them + # Not all internal CloudFormation requests will be directed to the same region and account + # maybe we could need to expose a proper client factory where we can override some parameters like the + # Region + factory = ConvertingInternalClientFactory(use_ssl=config.DISTRIBUTED_MODE) + client_params = dict(request.aws_client_factory._client_creation_params) + client_params["region_name"] = subscription_region + service_factory = factory(**client_params) + else: + service_factory = request.aws_client_factory + + return service_factory