Skip to content

Commit 1789f05

Browse files
authored
Merge pull request #71 from JessicaJang/sns-email-notification
Add Notification API call
2 parents f5aca7e + c69f1eb commit 1789f05

File tree

16 files changed

+436
-11
lines changed

16 files changed

+436
-11
lines changed

.custom_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ https
1414
io
1515
readthedocs
1616
vmdk
17+
SNS

awspub/configmodels.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pathlib
2+
from enum import Enum
23
from typing import Dict, List, Literal, Optional
34

45
from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -94,6 +95,34 @@ class ConfigImageSSMParameterModel(BaseModel):
9495
)
9596

9697

98+
class SNSNotificationProtocol(str, Enum):
99+
DEFAULT = "default"
100+
EMAIL = "email"
101+
102+
103+
class ConfigImageSNSNotificationModel(BaseModel):
104+
"""
105+
Image/AMI SNS Notification specific configuration to notify subscribers about new images availability
106+
"""
107+
108+
model_config = ConfigDict(extra="forbid")
109+
110+
subject: str = Field(description="The subject of SNS Notification", min_length=1, max_length=99)
111+
message: Dict[SNSNotificationProtocol, str] = Field(
112+
description="The body of the message to be sent to subscribers.",
113+
default={SNSNotificationProtocol.DEFAULT: ""},
114+
)
115+
116+
@field_validator("message")
117+
def check_message(cls, value):
118+
# Check message protocols have default key
119+
# Message should contain at least a top-level JSON key of “default”
120+
# with a value that is a string
121+
if SNSNotificationProtocol.DEFAULT not in value:
122+
raise ValueError(f"{SNSNotificationProtocol.DEFAULT.value} key is required to send SNS notification")
123+
return value
124+
125+
97126
class ConfigImageModel(BaseModel):
98127
"""
99128
Image/AMI configuration.
@@ -148,6 +177,9 @@ class ConfigImageModel(BaseModel):
148177
)
149178
groups: Optional[List[str]] = Field(description="Optional list of groups this image is part of", default=[])
150179
tags: Optional[Dict[str, str]] = Field(description="Optional Tags to apply to this image only", default={})
180+
sns: Optional[List[Dict[str, ConfigImageSNSNotificationModel]]] = Field(
181+
description="Optional list of SNS Notification related configuration", default=None
182+
)
151183

152184
@field_validator("share")
153185
@classmethod

awspub/exceptions.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,11 @@ class BucketDoesNotExistException(Exception):
1414
def __init__(self, bucket_name: str, *args, **kwargs):
1515
msg = f"The bucket named '{bucket_name}' does not exist. You will need to create the bucket before proceeding."
1616
super().__init__(msg, *args, **kwargs)
17+
18+
19+
class AWSNotificationException(Exception):
20+
pass
21+
22+
23+
class AWSAuthorizationException(Exception):
24+
pass

awspub/image.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from awspub.image_marketplace import ImageMarketplace
1515
from awspub.s3 import S3
1616
from awspub.snapshot import Snapshot
17+
from awspub.sns import SNSNotification
1718

1819
logger = logging.getLogger(__name__)
1920

@@ -353,6 +354,19 @@ def _public(self) -> None:
353354
else:
354355
logger.error(f"image {self.image_name} not available in region {region}. can not make public")
355356

357+
def _sns_publish(self) -> None:
358+
"""
359+
Publish SNS notifiations about newly available images to subscribers
360+
"""
361+
for region in self.image_regions:
362+
ec2client_region: EC2Client = boto3.client("ec2", region_name=region)
363+
image_info: Optional[_ImageInfo] = self._get(ec2client_region)
364+
365+
if not image_info:
366+
logger.error(f"can not send SNS notification for {self.image_name} because no image found in {region}")
367+
return
368+
SNSNotification(self._ctx, self.image_name, region).publish()
369+
356370
def cleanup(self) -> None:
357371
"""
358372
Cleanup/delete the temporary images
@@ -556,6 +570,10 @@ def publish(self) -> None:
556570
f"currently using partition {partition}. Ignoring marketplace config."
557571
)
558572

573+
# send ssn notification
574+
if self.conf["sns"]:
575+
self._sns_publish()
576+
559577
def _verify(self, region: str) -> List[ImageVerificationErrors]:
560578
"""
561579
Verify (but don't modify or create anything) the image in a single region

awspub/sns.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""
2+
Methods used to handle notifications for AWS using SNS
3+
"""
4+
5+
import json
6+
import logging
7+
from typing import Any, Dict, List
8+
9+
import boto3
10+
from botocore.exceptions import ClientError
11+
from mypy_boto3_sns.client import SNSClient
12+
from mypy_boto3_sts.client import STSClient
13+
14+
from awspub.context import Context
15+
from awspub.exceptions import AWSAuthorizationException, AWSNotificationException
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class SNSNotification(object):
21+
"""
22+
A data object that contains validation logic and
23+
structuring rules for SNS notification JSON
24+
"""
25+
26+
def __init__(self, context: Context, image_name: str, region_name: str):
27+
"""
28+
Construct a message and verify that it is valid
29+
"""
30+
self._ctx: Context = context
31+
self._image_name: str = image_name
32+
self._region_name: str = region_name
33+
34+
@property
35+
def conf(self) -> List[Dict[str, Any]]:
36+
"""
37+
The sns configuration for the current image (based on "image_name") from context
38+
"""
39+
return self._ctx.conf["images"][self._image_name]["sns"]
40+
41+
def _get_topic_arn(self, topic_name: str) -> str:
42+
"""
43+
Calculate topic ARN based on partition, region, account and topic name
44+
:param topic_name: Name of topic
45+
:type topic_name: str
46+
:param region_name: name of region
47+
:type region_name: str
48+
:return: return topic ARN
49+
:rtype: str
50+
"""
51+
52+
stsclient: STSClient = boto3.client("sts", region_name=self._region_name)
53+
resp = stsclient.get_caller_identity()
54+
55+
account = resp["Account"]
56+
# resp["Arn"] has string format "arn:partition:iam::accountnumber:user/iam_role"
57+
partition = resp["Arn"].rsplit(":")[1]
58+
59+
return f"arn:{partition}:sns:{self._region_name}:{account}:{topic_name}"
60+
61+
def publish(self) -> None:
62+
"""
63+
send notification to subscribers
64+
"""
65+
66+
snsclient: SNSClient = boto3.client("sns", region_name=self._region_name)
67+
68+
for topic in self.conf:
69+
for topic_name, topic_config in topic.items():
70+
try:
71+
snsclient.publish(
72+
TopicArn=self._get_topic_arn(topic_name),
73+
Subject=topic_config["subject"],
74+
Message=json.dumps(topic_config["message"]),
75+
MessageStructure="json",
76+
)
77+
except ClientError as e:
78+
exception_code: str = e.response["Error"]["Code"]
79+
if exception_code == "AuthorizationError":
80+
raise AWSAuthorizationException(
81+
"Profile does not have a permission to send the SNS notification. Please review the policy."
82+
)
83+
else:
84+
raise AWSNotificationException(str(e))
85+
logger.info(
86+
f"The SNS notification {topic_config['subject']}"
87+
f" for the topic {topic_name} in {self._region_name} has been sent."
88+
)

awspub/tests/fixtures/config1.yaml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,35 @@ awspub:
118118
-
119119
name: /awspub-test/param2
120120
allow_overwrite: true
121+
"test-image-10":
122+
boot_mode: "uefi"
123+
description: |
124+
A test image without a separate snapshot but single sns configs
125+
regions:
126+
- "us-east-1"
127+
sns:
128+
- "topic1":
129+
subject: "topic1-subject"
130+
message:
131+
default: "default-message"
132+
email: "email-message"
133+
"test-image-11":
134+
boot_mode: "uefi"
135+
description: |
136+
A test image without a separate snapshot but multiple sns configs
137+
regions:
138+
- "us-east-1"
139+
- "eu-central-1"
140+
sns:
141+
- "topic1":
142+
subject: "topic1-subject"
143+
message:
144+
default: "default-message"
145+
email: "email-message"
146+
- "topic2":
147+
subject: "topic2-subject"
148+
message:
149+
default: "default-message"
121150

122151
tags:
123152
name: "foobar"

awspub/tests/test_api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
"test-image-7",
2424
"test-image-8",
2525
"test-image-9",
26+
"test-image-10",
27+
"test-image-11",
2628
],
2729
),
2830
# with a group that no image as, no image should be processed

awspub/tests/test_image.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,16 +127,32 @@ def test_image___get_root_device_snapshot_id(root_device_name, block_device_mapp
127127

128128

129129
@pytest.mark.parametrize(
130-
"imagename,partition,called_mod_image,called_mod_snapshot,called_start_change_set,called_put_parameter",
130+
(
131+
"imagename",
132+
"partition",
133+
"called_mod_image",
134+
"called_mod_snapshot",
135+
"called_start_change_set",
136+
"called_put_parameter",
137+
"called_sns_publish",
138+
),
131139
[
132-
("test-image-6", "aws", True, True, False, False),
133-
("test-image-7", "aws", False, False, False, False),
134-
("test-image-8", "aws", True, True, True, True),
135-
("test-image-8", "aws-cn", True, True, False, True),
140+
("test-image-6", "aws", True, True, False, False, False),
141+
("test-image-7", "aws", False, False, False, False, False),
142+
("test-image-8", "aws", True, True, True, True, False),
143+
("test-image-8", "aws-cn", True, True, False, True, False),
144+
("test-image-10", "aws", False, False, False, False, True),
145+
("test-image-11", "aws", False, False, False, False, True),
136146
],
137147
)
138148
def test_image_publish(
139-
imagename, partition, called_mod_image, called_mod_snapshot, called_start_change_set, called_put_parameter
149+
imagename,
150+
partition,
151+
called_mod_image,
152+
called_mod_snapshot,
153+
called_start_change_set,
154+
called_put_parameter,
155+
called_sns_publish,
140156
):
141157
"""
142158
Test the publish() for a given image
@@ -167,13 +183,15 @@ def test_image_publish(
167183
"Regions": [{"RegionName": "eu-central-1"}, {"RegionName": "us-east-1"}]
168184
}
169185
instance.list_buckets.return_value = {"Buckets": [{"Name": "bucket1"}]}
186+
instance.list_topics.return_value = {"Topics": [{"TopicArn": "arn:aws:sns:topic1"}]}
170187
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
171188
img = image.Image(ctx, imagename)
172189
img.publish()
173190
assert instance.modify_image_attribute.called == called_mod_image
174191
assert instance.modify_snapshot_attribute.called == called_mod_snapshot
175192
assert instance.start_change_set.called == called_start_change_set
176193
assert instance.put_parameter.called == called_put_parameter
194+
assert instance.publish.called == called_sns_publish
177195

178196

179197
def test_image__get_zero_images():

0 commit comments

Comments
 (0)