awspub 0.0.8__tar.gz → 0.0.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {awspub-0.0.8 → awspub-0.0.9}/PKG-INFO +2 -3
- awspub-0.0.9/awspub/common.py +64 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/configmodels.py +6 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/image.py +5 -28
- awspub-0.0.9/awspub/sns.py +105 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config1.yaml +19 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_api.py +1 -0
- awspub-0.0.9/awspub/tests/test_common.py +34 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_image.py +1 -1
- awspub-0.0.9/awspub/tests/test_sns.py +189 -0
- {awspub-0.0.8 → awspub-0.0.9}/pyproject.toml +2 -3
- awspub-0.0.8/awspub/common.py +0 -18
- awspub-0.0.8/awspub/sns.py +0 -88
- awspub-0.0.8/awspub/tests/test_common.py +0 -16
- awspub-0.0.8/awspub/tests/test_sns.py +0 -140
- {awspub-0.0.8 → awspub-0.0.9}/LICENSE +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/__init__.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/api.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/cli/__init__.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/context.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/exceptions.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/image_marketplace.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/s3.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/snapshot.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/__init__.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config-invalid-s3-extra.yaml +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config-minimal.yaml +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config-valid-nonawspub.yaml +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config1.vmdk +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config2-mapping.yaml +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config2.yaml +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config3-duplicate-keys.yaml +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_cli.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_context.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_image_marketplace.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_s3.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_snapshot.py +0 -0
- {awspub-0.0.8 → awspub-0.0.9}/readme.rst +0 -0
@@ -1,16 +1,15 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: awspub
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.9
|
4
4
|
Summary: Publish images to AWS EC2
|
5
5
|
Home-page: https://github.com/canonical/awspub
|
6
6
|
License: GPL-3.0-or-later
|
7
7
|
Keywords: AWS,EC2,publication
|
8
8
|
Author: Thomas Bechtold
|
9
9
|
Author-email: thomasbechtold@jpberlin.de
|
10
|
-
Requires-Python: >=3.
|
10
|
+
Requires-Python: >=3.10,<4.0
|
11
11
|
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
13
|
-
Classifier: Programming Language :: Python :: 3.9
|
14
13
|
Classifier: Programming Language :: Python :: 3.10
|
15
14
|
Classifier: Programming Language :: Python :: 3.11
|
16
15
|
Classifier: Programming Language :: Python :: 3.12
|
@@ -0,0 +1,64 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import List, Tuple
|
3
|
+
|
4
|
+
import boto3
|
5
|
+
from mypy_boto3_ec2.client import EC2Client
|
6
|
+
|
7
|
+
logger = logging.getLogger(__name__)
|
8
|
+
|
9
|
+
|
10
|
+
def _split_partition(val: str) -> Tuple[str, str]:
|
11
|
+
"""
|
12
|
+
Split a string into partition and resource, separated by a colon. If no partition is given, assume "aws"
|
13
|
+
:param val: the string to split
|
14
|
+
:type val: str
|
15
|
+
:return: the partition and the resource
|
16
|
+
:rtype: Tuple[str, str]
|
17
|
+
"""
|
18
|
+
if ":" in val:
|
19
|
+
partition, resource = val.split(":")
|
20
|
+
else:
|
21
|
+
# if no partition is given, assume default commercial partition "aws"
|
22
|
+
partition = "aws"
|
23
|
+
resource = val
|
24
|
+
return partition, resource
|
25
|
+
|
26
|
+
|
27
|
+
def _get_regions(region_to_query: str, regions_allowlist: List[str]) -> List[str]:
|
28
|
+
"""
|
29
|
+
Get a list of region names querying the `region_to_query` for all regions and
|
30
|
+
then filtering by `regions_allowlist`.
|
31
|
+
If no `regions_allowlist` is given, all queried regions are returned for the
|
32
|
+
current partition.
|
33
|
+
If `regions_allowlist` is given, all regions from that list are returned if
|
34
|
+
the listed region exist in the current partition.
|
35
|
+
Eg. `us-east-1` listed in `regions_allowlist` won't be returned if the current
|
36
|
+
partition is `aws-cn`.
|
37
|
+
:param region_to_query: region name of current partition
|
38
|
+
:type region_to_query: str
|
39
|
+
:praram regions_allowlist: list of regions in config file
|
40
|
+
:type regions_allowlist: List[str]
|
41
|
+
:return: list of regions names
|
42
|
+
:rtype: List[str]
|
43
|
+
"""
|
44
|
+
|
45
|
+
# get all available regions
|
46
|
+
ec2client: EC2Client = boto3.client("ec2", region_name=region_to_query)
|
47
|
+
resp = ec2client.describe_regions()
|
48
|
+
ec2_regions_all = [r["RegionName"] for r in resp["Regions"]]
|
49
|
+
|
50
|
+
if regions_allowlist:
|
51
|
+
# filter out regions that are not available in the current partition
|
52
|
+
regions_allowlist_set = set(regions_allowlist)
|
53
|
+
ec2_regions_all_set = set(ec2_regions_all)
|
54
|
+
regions = list(regions_allowlist_set.intersection(ec2_regions_all_set))
|
55
|
+
diff = regions_allowlist_set.difference(ec2_regions_all_set)
|
56
|
+
if diff:
|
57
|
+
logger.warning(
|
58
|
+
f"regions {diff} listed in regions allowlist are not available in the current partition."
|
59
|
+
" Ignoring those."
|
60
|
+
)
|
61
|
+
else:
|
62
|
+
regions = ec2_regions_all
|
63
|
+
|
64
|
+
return regions
|
@@ -112,6 +112,12 @@ class ConfigImageSNSNotificationModel(BaseModel):
|
|
112
112
|
description="The body of the message to be sent to subscribers.",
|
113
113
|
default={SNSNotificationProtocol.DEFAULT: ""},
|
114
114
|
)
|
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
|
+
)
|
115
121
|
|
116
122
|
@field_validator("message")
|
117
123
|
def check_message(cls, value):
|
@@ -9,7 +9,7 @@ from mypy_boto3_ec2.client import EC2Client
|
|
9
9
|
from mypy_boto3_ssm import SSMClient
|
10
10
|
|
11
11
|
from awspub import exceptions
|
12
|
-
from awspub.common import _split_partition
|
12
|
+
from awspub.common import _get_regions, _split_partition
|
13
13
|
from awspub.context import Context
|
14
14
|
from awspub.image_marketplace import ImageMarketplace
|
15
15
|
from awspub.s3 import S3
|
@@ -121,28 +121,11 @@ class Image:
|
|
121
121
|
@property
|
122
122
|
def image_regions(self) -> List[str]:
|
123
123
|
"""
|
124
|
-
Get the image regions.
|
125
|
-
or all available regions.
|
126
|
-
If a region is listed that is not available in the currently used partition,
|
127
|
-
that region will be ignored (eg. having us-east-1 configured but running in the aws-cn
|
128
|
-
partition doesn't include us-east-1 here).
|
124
|
+
Get the image regions.
|
129
125
|
"""
|
130
126
|
if not self._image_regions_cached:
|
131
|
-
|
132
|
-
|
133
|
-
resp = ec2client.describe_regions()
|
134
|
-
image_regions_all = [r["RegionName"] for r in resp["Regions"]]
|
135
|
-
|
136
|
-
if self.conf["regions"]:
|
137
|
-
# filter out regions that are not available in the current partition
|
138
|
-
image_regions_configured_set = set(self.conf["regions"])
|
139
|
-
image_regions_all_set = set(image_regions_all)
|
140
|
-
self._image_regions = list(image_regions_configured_set.intersection(image_regions_all_set))
|
141
|
-
diff = image_regions_configured_set.difference(image_regions_all_set)
|
142
|
-
if diff:
|
143
|
-
logger.warning(f"configured regions {diff} not available in the current partition. Ignoring those.")
|
144
|
-
else:
|
145
|
-
self._image_regions = image_regions_all
|
127
|
+
regions_configured = self.conf["regions"] if "regions" in self.conf else []
|
128
|
+
self._image_regions = _get_regions(self._s3.bucket_region, regions_configured)
|
146
129
|
self._image_regions_cached = True
|
147
130
|
return self._image_regions
|
148
131
|
|
@@ -358,14 +341,8 @@ class Image:
|
|
358
341
|
"""
|
359
342
|
Publish SNS notifiations about newly available images to subscribers
|
360
343
|
"""
|
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
344
|
|
365
|
-
|
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()
|
345
|
+
SNSNotification(self._ctx, self.image_name).publish()
|
369
346
|
|
370
347
|
def cleanup(self) -> None:
|
371
348
|
"""
|
@@ -0,0 +1,105 @@
|
|
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.common import _get_regions
|
15
|
+
from awspub.context import Context
|
16
|
+
from awspub.exceptions import AWSAuthorizationException, AWSNotificationException
|
17
|
+
from awspub.s3 import S3
|
18
|
+
|
19
|
+
logger = logging.getLogger(__name__)
|
20
|
+
|
21
|
+
|
22
|
+
class SNSNotification(object):
|
23
|
+
"""
|
24
|
+
A data object that contains validation logic and
|
25
|
+
structuring rules for SNS notification JSON
|
26
|
+
"""
|
27
|
+
|
28
|
+
def __init__(self, context: Context, image_name: str):
|
29
|
+
"""
|
30
|
+
Construct a message and verify that it is valid
|
31
|
+
"""
|
32
|
+
self._ctx: Context = context
|
33
|
+
self._image_name: str = image_name
|
34
|
+
self._s3: S3 = S3(context)
|
35
|
+
|
36
|
+
@property
|
37
|
+
def conf(self) -> List[Dict[str, Any]]:
|
38
|
+
"""
|
39
|
+
The sns configuration for the current image (based on "image_name") from context
|
40
|
+
"""
|
41
|
+
return self._ctx.conf["images"][self._image_name]["sns"]
|
42
|
+
|
43
|
+
def _sns_regions(self, topic_config: Dict[Any, Any]) -> List[str]:
|
44
|
+
"""
|
45
|
+
Get the sns regions. Either configured in the sns configuration
|
46
|
+
or all available regions.
|
47
|
+
If a region is listed that is not available in the currently used partition,
|
48
|
+
that region will be ignored (eg. having us-east-1 configured but running in the aws-cn
|
49
|
+
partition doesn't include us-east-1 here).
|
50
|
+
"""
|
51
|
+
|
52
|
+
regions_configured = topic_config["regions"] if "regions" in topic_config else []
|
53
|
+
sns_regions = _get_regions(self._s3.bucket_region, regions_configured)
|
54
|
+
|
55
|
+
return sns_regions
|
56
|
+
|
57
|
+
def _get_topic_arn(self, topic_name: str, region_name: str) -> str:
|
58
|
+
"""
|
59
|
+
Calculate topic ARN based on partition, region, account and topic name
|
60
|
+
:param topic_name: Name of topic
|
61
|
+
:type topic_name: str
|
62
|
+
:param region_name: name of region
|
63
|
+
:type region_name: str
|
64
|
+
:return: return topic ARN
|
65
|
+
:rtype: str
|
66
|
+
"""
|
67
|
+
|
68
|
+
stsclient: STSClient = boto3.client("sts", region_name=region_name)
|
69
|
+
resp = stsclient.get_caller_identity()
|
70
|
+
|
71
|
+
account = resp["Account"]
|
72
|
+
# resp["Arn"] has string format "arn:partition:iam::accountnumber:user/iam_role"
|
73
|
+
partition = resp["Arn"].rsplit(":")[1]
|
74
|
+
|
75
|
+
return f"arn:{partition}:sns:{region_name}:{account}:{topic_name}"
|
76
|
+
|
77
|
+
def publish(self) -> None:
|
78
|
+
"""
|
79
|
+
send notification to subscribers
|
80
|
+
"""
|
81
|
+
|
82
|
+
for topic in self.conf:
|
83
|
+
for topic_name, topic_config in topic.items():
|
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",
|
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."
|
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
|
+
)
|
@@ -130,6 +130,8 @@ awspub:
|
|
130
130
|
message:
|
131
131
|
default: "default-message"
|
132
132
|
email: "email-message"
|
133
|
+
regions:
|
134
|
+
- "us-east-1"
|
133
135
|
"test-image-11":
|
134
136
|
boot_mode: "uefi"
|
135
137
|
description: |
|
@@ -143,10 +145,27 @@ awspub:
|
|
143
145
|
message:
|
144
146
|
default: "default-message"
|
145
147
|
email: "email-message"
|
148
|
+
regions:
|
149
|
+
- "us-east-1"
|
146
150
|
- "topic2":
|
147
151
|
subject: "topic2-subject"
|
148
152
|
message:
|
149
153
|
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"
|
150
169
|
|
151
170
|
tags:
|
152
171
|
name: "foobar"
|
@@ -0,0 +1,34 @@
|
|
1
|
+
from unittest.mock import patch
|
2
|
+
|
3
|
+
import pytest
|
4
|
+
|
5
|
+
from awspub.common import _get_regions, _split_partition
|
6
|
+
|
7
|
+
|
8
|
+
@pytest.mark.parametrize(
|
9
|
+
"input,expected_output",
|
10
|
+
[
|
11
|
+
("123456789123", ("aws", "123456789123")),
|
12
|
+
("aws:123456789123", ("aws", "123456789123")),
|
13
|
+
("aws-cn:123456789123", ("aws-cn", "123456789123")),
|
14
|
+
("aws-us-gov:123456789123", ("aws-us-gov", "123456789123")),
|
15
|
+
],
|
16
|
+
)
|
17
|
+
def test_common__split_partition(input, expected_output):
|
18
|
+
assert _split_partition(input) == expected_output
|
19
|
+
|
20
|
+
|
21
|
+
@pytest.mark.parametrize(
|
22
|
+
"regions_in_partition,configured_regions,expected_output",
|
23
|
+
[
|
24
|
+
(["region-1", "region-2"], ["region-1", "region-3"], ["region-1"]),
|
25
|
+
(["region-1", "region-2", "region-3"], ["region-4", "region-5"], []),
|
26
|
+
(["region-1", "region-2"], [], ["region-1", "region-2"]),
|
27
|
+
],
|
28
|
+
)
|
29
|
+
def test_common__get_regions(regions_in_partition, configured_regions, expected_output):
|
30
|
+
with patch("boto3.client") as bclient_mock:
|
31
|
+
instance = bclient_mock.return_value
|
32
|
+
instance.describe_regions.return_value = {"Regions": [{"RegionName": r} for r in regions_in_partition]}
|
33
|
+
|
34
|
+
assert _get_regions("", configured_regions) == expected_output
|
@@ -143,6 +143,7 @@ def test_image___get_root_device_snapshot_id(root_device_name, block_device_mapp
|
|
143
143
|
("test-image-8", "aws-cn", True, True, False, True, False),
|
144
144
|
("test-image-10", "aws", False, False, False, False, True),
|
145
145
|
("test-image-11", "aws", False, False, False, False, True),
|
146
|
+
("test-image-12", "aws", False, False, False, False, True),
|
146
147
|
],
|
147
148
|
)
|
148
149
|
def test_image_publish(
|
@@ -183,7 +184,6 @@ def test_image_publish(
|
|
183
184
|
"Regions": [{"RegionName": "eu-central-1"}, {"RegionName": "us-east-1"}]
|
184
185
|
}
|
185
186
|
instance.list_buckets.return_value = {"Buckets": [{"Name": "bucket1"}]}
|
186
|
-
instance.list_topics.return_value = {"Topics": [{"TopicArn": "arn:aws:sns:topic1"}]}
|
187
187
|
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
|
188
188
|
img = image.Image(ctx, imagename)
|
189
189
|
img.publish()
|
@@ -0,0 +1,189 @@
|
|
1
|
+
import pathlib
|
2
|
+
from unittest.mock import patch
|
3
|
+
|
4
|
+
import botocore.exceptions
|
5
|
+
import pytest
|
6
|
+
|
7
|
+
from awspub import context, exceptions, sns
|
8
|
+
|
9
|
+
curdir = pathlib.Path(__file__).parent.resolve()
|
10
|
+
|
11
|
+
|
12
|
+
@pytest.mark.parametrize(
|
13
|
+
"imagename,called_sns_publish, publish_call_count",
|
14
|
+
[
|
15
|
+
("test-image-10", True, 1),
|
16
|
+
("test-image-11", True, 2),
|
17
|
+
("test-image-12", True, 2),
|
18
|
+
],
|
19
|
+
)
|
20
|
+
def test_sns_publish(imagename, called_sns_publish, publish_call_count):
|
21
|
+
"""
|
22
|
+
Test the send_notification logic
|
23
|
+
"""
|
24
|
+
with patch("boto3.client") as bclient_mock:
|
25
|
+
instance = bclient_mock.return_value
|
26
|
+
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
|
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"}]}
|
31
|
+
|
32
|
+
sns.SNSNotification(ctx, imagename).publish()
|
33
|
+
assert instance.publish.called == called_sns_publish
|
34
|
+
assert instance.publish.call_count == publish_call_count
|
35
|
+
|
36
|
+
|
37
|
+
@pytest.mark.parametrize(
|
38
|
+
"imagename",
|
39
|
+
[
|
40
|
+
("test-image-10"),
|
41
|
+
("test-image-11"),
|
42
|
+
("test-image-12"),
|
43
|
+
],
|
44
|
+
)
|
45
|
+
def test_sns_publish_fail_with_invalid_topic(imagename):
|
46
|
+
"""
|
47
|
+
Test the send_notification logic
|
48
|
+
"""
|
49
|
+
with patch("boto3.client") as bclient_mock:
|
50
|
+
instance = bclient_mock.return_value
|
51
|
+
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
|
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"}]}
|
56
|
+
|
57
|
+
# topic1 is invalid topic
|
58
|
+
def side_effect(*args, **kwargs):
|
59
|
+
topic_arn = kwargs.get("TopicArn")
|
60
|
+
if "topic1" in topic_arn and "us-east-1" in topic_arn:
|
61
|
+
error_reponse = {
|
62
|
+
"Error": {
|
63
|
+
"Code": "NotFoundException",
|
64
|
+
"Message": "An error occurred (NotFound) when calling the Publish operation: "
|
65
|
+
"Topic does not exist.",
|
66
|
+
}
|
67
|
+
}
|
68
|
+
raise botocore.exceptions.ClientError(error_reponse, "")
|
69
|
+
|
70
|
+
instance.publish.side_effect = side_effect
|
71
|
+
|
72
|
+
with pytest.raises(exceptions.AWSNotificationException):
|
73
|
+
sns.SNSNotification(ctx, imagename).publish()
|
74
|
+
|
75
|
+
|
76
|
+
@pytest.mark.parametrize(
|
77
|
+
"imagename",
|
78
|
+
[
|
79
|
+
("test-image-10"),
|
80
|
+
("test-image-11"),
|
81
|
+
("test-image-12"),
|
82
|
+
],
|
83
|
+
)
|
84
|
+
def test_sns_publish_fail_with_unauthorized_user(imagename):
|
85
|
+
"""
|
86
|
+
Test the send_notification logic
|
87
|
+
"""
|
88
|
+
with patch("boto3.client") as bclient_mock:
|
89
|
+
instance = bclient_mock.return_value
|
90
|
+
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
|
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"}]}
|
95
|
+
|
96
|
+
error_reponse = {
|
97
|
+
"Error": {
|
98
|
+
"Code": "AuthorizationError",
|
99
|
+
"Message": "User are not authorized perform SNS Notification service",
|
100
|
+
}
|
101
|
+
}
|
102
|
+
instance.publish.side_effect = botocore.exceptions.ClientError(error_reponse, "")
|
103
|
+
|
104
|
+
with pytest.raises(exceptions.AWSAuthorizationException):
|
105
|
+
sns.SNSNotification(ctx, imagename).publish()
|
106
|
+
|
107
|
+
|
108
|
+
@pytest.mark.parametrize(
|
109
|
+
"imagename, partition, regions_in_partition, expected",
|
110
|
+
[
|
111
|
+
(
|
112
|
+
"test-image-10",
|
113
|
+
"aws-cn",
|
114
|
+
["cn-north1", "cn-northwest-1"],
|
115
|
+
[],
|
116
|
+
),
|
117
|
+
(
|
118
|
+
"test-image-11",
|
119
|
+
"aws",
|
120
|
+
["us-east-1", "eu-central-1"],
|
121
|
+
[
|
122
|
+
"arn:aws:sns:us-east-1:1234:topic1",
|
123
|
+
"arn:aws:sns:eu-central-1:1234:topic2",
|
124
|
+
],
|
125
|
+
),
|
126
|
+
(
|
127
|
+
"test-image-12",
|
128
|
+
"aws",
|
129
|
+
["us-east-1", "eu-central-1"],
|
130
|
+
[
|
131
|
+
"arn:aws:sns:us-east-1:1234:topic1",
|
132
|
+
"arn:aws:sns:eu-central-1:1234:topic1",
|
133
|
+
],
|
134
|
+
),
|
135
|
+
],
|
136
|
+
)
|
137
|
+
def test_sns__get_topic_arn(imagename, partition, regions_in_partition, expected):
|
138
|
+
"""
|
139
|
+
Test the send_notification logic
|
140
|
+
"""
|
141
|
+
with patch("boto3.client") as bclient_mock:
|
142
|
+
instance = bclient_mock.return_value
|
143
|
+
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
|
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"}]}
|
147
|
+
|
148
|
+
instance.get_caller_identity.return_value = {"Account": "1234", "Arn": f"arn:{partition}:iam::1234:user/test"}
|
149
|
+
|
150
|
+
topic_arns = []
|
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)
|
157
|
+
|
158
|
+
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
|
@@ -4,7 +4,7 @@ build-backend = "poetry_dynamic_versioning.backend"
|
|
4
4
|
|
5
5
|
[tool.poetry]
|
6
6
|
name = "awspub"
|
7
|
-
version = "0.0.
|
7
|
+
version = "0.0.9"
|
8
8
|
description = "Publish images to AWS EC2"
|
9
9
|
|
10
10
|
license = "GPL-3.0-or-later"
|
@@ -20,8 +20,7 @@ keywords = ["AWS", "EC2", "publication"]
|
|
20
20
|
|
21
21
|
|
22
22
|
[tool.poetry.dependencies]
|
23
|
-
|
24
|
-
python = "^3.8.1"
|
23
|
+
python = "^3.10"
|
25
24
|
boto3 = "*"
|
26
25
|
pydantic = "^2"
|
27
26
|
boto3-stubs = {extras = ["essential", "marketplace-catalog", "ssm", "s3", "sns", "sts"], version = "^1.28.83"}
|
awspub-0.0.8/awspub/common.py
DELETED
@@ -1,18 +0,0 @@
|
|
1
|
-
from typing import Tuple
|
2
|
-
|
3
|
-
|
4
|
-
def _split_partition(val: str) -> Tuple[str, str]:
|
5
|
-
"""
|
6
|
-
Split a string into partition and resource, separated by a colon. If no partition is given, assume "aws"
|
7
|
-
:param val: the string to split
|
8
|
-
:type val: str
|
9
|
-
:return: the partition and the resource
|
10
|
-
:rtype: Tuple[str, str]
|
11
|
-
"""
|
12
|
-
if ":" in val:
|
13
|
-
partition, resource = val.split(":")
|
14
|
-
else:
|
15
|
-
# if no partition is given, assume default commercial partition "aws"
|
16
|
-
partition = "aws"
|
17
|
-
resource = val
|
18
|
-
return partition, resource
|
awspub-0.0.8/awspub/sns.py
DELETED
@@ -1,88 +0,0 @@
|
|
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
|
-
)
|
@@ -1,16 +0,0 @@
|
|
1
|
-
import pytest
|
2
|
-
|
3
|
-
from awspub.common import _split_partition
|
4
|
-
|
5
|
-
|
6
|
-
@pytest.mark.parametrize(
|
7
|
-
"input,expected_output",
|
8
|
-
[
|
9
|
-
("123456789123", ("aws", "123456789123")),
|
10
|
-
("aws:123456789123", ("aws", "123456789123")),
|
11
|
-
("aws-cn:123456789123", ("aws-cn", "123456789123")),
|
12
|
-
("aws-us-gov:123456789123", ("aws-us-gov", "123456789123")),
|
13
|
-
],
|
14
|
-
)
|
15
|
-
def test_common__split_partition(input, expected_output):
|
16
|
-
assert _split_partition(input) == expected_output
|
@@ -1,140 +0,0 @@
|
|
1
|
-
import pathlib
|
2
|
-
from unittest.mock import patch
|
3
|
-
|
4
|
-
import botocore.exceptions
|
5
|
-
import pytest
|
6
|
-
|
7
|
-
from awspub import context, exceptions, sns
|
8
|
-
|
9
|
-
curdir = pathlib.Path(__file__).parent.resolve()
|
10
|
-
|
11
|
-
|
12
|
-
@pytest.mark.parametrize(
|
13
|
-
"imagename,called_sns_publish, publish_call_count",
|
14
|
-
[
|
15
|
-
("test-image-10", True, 1),
|
16
|
-
("test-image-11", True, 4),
|
17
|
-
],
|
18
|
-
)
|
19
|
-
def test_sns_publish(imagename, called_sns_publish, publish_call_count):
|
20
|
-
"""
|
21
|
-
Test the send_notification logic
|
22
|
-
"""
|
23
|
-
with patch("boto3.client") as bclient_mock:
|
24
|
-
instance = bclient_mock.return_value
|
25
|
-
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()
|
30
|
-
|
31
|
-
assert instance.publish.called == called_sns_publish
|
32
|
-
assert instance.publish.call_count == publish_call_count
|
33
|
-
|
34
|
-
|
35
|
-
@pytest.mark.parametrize(
|
36
|
-
"imagename",
|
37
|
-
[
|
38
|
-
("test-image-10"),
|
39
|
-
("test-image-11"),
|
40
|
-
],
|
41
|
-
)
|
42
|
-
def test_sns_publish_fail_with_invalid_topic(imagename):
|
43
|
-
"""
|
44
|
-
Test the send_notification logic
|
45
|
-
"""
|
46
|
-
with patch("boto3.client") as bclient_mock:
|
47
|
-
instance = bclient_mock.return_value
|
48
|
-
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
|
49
|
-
image_conf = ctx.conf["images"][imagename]
|
50
|
-
|
51
|
-
# topic1 is invalid topic
|
52
|
-
def side_effect(*args, **kwargs):
|
53
|
-
topic_arn = kwargs.get("TopicArn")
|
54
|
-
if "topic1" in topic_arn:
|
55
|
-
error_reponse = {
|
56
|
-
"Error": {
|
57
|
-
"Code": "NotFoundException",
|
58
|
-
"Message": "An error occurred (NotFound) when calling the Publish operation: "
|
59
|
-
"Topic does not exist.",
|
60
|
-
}
|
61
|
-
}
|
62
|
-
raise botocore.exceptions.ClientError(error_reponse, "")
|
63
|
-
|
64
|
-
instance.publish.side_effect = side_effect
|
65
|
-
|
66
|
-
for region in image_conf["regions"]:
|
67
|
-
with pytest.raises(exceptions.AWSNotificationException):
|
68
|
-
sns.SNSNotification(ctx, imagename, region).publish()
|
69
|
-
|
70
|
-
|
71
|
-
@pytest.mark.parametrize(
|
72
|
-
"imagename",
|
73
|
-
[
|
74
|
-
("test-image-10"),
|
75
|
-
("test-image-11"),
|
76
|
-
],
|
77
|
-
)
|
78
|
-
def test_sns_publish_fail_with_unauthorized_user(imagename):
|
79
|
-
"""
|
80
|
-
Test the send_notification logic
|
81
|
-
"""
|
82
|
-
with patch("boto3.client") as bclient_mock:
|
83
|
-
instance = bclient_mock.return_value
|
84
|
-
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
|
85
|
-
image_conf = ctx.conf["images"][imagename]
|
86
|
-
|
87
|
-
error_reponse = {
|
88
|
-
"Error": {
|
89
|
-
"Code": "AuthorizationError",
|
90
|
-
"Message": "User are not authorized perform SNS Notification service",
|
91
|
-
}
|
92
|
-
}
|
93
|
-
instance.publish.side_effect = botocore.exceptions.ClientError(error_reponse, "")
|
94
|
-
|
95
|
-
for region in image_conf["regions"]:
|
96
|
-
with pytest.raises(exceptions.AWSAuthorizationException):
|
97
|
-
sns.SNSNotification(ctx, imagename, region).publish()
|
98
|
-
|
99
|
-
|
100
|
-
@pytest.mark.parametrize(
|
101
|
-
"imagename, partition, expected",
|
102
|
-
[
|
103
|
-
(
|
104
|
-
"test-image-10",
|
105
|
-
"aws-cn",
|
106
|
-
[
|
107
|
-
"arn:aws-cn:sns:us-east-1:1234:topic1",
|
108
|
-
],
|
109
|
-
),
|
110
|
-
(
|
111
|
-
"test-image-11",
|
112
|
-
"aws",
|
113
|
-
[
|
114
|
-
"arn:aws:sns:us-east-1:1234:topic1",
|
115
|
-
"arn:aws:sns:us-east-1:1234:topic2",
|
116
|
-
"arn:aws:sns:eu-central-1:1234:topic1",
|
117
|
-
"arn:aws:sns:eu-central-1:1234:topic2",
|
118
|
-
],
|
119
|
-
),
|
120
|
-
],
|
121
|
-
)
|
122
|
-
def test_sns__get_topic_arn(imagename, partition, expected):
|
123
|
-
"""
|
124
|
-
Test the send_notification logic
|
125
|
-
"""
|
126
|
-
with patch("boto3.client") as bclient_mock:
|
127
|
-
instance = bclient_mock.return_value
|
128
|
-
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
|
129
|
-
image_conf = ctx.conf["images"][imagename]
|
130
|
-
|
131
|
-
instance.get_caller_identity.return_value = {"Account": "1234", "Arn": f"arn:{partition}:iam::1234:user/test"}
|
132
|
-
|
133
|
-
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)
|
139
|
-
|
140
|
-
assert topic_arns == expected
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|