Skip to content

Commit 31964b4

Browse files
committed
fix: Allow SNS config has separate list of regions
This fix enables the SNS config to specify a list of regions for sending notifications. Users can send notifications to specific regions, but the notification will be sent in every regions if not specified.
1 parent 0ac0100 commit 31964b4

File tree

9 files changed

+133
-60
lines changed

9 files changed

+133
-60
lines changed

awspub/configmodels.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ class ConfigImageSNSNotificationModel(BaseModel):
112112
description="The body of the message to be sent to subscribers.",
113113
default={SNSNotificationProtocol.DEFAULT: ""},
114114
)
115+
regions: Optional[List[str]] = Field(
116+
description="Optional list of regions for sending notification. If not given, regions where the image "
117+
"registered will be used from the currently used parition. If a region doesn't exist in the currently "
118+
"used partition, it will be ignored.",
119+
default=None,
120+
)
115121

116122
@field_validator("message")
117123
def check_message(cls, value):

awspub/image.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -342,15 +342,7 @@ def _sns_publish(self) -> None:
342342
Publish SNS notifiations about newly available images to subscribers
343343
"""
344344

345-
# Checking if the image(s) are registered or published before sending the notification
346-
for region in self.image_regions:
347-
ec2client_region: EC2Client = boto3.client("ec2", region_name=region)
348-
image_info: Optional[_ImageInfo] = self._get(ec2client_region)
349-
if not image_info:
350-
logger.error(f"can not send SNS notification for {self.image_name} because no image found in {region}")
351-
return
352-
353-
SNSNotification(self._ctx, self.image_name, self._s3.bucket_region).publish()
345+
SNSNotification(self._ctx, self.image_name).publish()
354346

355347
def cleanup(self) -> None:
356348
"""

awspub/sns.py

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ def conf(self) -> List[Dict[str, Any]]:
4040
"""
4141
return self._ctx.conf["images"][self._image_name]["sns"]
4242

43-
def _get_topic_arn(self, topic_name: str) -> str:
4443
def _sns_regions(self, topic_config: Dict[Any, Any]) -> List[str]:
4544
"""
4645
Get the sns regions. Either configured in the sns configuration
@@ -55,6 +54,7 @@ def _sns_regions(self, topic_config: Dict[Any, Any]) -> List[str]:
5554

5655
return sns_regions
5756

57+
def _get_topic_arn(self, topic_name: str, region_name: str) -> str:
5858
"""
5959
Calculate topic ARN based on partition, region, account and topic name
6060
:param topic_name: Name of topic
@@ -65,40 +65,41 @@ def _sns_regions(self, topic_config: Dict[Any, Any]) -> List[str]:
6565
:rtype: str
6666
"""
6767

68-
stsclient: STSClient = boto3.client("sts", region_name=self._region_name)
68+
stsclient: STSClient = boto3.client("sts", region_name=region_name)
6969
resp = stsclient.get_caller_identity()
7070

7171
account = resp["Account"]
7272
# resp["Arn"] has string format "arn:partition:iam::accountnumber:user/iam_role"
7373
partition = resp["Arn"].rsplit(":")[1]
7474

75-
return f"arn:{partition}:sns:{self._region_name}:{account}:{topic_name}"
75+
return f"arn:{partition}:sns:{region_name}:{account}:{topic_name}"
7676

7777
def publish(self) -> None:
7878
"""
7979
send notification to subscribers
8080
"""
8181

82-
snsclient: SNSClient = boto3.client("sns", region_name=self._region_name)
83-
8482
for topic in self.conf:
8583
for topic_name, topic_config in topic.items():
86-
try:
87-
snsclient.publish(
88-
TopicArn=self._get_topic_arn(topic_name),
89-
Subject=topic_config["subject"],
90-
Message=json.dumps(topic_config["message"]),
91-
MessageStructure="json",
92-
)
93-
except ClientError as e:
94-
exception_code: str = e.response["Error"]["Code"]
95-
if exception_code == "AuthorizationError":
96-
raise AWSAuthorizationException(
97-
"Profile does not have a permission to send the SNS notification. Please review the policy."
84+
for region_name in self._sns_regions(topic_config):
85+
snsclient: SNSClient = boto3.client("sns", region_name=region_name)
86+
try:
87+
snsclient.publish(
88+
TopicArn=self._get_topic_arn(topic_name, region_name),
89+
Subject=topic_config["subject"],
90+
Message=json.dumps(topic_config["message"]),
91+
MessageStructure="json",
9892
)
99-
else:
100-
raise AWSNotificationException(str(e))
101-
logger.info(
102-
f"The SNS notification {topic_config['subject']}"
103-
f" for the topic {topic_name} in {self._region_name} has been sent."
104-
)
93+
except ClientError as e:
94+
exception_code: str = e.response["Error"]["Code"]
95+
if exception_code == "AuthorizationError":
96+
raise AWSAuthorizationException(
97+
"Profile does not have a permission to send the SNS notification."
98+
" Please review the policy."
99+
)
100+
else:
101+
raise AWSNotificationException(str(e))
102+
logger.info(
103+
f"The SNS notification {topic_config['subject']}"
104+
f" for the topic {topic_name} in {region_name} has been sent."
105+
)

awspub/tests/fixtures/config1.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ awspub:
130130
message:
131131
default: "default-message"
132132
email: "email-message"
133+
regions:
134+
- "us-east-1"
133135
"test-image-11":
134136
boot_mode: "uefi"
135137
description: |
@@ -143,10 +145,27 @@ awspub:
143145
message:
144146
default: "default-message"
145147
email: "email-message"
148+
regions:
149+
- "us-east-1"
146150
- "topic2":
147151
subject: "topic2-subject"
148152
message:
149153
default: "default-message"
154+
regions:
155+
- "us-gov-1"
156+
- "eu-central-1"
157+
"test-image-12":
158+
boot_mode: "uefi"
159+
description: |
160+
A test image without a separate snapshot but single sns configs
161+
regions:
162+
- "us-east-1"
163+
sns:
164+
- "topic1":
165+
subject: "topic1-subject"
166+
message:
167+
default: "default-message"
168+
email: "email-message"
150169

151170
tags:
152171
name: "foobar"

awspub/tests/test_api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"test-image-9",
2626
"test-image-10",
2727
"test-image-11",
28+
"test-image-12",
2829
],
2930
),
3031
# with a group that no image as, no image should be processed

awspub/tests/test_image.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ def test_image___get_root_device_snapshot_id(root_device_name, block_device_mapp
143143
("test-image-8", "aws-cn", True, True, False, True, False),
144144
("test-image-10", "aws", False, False, False, False, True),
145145
("test-image-11", "aws", False, False, False, False, True),
146+
("test-image-12", "aws", False, False, False, False, True),
146147
],
147148
)
148149
def test_image_publish(
@@ -183,7 +184,6 @@ def test_image_publish(
183184
"Regions": [{"RegionName": "eu-central-1"}, {"RegionName": "us-east-1"}]
184185
}
185186
instance.list_buckets.return_value = {"Buckets": [{"Name": "bucket1"}]}
186-
instance.list_topics.return_value = {"Topics": [{"TopicArn": "arn:aws:sns:topic1"}]}
187187
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
188188
img = image.Image(ctx, imagename)
189189
img.publish()

awspub/tests/test_sns.py

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"imagename,called_sns_publish, publish_call_count",
1414
[
1515
("test-image-10", True, 1),
16-
("test-image-11", True, 4),
16+
("test-image-11", True, 2),
17+
("test-image-12", True, 2),
1718
],
1819
)
1920
def test_sns_publish(imagename, called_sns_publish, publish_call_count):
@@ -23,11 +24,12 @@ def test_sns_publish(imagename, called_sns_publish, publish_call_count):
2324
with patch("boto3.client") as bclient_mock:
2425
instance = bclient_mock.return_value
2526
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
26-
image_conf = ctx.conf["images"][imagename]
27-
28-
for region in image_conf["regions"]:
29-
sns.SNSNotification(ctx, imagename, region).publish()
27+
instance.describe_regions.return_value = {
28+
"Regions": [{"RegionName": "eu-central-1"}, {"RegionName": "us-east-1"}]
29+
}
30+
instance.list_buckets.return_value = {"Buckets": [{"Name": "bucket1"}]}
3031

32+
sns.SNSNotification(ctx, imagename).publish()
3133
assert instance.publish.called == called_sns_publish
3234
assert instance.publish.call_count == publish_call_count
3335

@@ -37,6 +39,7 @@ def test_sns_publish(imagename, called_sns_publish, publish_call_count):
3739
[
3840
("test-image-10"),
3941
("test-image-11"),
42+
("test-image-12"),
4043
],
4144
)
4245
def test_sns_publish_fail_with_invalid_topic(imagename):
@@ -46,12 +49,15 @@ def test_sns_publish_fail_with_invalid_topic(imagename):
4649
with patch("boto3.client") as bclient_mock:
4750
instance = bclient_mock.return_value
4851
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
49-
image_conf = ctx.conf["images"][imagename]
52+
instance.describe_regions.return_value = {
53+
"Regions": [{"RegionName": "eu-central-1"}, {"RegionName": "us-east-1"}]
54+
}
55+
instance.list_buckets.return_value = {"Buckets": [{"Name": "bucket1"}]}
5056

5157
# topic1 is invalid topic
5258
def side_effect(*args, **kwargs):
5359
topic_arn = kwargs.get("TopicArn")
54-
if "topic1" in topic_arn:
60+
if "topic1" in topic_arn and "us-east-1" in topic_arn:
5561
error_reponse = {
5662
"Error": {
5763
"Code": "NotFoundException",
@@ -63,16 +69,16 @@ def side_effect(*args, **kwargs):
6369

6470
instance.publish.side_effect = side_effect
6571

66-
for region in image_conf["regions"]:
67-
with pytest.raises(exceptions.AWSNotificationException):
68-
sns.SNSNotification(ctx, imagename, region).publish()
72+
with pytest.raises(exceptions.AWSNotificationException):
73+
sns.SNSNotification(ctx, imagename).publish()
6974

7075

7176
@pytest.mark.parametrize(
7277
"imagename",
7378
[
7479
("test-image-10"),
7580
("test-image-11"),
81+
("test-image-12"),
7682
],
7783
)
7884
def test_sns_publish_fail_with_unauthorized_user(imagename):
@@ -82,7 +88,10 @@ def test_sns_publish_fail_with_unauthorized_user(imagename):
8288
with patch("boto3.client") as bclient_mock:
8389
instance = bclient_mock.return_value
8490
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
85-
image_conf = ctx.conf["images"][imagename]
91+
instance.describe_regions.return_value = {
92+
"Regions": [{"RegionName": "eu-central-1"}, {"RegionName": "us-east-1"}]
93+
}
94+
instance.list_buckets.return_value = {"Buckets": [{"Name": "bucket1"}]}
8695

8796
error_reponse = {
8897
"Error": {
@@ -92,49 +101,89 @@ def test_sns_publish_fail_with_unauthorized_user(imagename):
92101
}
93102
instance.publish.side_effect = botocore.exceptions.ClientError(error_reponse, "")
94103

95-
for region in image_conf["regions"]:
96-
with pytest.raises(exceptions.AWSAuthorizationException):
97-
sns.SNSNotification(ctx, imagename, region).publish()
104+
with pytest.raises(exceptions.AWSAuthorizationException):
105+
sns.SNSNotification(ctx, imagename).publish()
98106

99107

100108
@pytest.mark.parametrize(
101-
"imagename, partition, expected",
109+
"imagename, partition, regions_in_partition, expected",
102110
[
103111
(
104112
"test-image-10",
105113
"aws-cn",
114+
["cn-north1", "cn-northwest-1"],
115+
[],
116+
),
117+
(
118+
"test-image-11",
119+
"aws",
120+
["us-east-1", "eu-central-1"],
106121
[
107-
"arn:aws-cn:sns:us-east-1:1234:topic1",
122+
"arn:aws:sns:us-east-1:1234:topic1",
123+
"arn:aws:sns:eu-central-1:1234:topic2",
108124
],
109125
),
110126
(
111-
"test-image-11",
127+
"test-image-12",
112128
"aws",
129+
["us-east-1", "eu-central-1"],
113130
[
114131
"arn:aws:sns:us-east-1:1234:topic1",
115-
"arn:aws:sns:us-east-1:1234:topic2",
116132
"arn:aws:sns:eu-central-1:1234:topic1",
117-
"arn:aws:sns:eu-central-1:1234:topic2",
118133
],
119134
),
120135
],
121136
)
122-
def test_sns__get_topic_arn(imagename, partition, expected):
137+
def test_sns__get_topic_arn(imagename, partition, regions_in_partition, expected):
123138
"""
124139
Test the send_notification logic
125140
"""
126141
with patch("boto3.client") as bclient_mock:
127142
instance = bclient_mock.return_value
128143
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
129-
image_conf = ctx.conf["images"][imagename]
144+
sns_conf = ctx.conf["images"][imagename]["sns"]
145+
instance.describe_regions.return_value = {"Regions": [{"RegionName": r} for r in regions_in_partition]}
146+
instance.list_buckets.return_value = {"Buckets": [{"Name": "bucket1"}]}
130147

131148
instance.get_caller_identity.return_value = {"Account": "1234", "Arn": f"arn:{partition}:iam::1234:user/test"}
132149

133150
topic_arns = []
134-
for region in image_conf["regions"]:
135-
for topic in image_conf["sns"]:
136-
topic_name = next(iter(topic))
137-
res_arn = sns.SNSNotification(ctx, imagename, region)._get_topic_arn(topic_name)
138-
topic_arns.append(res_arn)
151+
for topic in sns_conf:
152+
for topic_name, topic_conf in topic.items():
153+
sns_regions = sns.SNSNotification(ctx, imagename)._sns_regions(topic_conf)
154+
for region in sns_regions:
155+
res_arn = sns.SNSNotification(ctx, imagename)._get_topic_arn(topic_name, region)
156+
topic_arns.append(res_arn)
139157

140158
assert topic_arns == expected
159+
160+
161+
@pytest.mark.parametrize(
162+
"imagename,regions_in_partition,regions_expected",
163+
[
164+
("test-image-10", ["us-east-1", "eu-west-1"], {"topic1": ["us-east-1"]}),
165+
(
166+
"test-image-11",
167+
["us-east-1", "eu-west-1"],
168+
{"topic1": ["us-east-1"], "topic2": []},
169+
),
170+
("test-image-12", ["eu-northwest-1", "ap-southeast-1"], {"topic1": ["eu-northwest-1", "ap-southeast-1"]}),
171+
],
172+
)
173+
def test_sns_regions(imagename, regions_in_partition, regions_expected):
174+
"""
175+
Test the regions for a given image
176+
"""
177+
with patch("boto3.client") as bclient_mock:
178+
instance = bclient_mock.return_value
179+
instance.describe_regions.return_value = {"Regions": [{"RegionName": r} for r in regions_in_partition]}
180+
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
181+
sns_conf = ctx.conf["images"][imagename]["sns"]
182+
instance.list_buckets.return_value = {"Buckets": [{"Name": "bucket1"}]}
183+
184+
sns_regions = {}
185+
for topic in sns_conf:
186+
for topic_name, topic_conf in topic.items():
187+
sns_regions[topic_name] = sns.SNSNotification(ctx, imagename)._sns_regions(topic_conf)
188+
189+
assert sns_regions == regions_expected

docs/config-samples/config-minimal-sns.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ awspub:
1717
subject: "my-topic2-subject"
1818
message:
1919
default: "This is message for email protocols. New image $serial is available"
20+
regions:
21+
- us-east-1

docs/how_to/publish.rst

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,8 @@ SNS Notification
242242
~~~~~~~~~~~~~~~~
243243

244244
It's possible to publish messages through the `Simple Notification Service (SNS) <https://docs.aws.amazon.com/sns/latest/dg/welcome.html>`_.
245-
Delivery to multiple topics is possible, but the topics need to exist in each of the regions an image gets published.
245+
Delivery to multiple topics is possible, but the topics need to exist in each of the regions where the notification will be sent.
246+
246247
To notify image information to users, the ``sns`` configuration for each image must be filled:
247248

248249
.. literalinclude:: ../config-samples/config-minimal-sns.yaml
@@ -257,6 +258,8 @@ Currently, the supported protocols are ``default`` and ``email`` only, and the `
257258
send notifications.
258259
The ``default`` message will be used as a fallback message for any protocols.
259260

261+
Also, Regions can also be specified in ``sns`` configuration to indicate where the notification should be sent. If no regions are specified, SNS will default to using all regions in the partition.
262+
260263
Create the image and use the `publish` command to publish the image and also notify the published images to users:
261264

262265
.. code-block:: shell

0 commit comments

Comments
 (0)