From 45b2684eb6024d2b2bfa901afe651d565b25767b Mon Sep 17 00:00:00 2001 From: Paul Roberts Date: Thu, 16 Sep 2021 02:26:50 -0700 Subject: [PATCH] Publish messages to SNS when CloudFormation NotifcationARNs is set (#4295) Co-authored-by: Paul Roberts --- moto/cloudformation/models.py | 61 ++++++++++++---- .../test_cloudformation_stack_crud.py | 70 ++++++++++++++++--- .../test_cloudformation_stack_crud_boto3.py | 64 +++++++++++++++-- 3 files changed, 170 insertions(+), 25 deletions(-) diff --git a/moto/cloudformation/models.py b/moto/cloudformation/models.py index 808ea6a7b..8fddcdab1 100644 --- a/moto/cloudformation/models.py +++ b/moto/cloudformation/models.py @@ -8,7 +8,12 @@ from boto3 import Session from collections import OrderedDict from moto.core import BaseBackend, BaseModel -from moto.core.utils import iso_8601_datetime_without_milliseconds +from moto.core.models import ACCOUNT_ID +from moto.core.utils import ( + iso_8601_datetime_with_milliseconds, + iso_8601_datetime_without_milliseconds, +) +from moto.sns.models import sns_backends from .parsing import ResourceMap, OutputMap from .utils import ( @@ -261,19 +266,21 @@ class FakeStack(BaseModel): def _add_stack_event( self, resource_status, resource_status_reason=None, resource_properties=None ): - self.events.append( - FakeEvent( - stack_id=self.stack_id, - stack_name=self.name, - logical_resource_id=self.name, - physical_resource_id=self.stack_id, - resource_type="AWS::CloudFormation::Stack", - resource_status=resource_status, - resource_status_reason=resource_status_reason, - resource_properties=resource_properties, - ) + + event = FakeEvent( + stack_id=self.stack_id, + stack_name=self.name, + logical_resource_id=self.name, + physical_resource_id=self.stack_id, + resource_type="AWS::CloudFormation::Stack", + resource_status=resource_status, + resource_status_reason=resource_status_reason, + resource_properties=resource_properties, ) + event.sendToSns(self.region_name, self.notification_arns) + self.events.append(event) + def _add_resource_event( self, logical_resource_id, @@ -431,6 +438,7 @@ class FakeEvent(BaseModel): resource_status, resource_status_reason=None, resource_properties=None, + client_request_token=None, ): self.stack_id = stack_id self.stack_name = stack_name @@ -442,6 +450,35 @@ class FakeEvent(BaseModel): self.resource_properties = resource_properties self.timestamp = datetime.utcnow() self.event_id = uuid.uuid4() + self.client_request_token = client_request_token + + def sendToSns(self, region, sns_topic_arns): + message = """StackId='{stack_id}' +Timestamp='{timestamp}' +EventId='{event_id}' +LogicalResourceId='{logical_resource_id}' +Namespace='{account_id}' +ResourceProperties='{resource_properties}' +ResourceStatus='{resource_status}' +ResourceStatusReason='{resource_status_reason}' +ResourceType='{resource_type}' +StackName='{stack_name}' +ClientRequestToken='{client_request_token}'""".format( + stack_id=self.stack_id, + timestamp=iso_8601_datetime_with_milliseconds(self.timestamp), + event_id=self.event_id, + logical_resource_id=self.logical_resource_id, + account_id=ACCOUNT_ID, + resource_properties=self.resource_properties, + resource_status=self.resource_status, + resource_status_reason=self.resource_status_reason, + resource_type=self.resource_type, + stack_name=self.stack_name, + client_request_token=self.client_request_token, + ) + + for sns_topic_arn in sns_topic_arns: + sns_backends[region].publish(message, arn=sns_topic_arn) def filter_stacks(all_stacks, status_filter): diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud.py b/tests/test_cloudformation/test_cloudformation_stack_crud.py index 9c1a723a0..7093296a3 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_crud.py +++ b/tests/test_cloudformation/test_cloudformation_stack_crud.py @@ -11,6 +11,7 @@ import boto.s3 import boto.s3.key import boto.cloudformation from boto.exception import BotoServerError +from freezegun import freeze_time import sure # noqa import pytest @@ -19,6 +20,8 @@ from moto.core import ACCOUNT_ID from moto import ( mock_cloudformation_deprecated, mock_s3_deprecated, + mock_sns_deprecated, + mock_sqs_deprecated, mock_route53_deprecated, mock_iam_deprecated, mock_dynamodb2_deprecated, @@ -151,18 +154,69 @@ def test_creating_stacks_across_regions(): @mock_cloudformation_deprecated +@mock_sns_deprecated +@mock_sqs_deprecated def test_create_stack_with_notification_arn(): + sqs_conn = boto.connect_sqs() + queue = sqs_conn.create_queue("fake-queue", visibility_timeout=3) + queue_arn = queue.get_attributes()["QueueArn"] + + sns_conn = boto.connect_sns() + topic = sns_conn.create_topic("fake-topic") + topic_arn = topic["CreateTopicResponse"]["CreateTopicResult"]["TopicArn"] + + sns_conn.subscribe(topic_arn, "sqs", queue_arn) + conn = boto.connect_cloudformation() - conn.create_stack( - "test_stack_with_notifications", - template_body=dummy_template_json, - notification_arns="arn:aws:sns:us-east-1:{}:fake-queue".format(ACCOUNT_ID), - ) + with freeze_time("2015-01-01 12:00:00"): + conn.create_stack( + "test_stack_with_notifications", + template_body=dummy_template_json, + notification_arns=topic_arn, + ) stack = conn.describe_stacks()[0] - [n.value for n in stack.notification_arns].should.contain( - "arn:aws:sns:us-east-1:{}:fake-queue".format(ACCOUNT_ID) - ) + [n.value for n in stack.notification_arns].should.contain(topic_arn) + + with freeze_time("2015-01-01 12:00:01"): + message = queue.read(1) + + msg = json.loads(message.get_body()) + msg["Message"].should.contain("StackId='{}'\n".format(stack.stack_id)) + msg["Message"].should.contain("Timestamp='2015-01-01T12:00:00.000Z'\n") + msg["Message"].should.contain("LogicalResourceId='test_stack_with_notifications'\n") + msg["Message"].should.contain("ResourceStatus='CREATE_IN_PROGRESS'\n") + msg["Message"].should.contain("ResourceStatusReason='User Initiated'\n") + msg["Message"].should.contain("ResourceType='AWS::CloudFormation::Stack'\n") + msg["Message"].should.contain("StackName='test_stack_with_notifications'\n") + msg.should.have.key("MessageId") + msg.should.have.key("Signature") + msg.should.have.key("SignatureVersion") + msg.should.have.key("Subject") + msg["Timestamp"].should.equal("2015-01-01T12:00:00.000Z") + msg["TopicArn"].should.equal(topic_arn) + msg.should.have.key("Type") + msg.should.have.key("UnsubscribeURL") + + with freeze_time("2015-01-01 12:00:02"): + message = queue.read(1) + + msg = json.loads(message.get_body()) + msg["Message"].should.contain("StackId='{}'\n".format(stack.stack_id)) + msg["Message"].should.contain("Timestamp='2015-01-01T12:00:00.000Z'\n") + msg["Message"].should.contain("LogicalResourceId='test_stack_with_notifications'\n") + msg["Message"].should.contain("ResourceStatus='CREATE_COMPLETE'\n") + msg["Message"].should.contain("ResourceStatusReason='None'\n") + msg["Message"].should.contain("ResourceType='AWS::CloudFormation::Stack'\n") + msg["Message"].should.contain("StackName='test_stack_with_notifications'\n") + msg.should.have.key("MessageId") + msg.should.have.key("Signature") + msg.should.have.key("SignatureVersion") + msg.should.have.key("Subject") + msg["Timestamp"].should.equal("2015-01-01T12:00:00.000Z") + msg["TopicArn"].should.equal(topic_arn) + msg.should.have.key("Type") + msg.should.have.key("UnsubscribeURL") @mock_cloudformation_deprecated diff --git a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py index 1c2c546cd..e28cd0ac2 100644 --- a/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py +++ b/tests/test_cloudformation/test_cloudformation_stack_crud_boto3.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import json from collections import OrderedDict from datetime import datetime, timedelta +from freezegun import freeze_time import pytz import boto3 @@ -11,7 +12,14 @@ import sure # noqa import pytest -from moto import mock_cloudformation, mock_dynamodb2, mock_s3, mock_sqs, mock_ec2 +from moto import ( + mock_cloudformation, + mock_dynamodb2, + mock_s3, + mock_sns, + mock_sqs, + mock_ec2, +) from moto.core import ACCOUNT_ID from .test_cloudformation_stack_crud import dummy_template_json2, dummy_template_json4 from tests import EXAMPLE_AMI_ID @@ -901,18 +909,64 @@ def test_creating_stacks_across_regions(): @mock_cloudformation +@mock_sns +@mock_sqs def test_create_stack_with_notification_arn(): + sqs = boto3.resource("sqs", region_name="us-east-1") + queue = sqs.create_queue(QueueName="fake-queue") + queue_arn = queue.attributes["QueueArn"] + + sns = boto3.client("sns", region_name="us-east-1") + topic = sns.create_topic(Name="fake-topic") + topic_arn = topic["TopicArn"] + + sns.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn) + cf = boto3.resource("cloudformation", region_name="us-east-1") cf.create_stack( StackName="test_stack_with_notifications", TemplateBody=dummy_template_json, - NotificationARNs=["arn:aws:sns:us-east-1:{}:fake-queue".format(ACCOUNT_ID)], + NotificationARNs=[topic_arn], ) stack = list(cf.stacks.all())[0] - stack.notification_arns.should.contain( - "arn:aws:sns:us-east-1:{}:fake-queue".format(ACCOUNT_ID) - ) + stack.notification_arns.should.contain(topic_arn) + + messages = queue.receive_messages() + messages.should.have.length_of(1) + msg = json.loads(messages[0].body) + msg["Message"].should.contain("StackId='{}'\n".format(stack.stack_id)) + msg["Message"].should.contain("LogicalResourceId='test_stack_with_notifications'\n") + msg["Message"].should.contain("ResourceStatus='CREATE_IN_PROGRESS'\n") + msg["Message"].should.contain("ResourceStatusReason='User Initiated'\n") + msg["Message"].should.contain("ResourceType='AWS::CloudFormation::Stack'\n") + msg["Message"].should.contain("StackName='test_stack_with_notifications'\n") + msg.should.have.key("MessageId") + msg.should.have.key("Signature") + msg.should.have.key("SignatureVersion") + msg.should.have.key("Subject") + msg.should.have.key("Timestamp") + msg["TopicArn"].should.equal(topic_arn) + msg.should.have.key("Type") + msg.should.have.key("UnsubscribeURL") + + messages = queue.receive_messages() + messages.should.have.length_of(1) + msg = json.loads(messages[0].body) + msg["Message"].should.contain("StackId='{}'\n".format(stack.stack_id)) + msg["Message"].should.contain("LogicalResourceId='test_stack_with_notifications'\n") + msg["Message"].should.contain("ResourceStatus='CREATE_COMPLETE'\n") + msg["Message"].should.contain("ResourceStatusReason='None'\n") + msg["Message"].should.contain("ResourceType='AWS::CloudFormation::Stack'\n") + msg["Message"].should.contain("StackName='test_stack_with_notifications'\n") + msg.should.have.key("MessageId") + msg.should.have.key("Signature") + msg.should.have.key("SignatureVersion") + msg.should.have.key("Subject") + msg.should.have.key("Timestamp") + msg["TopicArn"].should.equal(topic_arn) + msg.should.have.key("Type") + msg.should.have.key("UnsubscribeURL") @mock_cloudformation