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.
Files changed (38) hide show
  1. {awspub-0.0.8 → awspub-0.0.9}/PKG-INFO +2 -3
  2. awspub-0.0.9/awspub/common.py +64 -0
  3. {awspub-0.0.8 → awspub-0.0.9}/awspub/configmodels.py +6 -0
  4. {awspub-0.0.8 → awspub-0.0.9}/awspub/image.py +5 -28
  5. awspub-0.0.9/awspub/sns.py +105 -0
  6. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config1.yaml +19 -0
  7. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_api.py +1 -0
  8. awspub-0.0.9/awspub/tests/test_common.py +34 -0
  9. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_image.py +1 -1
  10. awspub-0.0.9/awspub/tests/test_sns.py +189 -0
  11. {awspub-0.0.8 → awspub-0.0.9}/pyproject.toml +2 -3
  12. awspub-0.0.8/awspub/common.py +0 -18
  13. awspub-0.0.8/awspub/sns.py +0 -88
  14. awspub-0.0.8/awspub/tests/test_common.py +0 -16
  15. awspub-0.0.8/awspub/tests/test_sns.py +0 -140
  16. {awspub-0.0.8 → awspub-0.0.9}/LICENSE +0 -0
  17. {awspub-0.0.8 → awspub-0.0.9}/awspub/__init__.py +0 -0
  18. {awspub-0.0.8 → awspub-0.0.9}/awspub/api.py +0 -0
  19. {awspub-0.0.8 → awspub-0.0.9}/awspub/cli/__init__.py +0 -0
  20. {awspub-0.0.8 → awspub-0.0.9}/awspub/context.py +0 -0
  21. {awspub-0.0.8 → awspub-0.0.9}/awspub/exceptions.py +0 -0
  22. {awspub-0.0.8 → awspub-0.0.9}/awspub/image_marketplace.py +0 -0
  23. {awspub-0.0.8 → awspub-0.0.9}/awspub/s3.py +0 -0
  24. {awspub-0.0.8 → awspub-0.0.9}/awspub/snapshot.py +0 -0
  25. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/__init__.py +0 -0
  26. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config-invalid-s3-extra.yaml +0 -0
  27. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config-minimal.yaml +0 -0
  28. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config-valid-nonawspub.yaml +0 -0
  29. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config1.vmdk +0 -0
  30. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config2-mapping.yaml +0 -0
  31. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config2.yaml +0 -0
  32. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/fixtures/config3-duplicate-keys.yaml +0 -0
  33. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_cli.py +0 -0
  34. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_context.py +0 -0
  35. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_image_marketplace.py +0 -0
  36. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_s3.py +0 -0
  37. {awspub-0.0.8 → awspub-0.0.9}/awspub/tests/test_snapshot.py +0 -0
  38. {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.8
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.8.1,<4.0.0
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. Either configured in the image configuration
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
- # get all available regions
132
- ec2client: EC2Client = boto3.client("ec2", region_name=self._s3.bucket_region)
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
- 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()
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"
@@ -25,6 +25,7 @@ curdir = pathlib.Path(__file__).parent.resolve()
25
25
  "test-image-9",
26
26
  "test-image-10",
27
27
  "test-image-11",
28
+ "test-image-12",
28
29
  ],
29
30
  ),
30
31
  # with a group that no image as, no image should be processed
@@ -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.8"
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
- # use 3.8.1 instead of 3.8 to get a new flake8 version which fixes problems with false positive on python 3.12
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"}
@@ -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
@@ -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