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/__init__.py +3 -0
- awspub/api.py +165 -0
- awspub/cli/__init__.py +146 -0
- awspub/common.py +64 -0
- awspub/configmodels.py +216 -0
- awspub/context.py +108 -0
- awspub/exceptions.py +28 -0
- awspub/image.py +656 -0
- awspub/image_marketplace.py +120 -0
- awspub/s3.py +262 -0
- awspub/snapshot.py +241 -0
- awspub/sns.py +105 -0
- awspub/tests/__init__.py +0 -0
- awspub/tests/fixtures/config-invalid-s3-extra.yaml +12 -0
- awspub/tests/fixtures/config-minimal.yaml +12 -0
- awspub/tests/fixtures/config-valid-nonawspub.yaml +13 -0
- awspub/tests/fixtures/config1.vmdk +0 -0
- awspub/tests/fixtures/config1.yaml +171 -0
- awspub/tests/fixtures/config2-mapping.yaml +2 -0
- awspub/tests/fixtures/config2.yaml +48 -0
- awspub/tests/fixtures/config3-duplicate-keys.yaml +18 -0
- awspub/tests/test_api.py +89 -0
- awspub/tests/test_cli.py +0 -0
- awspub/tests/test_common.py +34 -0
- awspub/tests/test_context.py +88 -0
- awspub/tests/test_image.py +556 -0
- awspub/tests/test_image_marketplace.py +44 -0
- awspub/tests/test_s3.py +74 -0
- awspub/tests/test_snapshot.py +122 -0
- awspub/tests/test_sns.py +189 -0
- awspub-0.0.10.dist-info/LICENSE +675 -0
- awspub-0.0.10.dist-info/METADATA +46 -0
- awspub-0.0.10.dist-info/RECORD +35 -0
- awspub-0.0.10.dist-info/WHEEL +4 -0
- awspub-0.0.10.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,120 @@
|
|
1
|
+
import logging
|
2
|
+
import re
|
3
|
+
from typing import Any, Dict
|
4
|
+
|
5
|
+
import boto3
|
6
|
+
from mypy_boto3_marketplace_catalog import MarketplaceCatalogClient
|
7
|
+
|
8
|
+
from awspub.context import Context
|
9
|
+
|
10
|
+
logger = logging.getLogger(__name__)
|
11
|
+
|
12
|
+
|
13
|
+
class ImageMarketplace:
|
14
|
+
"""
|
15
|
+
Handle AWS Marketplace API interaction
|
16
|
+
"""
|
17
|
+
|
18
|
+
def __init__(self, context: Context, image_name: str):
|
19
|
+
self._ctx: Context = context
|
20
|
+
self._image_name: str = image_name
|
21
|
+
# marketplace-catalog API is only available via us-east-1
|
22
|
+
self._mpclient: MarketplaceCatalogClient = boto3.client("marketplace-catalog", region_name="us-east-1")
|
23
|
+
|
24
|
+
@property
|
25
|
+
def conf(self) -> Dict[str, Any]:
|
26
|
+
"""
|
27
|
+
The marketplace configuration for the current image (based on "image_name") from context
|
28
|
+
"""
|
29
|
+
return self._ctx.conf["images"][self._image_name]["marketplace"]
|
30
|
+
|
31
|
+
def request_new_version(self, image_id: str) -> None:
|
32
|
+
"""
|
33
|
+
Request a new Marketplace version for the given image Id
|
34
|
+
|
35
|
+
:param image_id: an image Id (in the format 'ami-123')
|
36
|
+
:type image_id: str
|
37
|
+
"""
|
38
|
+
entity = self._mpclient.describe_entity(Catalog="AWSMarketplace", EntityId=self.conf["entity_id"])
|
39
|
+
# check if the version already exists
|
40
|
+
for version in entity["DetailsDocument"]["Versions"]:
|
41
|
+
if version["VersionTitle"] == self.conf["version_title"]:
|
42
|
+
logger.info(f"Marketplace version '{self.conf['version_title']}' already exists. Do nothing")
|
43
|
+
return
|
44
|
+
|
45
|
+
# version doesn't exist already - create a new one
|
46
|
+
changeset = self._request_new_version_changeset(image_id)
|
47
|
+
changeset_name = ImageMarketplace.sanitize_changeset_name(
|
48
|
+
f"New version request for {self.conf['version_title']}"
|
49
|
+
)
|
50
|
+
resp = self._mpclient.start_change_set(
|
51
|
+
Catalog="AWSMarketplace", ChangeSet=changeset, ChangeSetTags=self._ctx.tags, ChangeSetName=changeset_name
|
52
|
+
)
|
53
|
+
logger.info(
|
54
|
+
f"new version '{self.conf['version_title']}' (image: {image_id}) for entity "
|
55
|
+
f"{self.conf['entity_id']} requested (changeset-id: {resp['ChangeSetId']})"
|
56
|
+
)
|
57
|
+
|
58
|
+
def _request_new_version_changeset(self, image_id: str):
|
59
|
+
"""
|
60
|
+
Create a changeset structure for a new AmiProduct version
|
61
|
+
See https://docs.aws.amazon.com/marketplace-catalog/latest/api-reference/ami-products.html#ami-add-version
|
62
|
+
|
63
|
+
:param image_id: an image Id (in the format 'ami-123')
|
64
|
+
:type image_id: str
|
65
|
+
:return: A changeset structure to request a new version
|
66
|
+
:rtype: List[Dict[str, Any]]
|
67
|
+
"""
|
68
|
+
return [
|
69
|
+
{
|
70
|
+
"ChangeType": "AddDeliveryOptions",
|
71
|
+
"Entity": {
|
72
|
+
"Identifier": self.conf["entity_id"],
|
73
|
+
"Type": "AmiProduct@1.0",
|
74
|
+
},
|
75
|
+
"DetailsDocument": {
|
76
|
+
"Version": {
|
77
|
+
"VersionTitle": self.conf["version_title"],
|
78
|
+
"ReleaseNotes": self.conf["release_notes"],
|
79
|
+
},
|
80
|
+
"DeliveryOptions": [
|
81
|
+
{
|
82
|
+
"Details": {
|
83
|
+
"AmiDeliveryOptionDetails": {
|
84
|
+
"AmiSource": {
|
85
|
+
"AmiId": image_id,
|
86
|
+
"AccessRoleArn": self.conf["access_role_arn"],
|
87
|
+
"UserName": self.conf["user_name"],
|
88
|
+
"OperatingSystemName": self.conf["os_name"],
|
89
|
+
"OperatingSystemVersion": self.conf["os_version"],
|
90
|
+
},
|
91
|
+
"UsageInstructions": self.conf["usage_instructions"],
|
92
|
+
"RecommendedInstanceType": self.conf["recommended_instance_type"],
|
93
|
+
"SecurityGroups": [
|
94
|
+
{
|
95
|
+
"IpProtocol": sg["ip_protocol"],
|
96
|
+
"IpRanges": [ipr for ipr in sg["ip_ranges"]],
|
97
|
+
"FromPort": sg["from_port"],
|
98
|
+
"ToPort": sg["to_port"],
|
99
|
+
}
|
100
|
+
for sg in self.conf["security_groups"]
|
101
|
+
],
|
102
|
+
}
|
103
|
+
}
|
104
|
+
}
|
105
|
+
],
|
106
|
+
},
|
107
|
+
}
|
108
|
+
]
|
109
|
+
|
110
|
+
@staticmethod
|
111
|
+
def sanitize_changeset_name(name: str) -> str:
|
112
|
+
# changeset names can only include alphanumeric characters, whitespace, and any combination of the following
|
113
|
+
# characters: _+=.:@- This regex pattern takes the list of allowed characters, does a negative match on the
|
114
|
+
# string and removes all matched (i.e. disallowed) characters. See [0] for reference.
|
115
|
+
# [0] https://docs.aws.amazon.com/marketplace-catalog/latest/api-reference/API_StartChangeSet.html#API_StartChangeSet_RequestSyntax # noqa
|
116
|
+
return re.sub(
|
117
|
+
"[^\\w\\s+=.:@-]",
|
118
|
+
"",
|
119
|
+
name,
|
120
|
+
)
|
awspub/s3.py
ADDED
@@ -0,0 +1,262 @@
|
|
1
|
+
import base64
|
2
|
+
import hashlib
|
3
|
+
import logging
|
4
|
+
import os
|
5
|
+
from typing import Dict
|
6
|
+
|
7
|
+
import boto3
|
8
|
+
from mypy_boto3_s3.type_defs import CompletedPartTypeDef
|
9
|
+
|
10
|
+
from awspub.context import Context
|
11
|
+
from awspub.exceptions import BucketDoesNotExistException
|
12
|
+
|
13
|
+
# chunk size is required for calculating the checksums
|
14
|
+
MULTIPART_CHUNK_SIZE = 8 * 1024 * 1024
|
15
|
+
|
16
|
+
|
17
|
+
logger = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
|
20
|
+
class S3:
|
21
|
+
"""
|
22
|
+
Handle S3 API interaction
|
23
|
+
"""
|
24
|
+
|
25
|
+
def __init__(self, context: Context):
|
26
|
+
"""
|
27
|
+
:param context:
|
28
|
+
"type context: awspub.context.Context
|
29
|
+
"""
|
30
|
+
self._ctx: Context = context
|
31
|
+
self._s3client = boto3.client("s3")
|
32
|
+
self._bucket_region = None
|
33
|
+
|
34
|
+
@property
|
35
|
+
def bucket_region(self):
|
36
|
+
if not self._bucket_region:
|
37
|
+
if not self._bucket_exists():
|
38
|
+
raise BucketDoesNotExistException(self.bucket_name)
|
39
|
+
self._bucket_region = self._s3client.head_bucket(Bucket=self.bucket_name)["BucketRegion"]
|
40
|
+
|
41
|
+
return self._bucket_region
|
42
|
+
|
43
|
+
@property
|
44
|
+
def bucket_name(self):
|
45
|
+
return self._ctx.conf["s3"]["bucket_name"]
|
46
|
+
|
47
|
+
def __repr__(self):
|
48
|
+
return (
|
49
|
+
f"<{self.__class__} bucket:'{self.bucket_name}' "
|
50
|
+
f"region:'{self.bucket_region} key:{self._ctx.source_sha256}'>"
|
51
|
+
)
|
52
|
+
|
53
|
+
def _multipart_sha256sum(self, file_path: str) -> str:
|
54
|
+
"""
|
55
|
+
Calculate the sha256 checksum like AWS does it (in a multipart upload) per chunk
|
56
|
+
See https://docs.aws.amazon.com/AmazonS3/latest/userguide/checking-object-integrity.html#large-object-checksums
|
57
|
+
|
58
|
+
:param file_path: the path to the local file to upload
|
59
|
+
:type file_path: str
|
60
|
+
"""
|
61
|
+
sha256_list = []
|
62
|
+
count = 0
|
63
|
+
with open(file_path, "rb") as f:
|
64
|
+
for chunk in iter(lambda: f.read(MULTIPART_CHUNK_SIZE), b""):
|
65
|
+
sha256_list.append(hashlib.sha256(chunk))
|
66
|
+
count += 1
|
67
|
+
|
68
|
+
sha256_list_digest_concatenated = b"".join([s.digest() for s in sha256_list])
|
69
|
+
sha256_b64 = base64.b64encode(hashlib.sha256(sha256_list_digest_concatenated).digest())
|
70
|
+
return f"{sha256_b64.decode('ascii')}-{count}"
|
71
|
+
|
72
|
+
def _bucket_exists(self) -> bool:
|
73
|
+
"""
|
74
|
+
Check if the S3 bucket from context exists
|
75
|
+
|
76
|
+
:return: True if the bucket exists, otherwise False
|
77
|
+
:rtype: bool
|
78
|
+
"""
|
79
|
+
resp = self._s3client.list_buckets()
|
80
|
+
return self.bucket_name in [b["Name"] for b in resp["Buckets"]]
|
81
|
+
|
82
|
+
def upload_file(self, source_path: str):
|
83
|
+
"""
|
84
|
+
Upload a given file to the bucket from context. The key name will be the sha256sum hexdigest of the file.
|
85
|
+
If a file with that name already exist in the given bucket and the calculated sha256sum matches
|
86
|
+
the sha256sum from S3, nothing will be uploaded. Instead the existing file will be used.
|
87
|
+
This method does use a multipart upload internally so an upload can be retriggered in case
|
88
|
+
of errors and the previously uploaded content will be reused.
|
89
|
+
Note: be aware that failed multipart uploads are not deleted. So it's recommended to setup
|
90
|
+
a bucket lifecycle rule to delete incomplete multipart uploads.
|
91
|
+
See https://docs.aws.amazon.com/AmazonS3/latest/userguide//mpu-abort-incomplete-mpu-lifecycle-config.html
|
92
|
+
|
93
|
+
:param source_path: the path to the local file to upload (usually a .vmdk file)
|
94
|
+
:type source_path: str
|
95
|
+
"""
|
96
|
+
# make sure the bucket exists
|
97
|
+
if not self._bucket_exists():
|
98
|
+
raise BucketDoesNotExistException(self.bucket_name)
|
99
|
+
|
100
|
+
s3_sha256sum = self._multipart_sha256sum(source_path)
|
101
|
+
|
102
|
+
try:
|
103
|
+
# check if the key exists already in the bucket and if so, if the multipart upload
|
104
|
+
# sha256sum does match
|
105
|
+
head = self._s3client.head_object(
|
106
|
+
Bucket=self.bucket_name, Key=self._ctx.source_sha256, ChecksumMode="ENABLED"
|
107
|
+
)
|
108
|
+
|
109
|
+
if head["ChecksumSHA256"] == s3_sha256sum:
|
110
|
+
logger.info(
|
111
|
+
f"'{self._ctx.source_sha256}' in bucket '{self.bucket_name}' "
|
112
|
+
"already exists and sha256sum matches. nothing to upload to S3"
|
113
|
+
)
|
114
|
+
return
|
115
|
+
else:
|
116
|
+
logger.warning(
|
117
|
+
f"'{self._ctx.source_sha256}' in bucket '{self.bucket_name}' "
|
118
|
+
f"already exists but sha256sum does not match. Will be overwritten ..."
|
119
|
+
)
|
120
|
+
except Exception:
|
121
|
+
logging.debug(f"Can not find '{self._ctx.source_sha256}' in bucket '{self.bucket_name}'")
|
122
|
+
|
123
|
+
# do the real upload
|
124
|
+
self._upload_file_multipart(source_path, s3_sha256sum)
|
125
|
+
|
126
|
+
def _get_multipart_upload_id(self) -> str:
|
127
|
+
"""
|
128
|
+
Get an existing or create a multipart upload id
|
129
|
+
|
130
|
+
:return: a multipart upload id
|
131
|
+
:rtype: str
|
132
|
+
"""
|
133
|
+
resp = self._s3client.list_multipart_uploads(Bucket=self.bucket_name)
|
134
|
+
multipart_uploads = [
|
135
|
+
upload["UploadId"] for upload in resp.get("Uploads", []) if upload["Key"] == self._ctx.source_sha256
|
136
|
+
]
|
137
|
+
if len(multipart_uploads) == 1:
|
138
|
+
logger.info(f"found existing multipart upload '{multipart_uploads[0]}' for key '{self._ctx.source_sha256}'")
|
139
|
+
return multipart_uploads[0]
|
140
|
+
elif len(multipart_uploads) == 0:
|
141
|
+
# create a new multipart upload
|
142
|
+
resp_create = self._s3client.create_multipart_upload(
|
143
|
+
Bucket=self.bucket_name,
|
144
|
+
Key=self._ctx.source_sha256,
|
145
|
+
ChecksumAlgorithm="SHA256",
|
146
|
+
ACL="private",
|
147
|
+
)
|
148
|
+
upload_id = resp_create["UploadId"]
|
149
|
+
logger.info(
|
150
|
+
f"new multipart upload (upload id: '{upload_id})' started in bucket "
|
151
|
+
f"{self.bucket_name} for key {self._ctx.source_sha256}"
|
152
|
+
)
|
153
|
+
# if there's an expire rule configured for that bucket, inform about it
|
154
|
+
if resp_create.get("AbortDate"):
|
155
|
+
logger.info(
|
156
|
+
f"multipart upload '{upload_id}' will expire at "
|
157
|
+
f"{resp_create['AbortDate']} (rule: {resp_create.get('AbortRuleId')})"
|
158
|
+
)
|
159
|
+
else:
|
160
|
+
logger.warning("there is no matching expire/lifecycle rule configured for incomplete multipart uploads")
|
161
|
+
return upload_id
|
162
|
+
else:
|
163
|
+
# multiple multipart uploads for the same key available
|
164
|
+
logger.warning(
|
165
|
+
f"there are multiple ({len(multipart_uploads)}) multipart uploads ongoing in "
|
166
|
+
f"bucket {self.bucket_name} for key {self._ctx.source_sha256}"
|
167
|
+
)
|
168
|
+
logger.warning("using the first found multipart upload but you should delete pending multipart uploads")
|
169
|
+
return multipart_uploads[0]
|
170
|
+
|
171
|
+
def _upload_file_multipart(self, source_path: str, s3_sha256sum: str) -> None:
|
172
|
+
"""
|
173
|
+
Upload a given file to the bucket from context. The key name will be the sha256sum hexdigest of the file
|
174
|
+
|
175
|
+
:param source_path: the path to the local file to upload (usually a .vmdk file)
|
176
|
+
:type source_path: str
|
177
|
+
:param s3_sha256sum: the sha256sum how S3 calculates it
|
178
|
+
:type s3_sha256sum: str
|
179
|
+
"""
|
180
|
+
upload_id = self._get_multipart_upload_id()
|
181
|
+
|
182
|
+
logger.info(f"using upload id '{upload_id}' for multipart upload of '{source_path}' ...")
|
183
|
+
resp_list_parts = self._s3client.list_parts(
|
184
|
+
Bucket=self.bucket_name, Key=self._ctx.source_sha256, UploadId=upload_id
|
185
|
+
)
|
186
|
+
|
187
|
+
# sanity check for the used checksum algorithm
|
188
|
+
if resp_list_parts["ChecksumAlgorithm"] != "SHA256":
|
189
|
+
logger.error(f"available ongoing multipart upload '{upload_id}' does not use SHA256 as checksum algorithm")
|
190
|
+
|
191
|
+
# already available parts
|
192
|
+
parts_available = {p["PartNumber"]: p for p in resp_list_parts.get("Parts", [])}
|
193
|
+
# keep a list of parts (either already available or created) required to complete the multipart upload
|
194
|
+
parts: Dict[int, CompletedPartTypeDef] = {}
|
195
|
+
parts_size_done: int = 0
|
196
|
+
source_path_size: int = os.path.getsize(source_path)
|
197
|
+
with open(source_path, "rb") as f:
|
198
|
+
# parts start at 1 (not 0)
|
199
|
+
for part_number, chunk in enumerate(iter(lambda: f.read(MULTIPART_CHUNK_SIZE), b""), start=1):
|
200
|
+
# the sha256sum of the current part
|
201
|
+
sha256_part = base64.b64encode(hashlib.sha256(chunk).digest()).decode("ascii")
|
202
|
+
# do nothing if that part number already exist and the sha256sum matches
|
203
|
+
if parts_available.get(part_number):
|
204
|
+
if parts_available[part_number]["ChecksumSHA256"] == sha256_part:
|
205
|
+
logger.info(f"part {part_number} already exists and sha256sum matches. continue")
|
206
|
+
parts[part_number] = dict(
|
207
|
+
PartNumber=part_number,
|
208
|
+
ETag=parts_available[part_number]["ETag"],
|
209
|
+
ChecksumSHA256=parts_available[part_number]["ChecksumSHA256"],
|
210
|
+
)
|
211
|
+
parts_size_done += len(chunk)
|
212
|
+
continue
|
213
|
+
else:
|
214
|
+
logger.info(f"part {part_number} already exists but will be overwritten")
|
215
|
+
|
216
|
+
# upload a new part
|
217
|
+
resp_upload_part = self._s3client.upload_part(
|
218
|
+
Body=chunk,
|
219
|
+
Bucket=self.bucket_name,
|
220
|
+
ContentLength=len(chunk),
|
221
|
+
ChecksumAlgorithm="SHA256",
|
222
|
+
ChecksumSHA256=sha256_part,
|
223
|
+
Key=self._ctx.source_sha256,
|
224
|
+
PartNumber=part_number,
|
225
|
+
UploadId=upload_id,
|
226
|
+
)
|
227
|
+
parts_size_done += len(chunk)
|
228
|
+
# add new part to the dict of parts
|
229
|
+
parts[part_number] = dict(
|
230
|
+
PartNumber=part_number,
|
231
|
+
ETag=resp_upload_part["ETag"],
|
232
|
+
ChecksumSHA256=sha256_part,
|
233
|
+
)
|
234
|
+
logger.info(
|
235
|
+
f"part {part_number} uploaded ({round(parts_size_done/source_path_size * 100, 2)}% "
|
236
|
+
f"; {parts_size_done} / {source_path_size} bytes)"
|
237
|
+
)
|
238
|
+
|
239
|
+
logger.info(
|
240
|
+
f"finishing the multipart upload for key '{self._ctx.source_sha256}' in bucket {self.bucket_name} now ..."
|
241
|
+
)
|
242
|
+
# finish the multipart upload
|
243
|
+
self._s3client.complete_multipart_upload(
|
244
|
+
Bucket=self.bucket_name,
|
245
|
+
Key=self._ctx.source_sha256,
|
246
|
+
UploadId=upload_id,
|
247
|
+
ChecksumSHA256=s3_sha256sum,
|
248
|
+
MultipartUpload={"Parts": [value for key, value in parts.items()]},
|
249
|
+
)
|
250
|
+
logger.info(
|
251
|
+
f"multipart upload finished and key '{self._ctx.source_sha256}' now "
|
252
|
+
f"available in bucket '{self.bucket_name}'"
|
253
|
+
)
|
254
|
+
|
255
|
+
# add tagging to the final s3 object
|
256
|
+
self._s3client.put_object_tagging(
|
257
|
+
Bucket=self.bucket_name,
|
258
|
+
Key=self._ctx.source_sha256,
|
259
|
+
Tagging={
|
260
|
+
"TagSet": self._ctx.tags,
|
261
|
+
},
|
262
|
+
)
|
awspub/snapshot.py
ADDED
@@ -0,0 +1,241 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import Dict, List, Optional
|
3
|
+
|
4
|
+
import boto3
|
5
|
+
from mypy_boto3_ec2.client import EC2Client
|
6
|
+
|
7
|
+
from awspub import exceptions
|
8
|
+
from awspub.context import Context
|
9
|
+
|
10
|
+
logger = logging.getLogger(__name__)
|
11
|
+
|
12
|
+
|
13
|
+
class Snapshot:
|
14
|
+
"""
|
15
|
+
Handle EC2 Snapshot API interaction
|
16
|
+
"""
|
17
|
+
|
18
|
+
def __init__(self, context: Context):
|
19
|
+
self._ctx: Context = context
|
20
|
+
|
21
|
+
def _get(self, ec2client: EC2Client, snapshot_name: str) -> Optional[str]:
|
22
|
+
"""
|
23
|
+
Get the snapshot id for the given name or None
|
24
|
+
|
25
|
+
:param ec2client: EC2 client for a specific region
|
26
|
+
:type ec2client: EC2Client
|
27
|
+
:param snapshot_name: the Snapshot name
|
28
|
+
:type snapshot_name: str
|
29
|
+
:return: Either None or a snapshot-id
|
30
|
+
:rtype: Optional[str]
|
31
|
+
"""
|
32
|
+
resp = ec2client.describe_snapshots(
|
33
|
+
Filters=[
|
34
|
+
{
|
35
|
+
"Name": "tag:Name",
|
36
|
+
"Values": [
|
37
|
+
snapshot_name,
|
38
|
+
],
|
39
|
+
},
|
40
|
+
{
|
41
|
+
"Name": "status",
|
42
|
+
"Values": [
|
43
|
+
"pending",
|
44
|
+
"completed",
|
45
|
+
],
|
46
|
+
},
|
47
|
+
],
|
48
|
+
OwnerIds=["self"],
|
49
|
+
)
|
50
|
+
if len(resp.get("Snapshots", [])) == 1:
|
51
|
+
return resp["Snapshots"][0]["SnapshotId"]
|
52
|
+
elif len(resp.get("Snapshots", [])) == 0:
|
53
|
+
return None
|
54
|
+
else:
|
55
|
+
raise exceptions.MultipleSnapshotsException(
|
56
|
+
f"Found {len(resp.get('Snapshots', []))} snapshots with "
|
57
|
+
f"name '{snapshot_name}' in region {ec2client.meta.region_name}"
|
58
|
+
)
|
59
|
+
|
60
|
+
def _get_import_snapshot_task(self, ec2client: EC2Client, snapshot_name: str) -> Optional[str]:
|
61
|
+
"""
|
62
|
+
Get a import snapshot task for the given name
|
63
|
+
|
64
|
+
:param ec2client: EC2 client for a specific region
|
65
|
+
:type ec2client: EC2Client
|
66
|
+
:param snapshot_name: the Snapshot name
|
67
|
+
:type snapshot_name: str
|
68
|
+
:return: Either None or a import-snapshot-task-id
|
69
|
+
:rtype: Optional[str]
|
70
|
+
"""
|
71
|
+
resp = ec2client.describe_import_snapshot_tasks(
|
72
|
+
Filters=[
|
73
|
+
{
|
74
|
+
"Name": "tag:Name",
|
75
|
+
"Values": [
|
76
|
+
snapshot_name,
|
77
|
+
],
|
78
|
+
}
|
79
|
+
]
|
80
|
+
)
|
81
|
+
# API doesn't support filters by status so filter here
|
82
|
+
tasks: List = resp.get("ImportSnapshotTasks", [])
|
83
|
+
# we already know here that the snapshot does not exist (checked in create() before calling this
|
84
|
+
# function). so ignore "deleted" or "completed" tasks here
|
85
|
+
# it might happen (for whatever reason) that a task got completed but the snapshot got deleted
|
86
|
+
# afterwards. In that case a "completed" task for the given snapshot_name exists but
|
87
|
+
# that doesn't help so ignore it
|
88
|
+
tasks = [t for t in tasks if t["SnapshotTaskDetail"]["Status"] not in ["deleted", "completed"]]
|
89
|
+
if len(tasks) == 1:
|
90
|
+
return tasks[0]["ImportTaskId"]
|
91
|
+
elif len(tasks) == 0:
|
92
|
+
return None
|
93
|
+
else:
|
94
|
+
raise exceptions.MultipleImportSnapshotTasksException(
|
95
|
+
f"Found {len(tasks)} import snapshot tasks with "
|
96
|
+
f"name '{snapshot_name}' in region {ec2client.meta.region_name}"
|
97
|
+
)
|
98
|
+
|
99
|
+
def create(self, ec2client: EC2Client, snapshot_name: str) -> str:
|
100
|
+
"""
|
101
|
+
Create a EC2 snapshot with the given name
|
102
|
+
If the snapshot already exists, just return the snapshot-id for the existing snapshot.
|
103
|
+
|
104
|
+
:param ec2client: EC2 client for a specific region
|
105
|
+
:type ec2client: EC2Client
|
106
|
+
:param snapshot_name: the Snapshot name
|
107
|
+
:type snapshot_name: str
|
108
|
+
:return: a snapshot-id
|
109
|
+
:rtype: str
|
110
|
+
"""
|
111
|
+
# does a snapshot with the given name already exists?
|
112
|
+
snap_id: Optional[str] = self._get(ec2client, snapshot_name)
|
113
|
+
if snap_id:
|
114
|
+
logger.info(f"snapshot with name '{snapshot_name}' already exists in region {ec2client.meta.region_name}")
|
115
|
+
return snap_id
|
116
|
+
|
117
|
+
logger.info(
|
118
|
+
f"Create snapshot from bucket '{self._ctx.conf['s3']['bucket_name']}' "
|
119
|
+
f"for '{snapshot_name}' in region {ec2client.meta.region_name}"
|
120
|
+
)
|
121
|
+
|
122
|
+
# extend tags
|
123
|
+
tags = self._ctx.tags
|
124
|
+
tags.append({"Key": "Name", "Value": snapshot_name})
|
125
|
+
|
126
|
+
# does a import snapshot task with the given name already exist?
|
127
|
+
import_snapshot_task_id: Optional[str] = self._get_import_snapshot_task(ec2client, snapshot_name)
|
128
|
+
if import_snapshot_task_id:
|
129
|
+
logger.info(
|
130
|
+
f"import snapshot task ({import_snapshot_task_id}) with "
|
131
|
+
f"name '{snapshot_name}' exists in region {ec2client.meta.region_name}"
|
132
|
+
)
|
133
|
+
else:
|
134
|
+
resp = ec2client.import_snapshot(
|
135
|
+
Description="Import ",
|
136
|
+
DiskContainer={
|
137
|
+
"Description": "",
|
138
|
+
"Format": "vmdk",
|
139
|
+
"UserBucket": {
|
140
|
+
"S3Bucket": self._ctx.conf["s3"]["bucket_name"],
|
141
|
+
"S3Key": self._ctx.source_sha256,
|
142
|
+
},
|
143
|
+
},
|
144
|
+
TagSpecifications=[
|
145
|
+
{"ResourceType": "import-snapshot-task", "Tags": tags},
|
146
|
+
],
|
147
|
+
)
|
148
|
+
import_snapshot_task_id = resp["ImportTaskId"]
|
149
|
+
|
150
|
+
logger.info(
|
151
|
+
f"Waiting for snapshot import task (id: {import_snapshot_task_id}) "
|
152
|
+
f"in region {ec2client.meta.region_name} ..."
|
153
|
+
)
|
154
|
+
|
155
|
+
waiter_import = ec2client.get_waiter("snapshot_imported")
|
156
|
+
waiter_import.wait(ImportTaskIds=[import_snapshot_task_id], WaiterConfig={"Delay": 30, "MaxAttempts": 90})
|
157
|
+
|
158
|
+
task_details = ec2client.describe_import_snapshot_tasks(ImportTaskIds=[import_snapshot_task_id])
|
159
|
+
snapshot_id = task_details["ImportSnapshotTasks"][0]["SnapshotTaskDetail"]["SnapshotId"]
|
160
|
+
|
161
|
+
# create tags before waiting for completion so the tags are already there
|
162
|
+
ec2client.create_tags(Resources=[snapshot_id], Tags=tags)
|
163
|
+
|
164
|
+
waiter_completed = ec2client.get_waiter("snapshot_completed")
|
165
|
+
waiter_completed.wait(SnapshotIds=[snapshot_id], WaiterConfig={"Delay": 30, "MaxAttempts": 60})
|
166
|
+
|
167
|
+
logger.info(f"Snapshot import as '{snapshot_id}' in region {ec2client.meta.region_name} done")
|
168
|
+
return snapshot_id
|
169
|
+
|
170
|
+
def _copy(self, snapshot_name: str, source_region: str, destination_region: str) -> str:
|
171
|
+
"""
|
172
|
+
Copy a EC2 snapshot for the given context to the destination region
|
173
|
+
NOTE: we don't wait for the snapshot to complete here!
|
174
|
+
|
175
|
+
:param snapshot_name: the Snapshot name to copy
|
176
|
+
:type snapshot_name: str
|
177
|
+
:param source_region: a region to copy the snapshot from
|
178
|
+
:type source_region: str
|
179
|
+
:param destination_region: a region to copy the snapshot to
|
180
|
+
:type destionation_region: str
|
181
|
+
|
182
|
+
:return: the existing or created snapshot-id
|
183
|
+
:rtype: str
|
184
|
+
"""
|
185
|
+
|
186
|
+
# does the snapshot with that name already exist in the destination region?
|
187
|
+
ec2client_dest: EC2Client = boto3.client("ec2", region_name=destination_region)
|
188
|
+
snapshot_id: Optional[str] = self._get(ec2client_dest, snapshot_name)
|
189
|
+
if snapshot_id:
|
190
|
+
logger.info(
|
191
|
+
f"snapshot with name '{snapshot_name}' already "
|
192
|
+
f"exists ({snapshot_id}) in destination region {ec2client_dest.meta.region_name}"
|
193
|
+
)
|
194
|
+
return snapshot_id
|
195
|
+
|
196
|
+
ec2client_source: EC2Client = boto3.client("ec2", region_name=source_region)
|
197
|
+
source_snapshot_id: Optional[str] = self._get(ec2client_source, snapshot_name)
|
198
|
+
if not source_snapshot_id:
|
199
|
+
raise ValueError(
|
200
|
+
f"Can not find source snapshot with name '{snapshot_name}' "
|
201
|
+
f"in region {ec2client_source.meta.region_name}"
|
202
|
+
)
|
203
|
+
|
204
|
+
logger.info(f"Copy snapshot {source_snapshot_id} from " f"{source_region} to {destination_region}")
|
205
|
+
# extend tags
|
206
|
+
tags = self._ctx.tags
|
207
|
+
tags.append({"Key": "Name", "Value": snapshot_name})
|
208
|
+
resp = ec2client_dest.copy_snapshot(
|
209
|
+
SourceRegion=source_region,
|
210
|
+
SourceSnapshotId=source_snapshot_id,
|
211
|
+
TagSpecifications=[{"ResourceType": "snapshot", "Tags": tags}],
|
212
|
+
)
|
213
|
+
|
214
|
+
# note: we don't wait for the snapshot to complete here!
|
215
|
+
return resp["SnapshotId"]
|
216
|
+
|
217
|
+
def copy(self, snapshot_name: str, source_region: str, destination_regions: List[str]) -> Dict[str, str]:
|
218
|
+
"""
|
219
|
+
Copy a snapshot to multiple regions
|
220
|
+
|
221
|
+
:param snapshot_name: the Snapshot name to copy
|
222
|
+
:type snapshot_name: str
|
223
|
+
:param source_region: a region to copy the snapshot from
|
224
|
+
:type source_region: str
|
225
|
+
:param destination_regions: a list of regions to copy the snaphot to
|
226
|
+
:type destionation_regions: List[str]
|
227
|
+
:return: a dict with region/snapshot-id mapping for the newly copied snapshots
|
228
|
+
:rtype: Dict[str, str] where the key is a region name and the value a snapshot-id
|
229
|
+
"""
|
230
|
+
snapshot_ids: Dict[str, str] = dict()
|
231
|
+
for destination_region in destination_regions:
|
232
|
+
snapshot_ids[destination_region] = self._copy(snapshot_name, source_region, destination_region)
|
233
|
+
|
234
|
+
logger.info(f"Waiting for {len(snapshot_ids)} snapshots to appear in the destination regions ...")
|
235
|
+
for destination_region, snapshot_id in snapshot_ids.items():
|
236
|
+
ec2client_dest = boto3.client("ec2", region_name=destination_region)
|
237
|
+
waiter = ec2client_dest.get_waiter("snapshot_completed")
|
238
|
+
logger.info(f"Waiting for {snapshot_id} in {ec2client_dest.meta.region_name} to complete ...")
|
239
|
+
waiter.wait(SnapshotIds=[snapshot_id], WaiterConfig={"Delay": 30, "MaxAttempts": 90})
|
240
|
+
|
241
|
+
return snapshot_ids
|