awspub 0.0.10__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
awspub/sns.py ADDED
@@ -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
+ )
File without changes
@@ -0,0 +1,12 @@
1
+ awspub:
2
+ s3:
3
+ bucket_name: "bucket1"
4
+ invalid_field: "not allowed" # This is an invalid field
5
+ source:
6
+ path: "config1.vmdk"
7
+ architecture: "x86_64"
8
+ images:
9
+ test-image:
10
+ description: "Test Image"
11
+ separate_snapshot: "False"
12
+ boot_mode: "uefi-preferred"
@@ -0,0 +1,12 @@
1
+ ---
2
+ awspub:
3
+ source:
4
+ path: "config1.vmdk"
5
+ architecture: "x86_64"
6
+
7
+ s3:
8
+ bucket_name: "bucket1"
9
+
10
+ images:
11
+ "my-custom-image":
12
+ boot_mode: "uefi-preferred"
@@ -0,0 +1,13 @@
1
+ awspub:
2
+ s3:
3
+ bucket_name: "bucket1"
4
+ source:
5
+ path: "config1.vmdk"
6
+ architecture: "x86_64"
7
+ images:
8
+ test-image:
9
+ description: "Test Image"
10
+ separate_snapshot: "False"
11
+ boot_mode: "uefi-preferred"
12
+ notawspub: # to make sure config outside of toplevel `awspub` dict is allowed
13
+ foo_bar: "irrelevant"
Binary file
@@ -0,0 +1,171 @@
1
+ awspub:
2
+ s3:
3
+ bucket_name: "bucket1"
4
+
5
+ source:
6
+ # config1.vmdk generated with
7
+ # dd if=/dev/zero of=config1.raw bs=1K count=1
8
+ # qemu-img convert -f raw -O vmdk -o subformat=streamOptimized config1.raw config1.vmdk
9
+ path: "config1.vmdk"
10
+ architecture: "x86_64"
11
+
12
+ images:
13
+ "test-image-1":
14
+ description: |
15
+ A test image
16
+ boot_mode: "uefi"
17
+ regions:
18
+ - region1
19
+ - region2
20
+ temporary: true
21
+ groups:
22
+ - group1
23
+ - group2
24
+ "test-image-2":
25
+ description: |
26
+ A test image with a separate snapshot
27
+ boot_mode: "uefi"
28
+ separate_snapshot: true
29
+ groups:
30
+ - group1
31
+ "test-image-3":
32
+ description: |
33
+ A test image with a separate snapshot and a billing code
34
+ boot_mode: "uefi"
35
+ separate_snapshot: true
36
+ billing_products:
37
+ - billingcode
38
+ "test-image-4":
39
+ description: |
40
+ A test image without a separate snapshot but a billing product
41
+ boot_mode: "uefi-preferred"
42
+ billing_products:
43
+ - billingcode
44
+ "test-image-5":
45
+ description: |
46
+ A test image without a separate snapshot but multiple billing products
47
+ boot_mode: "uefi-preferred"
48
+ billing_products:
49
+ - billingcode1
50
+ - billingcode2
51
+ "test-image-6":
52
+ description: |
53
+ A test image without a separate snapshot but multiple billing products
54
+ boot_mode: "uefi-preferred"
55
+ regions:
56
+ - "eu-central-1"
57
+ public: true
58
+ tags:
59
+ key1: value1
60
+ "test-image-7":
61
+ description: |
62
+ A test image without a separate snapshot but multiple billing products
63
+ boot_mode: "uefi-preferred"
64
+ regions:
65
+ - "eu-central-1"
66
+ public: true
67
+ temporary: true
68
+ tags:
69
+ key2: name
70
+ name: "not-foobar"
71
+ "test-image-8":
72
+ description: |
73
+ A test image without a separate snapshot but multiple billing products
74
+ boot_mode: "uefi-preferred"
75
+ regions:
76
+ - "eu-central-1"
77
+ - "us-east-1"
78
+ public: true
79
+ tags:
80
+ key1: value1
81
+ share:
82
+ - "123456789123"
83
+ - "221020170000"
84
+ - "aws:290620200000"
85
+ - "aws-cn:334455667788"
86
+ marketplace:
87
+ entity_id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
88
+ access_role_arn: "arn:aws:iam::xxxxxxxxxxxx:role/AWSMarketplaceAccess"
89
+ version_title: "1.0.0"
90
+ release_notes: "N/A"
91
+ user_name: "ubuntu"
92
+ scanning_port: 22
93
+ os_name: "UBUNTU"
94
+ os_version: "22.04"
95
+ usage_instructions: |
96
+ You can use me
97
+ recommended_instance_type: "m5.large"
98
+ security_groups:
99
+ -
100
+ from_port: 22
101
+ ip_protocol: "tcp"
102
+ ip_ranges:
103
+ - "0.0.0.0/0"
104
+ to_port: 22
105
+ ssm_parameter:
106
+ -
107
+ name: /test/image
108
+ -
109
+ name: /test/another-image
110
+ "test-image-9":
111
+ boot_mode: "uefi"
112
+ description: |
113
+ A test image without a separate snapshot but multiple billing products
114
+ regions:
115
+ - "eu-central-1"
116
+ - "us-east-1"
117
+ ssm_parameter:
118
+ -
119
+ name: /awspub-test/param2
120
+ 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
+ regions:
134
+ - "us-east-1"
135
+ "test-image-11":
136
+ boot_mode: "uefi"
137
+ description: |
138
+ A test image without a separate snapshot but multiple sns configs
139
+ regions:
140
+ - "us-east-1"
141
+ - "eu-central-1"
142
+ sns:
143
+ - "topic1":
144
+ subject: "topic1-subject"
145
+ message:
146
+ default: "default-message"
147
+ email: "email-message"
148
+ regions:
149
+ - "us-east-1"
150
+ - "topic2":
151
+ subject: "topic2-subject"
152
+ message:
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"
169
+
170
+ tags:
171
+ name: "foobar"
@@ -0,0 +1,2 @@
1
+ key1: "value1"
2
+ key2: "value$2"
@@ -0,0 +1,48 @@
1
+ awspub:
2
+ s3:
3
+ bucket_name: "bucket1"
4
+
5
+ source:
6
+ # config1.vmdk generated with
7
+ # dd if=/dev/zero of=config1.raw bs=1K count=1
8
+ # qemu-img convert -f raw -O vmdk -o subformat=streamOptimized config1.raw config1.vmdk
9
+ path: "config1.vmdk"
10
+ architecture: "x86_64"
11
+
12
+ images:
13
+ "test-image-$key1":
14
+ description: |
15
+ A test image
16
+ boot_mode: "uefi"
17
+ regions:
18
+ - region1
19
+ - region2
20
+ temporary: true
21
+ "test-image-$key2":
22
+ description: |
23
+ A test image with a separate snapshot
24
+ boot_mode: "uefi"
25
+ separate_snapshot: true
26
+ "test-image-3":
27
+ description: |
28
+ A test image with a separate snapshot and a billing code
29
+ boot_mode: "uefi"
30
+ separate_snapshot: true
31
+ billing_products:
32
+ - billingcode
33
+ "test-image-4":
34
+ description: |
35
+ A test image without a separate snapshot but a billing product
36
+ boot_mode: "uefi-preferred"
37
+ billing_products:
38
+ - billingcode
39
+ "test-image-5":
40
+ description: |
41
+ A test image without a separate snapshot but multiple billing products
42
+ boot_mode: "uefi-preferred"
43
+ billing_products:
44
+ - billingcode1
45
+ - billingcode2
46
+
47
+ tags:
48
+ name: "foobar"
@@ -0,0 +1,18 @@
1
+ awspub:
2
+ s3:
3
+ bucket_name: "bucket1"
4
+
5
+ source:
6
+ path: "config1.vmdk"
7
+ architecture: "x86_64"
8
+
9
+ images:
10
+ "test-image-1":
11
+ description: |
12
+ A test image
13
+ boot_mode: "uefi"
14
+ # second image with the same key
15
+ "test-image-1":
16
+ description: |
17
+ A test image
18
+ boot_mode: "uefi"
@@ -0,0 +1,89 @@
1
+ import pathlib
2
+
3
+ import pytest
4
+
5
+ from awspub import api, context, image
6
+
7
+ curdir = pathlib.Path(__file__).parent.resolve()
8
+
9
+
10
+ @pytest.mark.parametrize(
11
+ "group,expected_image_names",
12
+ [
13
+ # without any group, all images should be processed
14
+ (
15
+ None,
16
+ [
17
+ "test-image-1",
18
+ "test-image-2",
19
+ "test-image-3",
20
+ "test-image-4",
21
+ "test-image-5",
22
+ "test-image-6",
23
+ "test-image-7",
24
+ "test-image-8",
25
+ "test-image-9",
26
+ "test-image-10",
27
+ "test-image-11",
28
+ "test-image-12",
29
+ ],
30
+ ),
31
+ # with a group that no image as, no image should be processed
32
+ (
33
+ "group-not-used",
34
+ [],
35
+ ),
36
+ # with a group that an image has
37
+ (
38
+ "group2",
39
+ ["test-image-1"],
40
+ ),
41
+ # with a group that multiple images have
42
+ (
43
+ "group1",
44
+ ["test-image-1", "test-image-2"],
45
+ ),
46
+ ],
47
+ )
48
+ def test_api__images_filtered(group, expected_image_names):
49
+ """
50
+ Test the _images_filtered() function
51
+ """
52
+ ctx = context.Context(curdir / "fixtures/config1.yaml", None)
53
+
54
+ image_names = [i[0] for i in api._images_filtered(ctx, group)]
55
+ assert image_names == expected_image_names
56
+
57
+
58
+ @pytest.mark.parametrize(
59
+ "group,expected",
60
+ [
61
+ # without any group, all images should be processed
62
+ (
63
+ None,
64
+ (
65
+ {"test-image-1": {"eu-central-1": "ami-123", "eu-central-2": "ami-456"}},
66
+ {
67
+ "group1": {"test-image-1": {"eu-central-1": "ami-123", "eu-central-2": "ami-456"}},
68
+ "group2": {"test-image-1": {"eu-central-1": "ami-123", "eu-central-2": "ami-456"}},
69
+ },
70
+ ),
71
+ ),
72
+ # with a group that no image as, image should be there but nothing in the group
73
+ ("group-not-used", ({"test-image-1": {"eu-central-1": "ami-123", "eu-central-2": "ami-456"}}, {})),
74
+ ],
75
+ )
76
+ def test_api__images_grouped(group, expected):
77
+ """
78
+ Test the _images_grouped() function
79
+ """
80
+ ctx = context.Context(curdir / "fixtures/config1.yaml", None)
81
+ images = [
82
+ (
83
+ "test-image-1",
84
+ image.Image(ctx, "test-image-1"),
85
+ {"eu-central-1": image._ImageInfo("ami-123", None), "eu-central-2": image._ImageInfo("ami-456", None)},
86
+ )
87
+ ]
88
+ grouped = api._images_grouped(images, group)
89
+ assert grouped == expected
File without changes
@@ -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
@@ -0,0 +1,88 @@
1
+ import glob
2
+ import os
3
+ import pathlib
4
+
5
+ import pytest
6
+ from pydantic import ValidationError
7
+ from ruamel.yaml.constructor import DuplicateKeyError
8
+
9
+ from awspub import context
10
+
11
+ curdir = pathlib.Path(__file__).parent.resolve()
12
+
13
+
14
+ def test_context_create():
15
+ """
16
+ Create a Context object from a given configuration
17
+ """
18
+ ctx = context.Context(curdir / "fixtures/config1.yaml", None)
19
+ assert ctx.conf["source"]["path"] == curdir / "fixtures/config1.vmdk"
20
+ assert ctx.source_sha256 == "6252475408b9f9ee64452b611d706a078831a99b123db69d144d878a0488a0a8"
21
+ assert ctx.conf["source"]["architecture"] == "x86_64"
22
+ assert ctx.conf["s3"]["bucket_name"] == "bucket1"
23
+
24
+
25
+ def test_context_create_minimal():
26
+ """
27
+ Create a Context object from a given minimal configuration
28
+ """
29
+ ctx = context.Context(curdir / "fixtures/config-minimal.yaml", None)
30
+ assert ctx.conf["source"]["path"] == curdir / "fixtures/config1.vmdk"
31
+ assert ctx.source_sha256 == "6252475408b9f9ee64452b611d706a078831a99b123db69d144d878a0488a0a8"
32
+ assert ctx.conf["source"]["architecture"] == "x86_64"
33
+ assert ctx.conf["s3"]["bucket_name"] == "bucket1"
34
+
35
+
36
+ def test_context_create_with_mapping():
37
+ """
38
+ Create a Context object from a given configuration
39
+ """
40
+ ctx = context.Context(curdir / "fixtures/config2.yaml", curdir / "fixtures/config2-mapping.yaml")
41
+ assert ctx.conf["source"]["path"] == curdir / "fixtures/config1.vmdk"
42
+ assert ctx.source_sha256 == "6252475408b9f9ee64452b611d706a078831a99b123db69d144d878a0488a0a8"
43
+ assert ctx.conf["images"].get("test-image-value1")
44
+ assert ctx.conf["images"].get("test-image-value$2")
45
+
46
+
47
+ def test_context_with_docs_config_samples():
48
+ """
49
+ Create a Context object with the sample config files used for documentation
50
+ """
51
+ config_samples_dir = curdir.parents[1] / "docs" / "config-samples"
52
+ for f in glob.glob(f"{config_samples_dir}/*.yaml"):
53
+ mapping_file = f + ".mapping"
54
+ if os.path.exists(mapping_file):
55
+ mapping = mapping_file
56
+ else:
57
+ mapping = None
58
+ context.Context(os.path.join(config_samples_dir, f), mapping)
59
+
60
+
61
+ def test_context_with_duplicate_image_name():
62
+ """
63
+ Create a context with a configuration file that contains a duplicate image name key
64
+ """
65
+ with pytest.raises(DuplicateKeyError):
66
+ context.Context(curdir / "fixtures/config3-duplicate-keys.yaml", None)
67
+
68
+
69
+ @pytest.mark.parametrize(
70
+ "config_file",
71
+ ["fixtures/config-minimal.yaml", "fixtures/config-valid-nonawspub.yaml"],
72
+ )
73
+ def test_valid_configuration(config_file):
74
+ """
75
+ Test with a valid configuration file (no extra fields)
76
+ """
77
+ ctx = context.Context(curdir / config_file, None)
78
+ assert ctx.conf is not None
79
+ assert ctx.conf["s3"]["bucket_name"] == "bucket1"
80
+ assert ctx.conf["source"]["architecture"] == "x86_64"
81
+
82
+
83
+ def test_invalid_configuration_extra_field():
84
+ """
85
+ Test with an invalid configuration file that includes an extra field
86
+ """
87
+ with pytest.raises(ValidationError):
88
+ context.Context(curdir / "fixtures/config-invalid-s3-extra.yaml", None)