awspub 0.0.10__py3-none-any.whl

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/image.py ADDED
@@ -0,0 +1,656 @@
1
+ import hashlib
2
+ import logging
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ import boto3
8
+ import botocore.exceptions
9
+ from mypy_boto3_ec2.client import EC2Client
10
+ from mypy_boto3_ssm import SSMClient
11
+
12
+ from awspub import exceptions
13
+ from awspub.common import _get_regions, _split_partition
14
+ from awspub.context import Context
15
+ from awspub.image_marketplace import ImageMarketplace
16
+ from awspub.s3 import S3
17
+ from awspub.snapshot import Snapshot
18
+ from awspub.sns import SNSNotification
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class _ImageInfo:
25
+ """
26
+ Information about a image from EC2
27
+ """
28
+
29
+ image_id: str
30
+ snapshot_id: Optional[str]
31
+
32
+
33
+ class ImageVerificationErrors(str, Enum):
34
+ """
35
+ Possible errors for image verification
36
+ """
37
+
38
+ NOT_EXIST = "image does not exist"
39
+ STATE_NOT_AVAILABLE = "image not available"
40
+ ROOT_DEVICE_TYPE = "root device type mismatch"
41
+ ROOT_DEVICE_VOLUME_TYPE = "root device volume type mismatch"
42
+ ROOT_DEVICE_VOLUME_SIZE = "root device volume size mismatch"
43
+ ROOT_DEVICE_SNAPSHOT_NOT_COMPLETE = "root device snapshot not complete"
44
+ BOOT_MODE = "boot mode mismatch"
45
+ TAGS = "tags mismatch"
46
+ TPM_SUPPORT = "tpm support mismatch"
47
+ IMDS_SUPPORT = "imds support mismatch"
48
+ BILLING_PRODUCTS = "billing products mismatch"
49
+
50
+
51
+ class Image:
52
+ """
53
+ Handle EC2 Image/AMI API interaction
54
+ """
55
+
56
+ def __init__(self, context: Context, image_name: str):
57
+ self._ctx: Context = context
58
+ self._image_name: str = image_name
59
+ self._image_regions: List[str] = []
60
+ self._image_regions_cached: bool = False
61
+
62
+ if self._image_name not in self._ctx.conf["images"].keys():
63
+ raise ValueError(f"image '{self._image_name}' not found in context configuration")
64
+
65
+ self._snapshot: Snapshot = Snapshot(context)
66
+ self._s3: S3 = S3(context)
67
+
68
+ def __repr__(self):
69
+ return f"<{self.__class__} :'{self.image_name}' (snapshot name: {self.snapshot_name})"
70
+
71
+ @property
72
+ def conf(self) -> Dict[str, Any]:
73
+ """
74
+ The configuration for the current image (based on "image_name") from context
75
+ """
76
+ return self._ctx.conf["images"][self._image_name]
77
+
78
+ @property
79
+ def image_name(self) -> str:
80
+ """
81
+ Get the image name
82
+ """
83
+ return self._image_name
84
+
85
+ @property
86
+ def snapshot_name(self) -> str:
87
+ """
88
+ Get the snapshot name which is a sha256 hexdigest
89
+
90
+ The snapshot name is the sha256 hexdigest of the source file given in the source->path
91
+ configuration option.
92
+
93
+ if the "separate_snapshot" config option is set to True, the snapshot name is
94
+ sha256 hexdigest of the source file given in the source->path conf option and then
95
+ the sha256 hexdigest of the image-name appended and then the sha256 hexdigest
96
+ calculated of this concatenated string.
97
+
98
+ if the "billing_products" config option is set, the snapshot name is
99
+ sha256 hexdigest of the source file given in the source->path conf option and then
100
+ the sha256 hexdigest of each entry in the billing_products appended and then the sha256 hexdigest
101
+ calculated of this concatenated string.
102
+
103
+ Note that both options ("separate_snapshot" and "billing_products") can be combined
104
+ and the snapshot calculation steps would be combined, too.
105
+ """
106
+ s_name = self._ctx.source_sha256
107
+ if self.conf["separate_snapshot"] is True:
108
+ s_name += hashlib.sha256(self.image_name.encode("utf-8")).hexdigest()
109
+
110
+ if self.conf["billing_products"]:
111
+ for bp in self.conf["billing_products"]:
112
+ s_name += hashlib.sha256(bp.encode("utf-8")).hexdigest()
113
+
114
+ # in the separate_snapshot and billing_products had no effect, don't do another sha256 of
115
+ # the source_sha256 to simplify things
116
+ if s_name == self._ctx.source_sha256:
117
+ return s_name
118
+
119
+ # do a sha256 of the concatenated string
120
+ return hashlib.sha256(s_name.encode("utf-8")).hexdigest()
121
+
122
+ @property
123
+ def image_regions(self) -> List[str]:
124
+ """
125
+ Get the image regions.
126
+ """
127
+ if not self._image_regions_cached:
128
+ regions_configured = self.conf["regions"] if "regions" in self.conf else []
129
+ self._image_regions = _get_regions(self._s3.bucket_region, regions_configured)
130
+ self._image_regions_cached = True
131
+ return self._image_regions
132
+
133
+ @property
134
+ def _tags(self):
135
+ """
136
+ Get the tags for this image (common tags + image specific tags)
137
+ image specific tags override common tags
138
+ """
139
+ tags = []
140
+ # the common tags
141
+ tags_dict = self._ctx.tags_dict
142
+ # the image specific tags
143
+ tags_dict.update(self.conf.get("tags", {}))
144
+ for name, value in tags_dict.items():
145
+ tags.append({"Key": name, "Value": value})
146
+ return tags
147
+
148
+ def _share_list_filtered(self, share_conf: List[str]) -> List[Dict[str, str]]:
149
+ """
150
+ Get a filtered list of share configurations based on the current partition
151
+ :param share_conf: the share configuration
152
+ :type share_conf: List[str]
153
+ :return: a List of share configurations that is usable by modify_image_attribute()
154
+ :rtype: List[Dict[str, str]]
155
+ """
156
+ # the current partition
157
+ partition_current = boto3.client("ec2").meta.partition
158
+
159
+ share_list: List[Dict[str, str]] = []
160
+ for share in share_conf:
161
+ partition, account_id = _split_partition(share)
162
+ if partition == partition_current:
163
+ share_list.append({"UserId": account_id})
164
+ return share_list
165
+
166
+ def _share(self, share_conf: List[str], images: Dict[str, _ImageInfo]):
167
+ """
168
+ Share images with accounts
169
+
170
+ :param share_conf: the share configuration containing list
171
+ :type share_conf: List[str]
172
+ :param images: a Dict with region names as keys and _ImageInfo objects as values
173
+ :type images: Dict[str, _ImageInfo]
174
+ """
175
+ share_list = self._share_list_filtered(share_conf)
176
+
177
+ if not share_list:
178
+ logger.info("no valid accounts found for sharing in this partition, skipping")
179
+ return
180
+
181
+ for region, image_info in images.items():
182
+ ec2client: EC2Client = boto3.client("ec2", region_name=region)
183
+ # modify image permissions
184
+ ec2client.modify_image_attribute(
185
+ Attribute="LaunchPermission",
186
+ ImageId=image_info.image_id,
187
+ LaunchPermission={"Add": share_list}, # type: ignore
188
+ )
189
+
190
+ # modify snapshot permissions
191
+ if image_info.snapshot_id:
192
+ ec2client.modify_snapshot_attribute(
193
+ Attribute="createVolumePermission",
194
+ SnapshotId=image_info.snapshot_id,
195
+ CreateVolumePermission={"Add": share_list}, # type: ignore
196
+ )
197
+
198
+ logger.info(f"shared images & snapshots with '{share_conf}'")
199
+
200
+ def _get_root_device_snapshot_id(self, image):
201
+ """
202
+ Get the root device snapshot id for a given image
203
+ :param image: a image structure returned by eg. describe_images()["Images"][0]
204
+ :type image: dict
205
+ :return: Either None or a snapshot-id
206
+ :rtype: Optional[str]
207
+ """
208
+ root_device_name = image.get("RootDeviceName")
209
+ if not root_device_name:
210
+ logger.debug(f"can not get RootDeviceName for image {image}")
211
+ return None
212
+ for bdm in image["BlockDeviceMappings"]:
213
+ if bdm["DeviceName"] == root_device_name:
214
+ ebs = bdm.get("Ebs")
215
+ if not ebs:
216
+ logger.debug(
217
+ f"can not get RootDeviceName. root device {root_device_name} doesn't have a Ebs section"
218
+ )
219
+ return None
220
+ logger.debug(f"found Ebs for root device {root_device_name}: {bdm['Ebs']}")
221
+ return bdm["Ebs"]["SnapshotId"]
222
+
223
+ def _get(self, ec2client: EC2Client) -> Optional[_ImageInfo]:
224
+ """
225
+ Get the a _ImageInfo for the current image which contains the ami id and
226
+ root device snapshot id.
227
+ This relies on the image name to be unique and will raise a MultipleImagesException
228
+ if multiple images are found.
229
+
230
+ :param ec2client: EC2Client
231
+ :type ec2client: EC2Client
232
+ :return: Either None or a _ImageInfo
233
+ :rtype: Optional[_ImageInfo]
234
+ """
235
+ resp = ec2client.describe_images(
236
+ Filters=[
237
+ {"Name": "name", "Values": [self.image_name]},
238
+ ],
239
+ Owners=["self"],
240
+ )
241
+
242
+ if len(resp.get("Images", [])) == 1:
243
+ root_device_snapshot_id = self._get_root_device_snapshot_id(resp["Images"][0])
244
+ return _ImageInfo(resp["Images"][0]["ImageId"], root_device_snapshot_id)
245
+ elif len(resp.get("Images", [])) == 0:
246
+ return None
247
+ else:
248
+ images = [i["ImageId"] for i in resp.get("Images", [])]
249
+ raise exceptions.MultipleImagesException(
250
+ f"Found {len(images)} images ({', '.join(images)}) with "
251
+ f"name {self.image_name} in region {ec2client.meta.region_name}. There should be only 1."
252
+ )
253
+
254
+ def _put_ssm_parameters(self) -> None:
255
+ """
256
+ Push the configured SSM parameters to the parameter store
257
+ """
258
+ logger.info(f"Pushing SSM parameters for image {self.image_name} in {len(self.image_regions)} regions ...")
259
+ for region in self.image_regions:
260
+ ec2client_region: EC2Client = boto3.client("ec2", region_name=region)
261
+ image_info: Optional[_ImageInfo] = self._get(ec2client_region)
262
+
263
+ # image in region not found
264
+ if not image_info:
265
+ logger.error(f"image {self.image_name} not available in region {region}. can not push SSM parameter")
266
+ continue
267
+
268
+ ssmclient_region: SSMClient = boto3.client("ssm", region_name=region)
269
+ # iterate over all defined parameters
270
+ for parameter in self.conf["ssm_parameter"]:
271
+ # if overwrite is not allowed, check if the parameter is already there and if so, do nothing
272
+ if not parameter["allow_overwrite"]:
273
+ resp = ssmclient_region.get_parameters(Names=[parameter["name"]])
274
+ if len(resp["Parameters"]) >= 1:
275
+ # sanity check if the available parameter matches the value we would (but don't) push
276
+ if resp["Parameters"][0]["Value"] != image_info.image_id:
277
+ logger.warning(
278
+ f"SSM parameter {parameter['name']} exists but value does not match "
279
+ f"(found {resp['Parameters'][0]['Value']}; expected: {image_info.image_id}"
280
+ )
281
+ # parameter exists already and overwrite is not allowed so continue
282
+ continue
283
+ # push parameter to store
284
+ ssmclient_region.put_parameter(
285
+ Name=parameter["name"],
286
+ Description=parameter.get("description", ""),
287
+ Value=image_info.image_id,
288
+ Type="String",
289
+ Overwrite=parameter["allow_overwrite"],
290
+ DataType="aws:ec2:image",
291
+ # TODO: tags can't be used together with overwrite
292
+ # Tags=self._ctx.tags,
293
+ )
294
+
295
+ logger.info(
296
+ f"pushed SSM parameter {parameter['name']} with value {image_info.image_id} in region {region}"
297
+ )
298
+
299
+ def _public(self) -> None:
300
+ """
301
+ Make image and underlying root device snapshot public
302
+ """
303
+ logger.info(f"Make image {self.image_name} in {len(self.image_regions)} regions public ...")
304
+
305
+ for region in self.image_regions:
306
+ ec2client_region: EC2Client = boto3.client("ec2", region_name=region)
307
+ image_info: Optional[_ImageInfo] = self._get(ec2client_region)
308
+ if image_info:
309
+ ec2client_region.modify_image_attribute(
310
+ ImageId=image_info.image_id,
311
+ LaunchPermission={
312
+ "Add": [
313
+ {
314
+ "Group": "all",
315
+ },
316
+ ],
317
+ },
318
+ )
319
+ logger.info(f"image {image_info.image_id} in region {region} public now")
320
+
321
+ if image_info.snapshot_id:
322
+ ec2client_region.modify_snapshot_attribute(
323
+ SnapshotId=image_info.snapshot_id,
324
+ Attribute="createVolumePermission",
325
+ GroupNames=[
326
+ "all",
327
+ ],
328
+ OperationType="add",
329
+ )
330
+ logger.info(
331
+ f"snapshot {image_info.snapshot_id} ({image_info.image_id}) in region {region} public now"
332
+ )
333
+ else:
334
+ logger.error(
335
+ f"snapshot for image {self.image_name} ({image_info.image_id}) not available "
336
+ f"in region {region}. can not make public"
337
+ )
338
+ else:
339
+ logger.error(f"image {self.image_name} not available in region {region}. can not make public")
340
+
341
+ def _sns_publish(self) -> None:
342
+ """
343
+ Publish SNS notifiations about newly available images to subscribers
344
+ """
345
+
346
+ SNSNotification(self._ctx, self.image_name).publish()
347
+
348
+ def cleanup(self) -> None:
349
+ """
350
+ Cleanup/delete the temporary images
351
+
352
+ If an image is marked as "temporary" in the configuration, do
353
+ delete that image in all regions.
354
+ Note: if a temporary image is public, it won't be deleted. A temporary
355
+ image should never be public
356
+ Note: the underlying snapshot is currently not deleted. That might change in
357
+ the future
358
+ """
359
+ if not self.conf["temporary"]:
360
+ logger.info(f"image {self.image_name} not marked as temporary. no cleanup")
361
+ return
362
+
363
+ # do the cleanup - the image is marked as temporary
364
+ logger.info(f"Cleanup image {self.image_name} ...")
365
+ for region in self.image_regions:
366
+ ec2client_region: EC2Client = boto3.client("ec2", region_name=region)
367
+ image_info: Optional[_ImageInfo] = self._get(ec2client_region)
368
+
369
+ if image_info:
370
+ resp = ec2client_region.describe_images(
371
+ Filters=[
372
+ {"Name": "image-id", "Values": [image_info.image_id]},
373
+ ]
374
+ )
375
+ if resp["Images"][0]["Public"] is True:
376
+ # this shouldn't happen because the image is marked as temporary in the config
377
+ # so how can it be public?
378
+ logger.error(
379
+ f"no cleanup for {self.image_name} in {region} because ({image_info.image_id}) image is public"
380
+ )
381
+ else:
382
+ ec2client_region.deregister_image(ImageId=image_info.image_id)
383
+ logger.info(f"{self.image_name} in {region} ({image_info.image_id}) deleted")
384
+
385
+ def list(self) -> Dict[str, _ImageInfo]:
386
+ """
387
+ Get image based on the available configuration
388
+ This doesn't change anything - it just tries to get the available image
389
+ for the different configured regions
390
+ :return: a Dict with region names as keys and _ImageInfo objects as values
391
+ :rtype: Dict[str, _ImageInfo]
392
+ """
393
+ images: Dict[str, _ImageInfo] = dict()
394
+ for region in self.image_regions:
395
+ ec2client_region: EC2Client = boto3.client("ec2", region_name=region)
396
+ image_info: Optional[_ImageInfo] = self._get(ec2client_region)
397
+ if image_info:
398
+ images[region] = image_info
399
+ else:
400
+ logger.warning(f"image {self.image_name} not available in region {region}")
401
+ return images
402
+
403
+ def create(self) -> Dict[str, _ImageInfo]:
404
+ """
405
+ Get or create a image based on the available configuration
406
+
407
+ :return: a Dict with region names as keys and _ImageInfo objects as values
408
+ :rtype: Dict[str, _ImageInfo]
409
+ """
410
+ # this **must** be the region that is used for S3
411
+ ec2client: EC2Client = boto3.client("ec2", region_name=self._s3.bucket_region)
412
+
413
+ # make sure the initial snapshot exists
414
+ self._snapshot.create(ec2client, self.snapshot_name)
415
+
416
+ # make sure the snapshot exist in all required regions
417
+ snapshot_ids: Dict[str, str] = self._snapshot.copy(
418
+ self.snapshot_name, self._s3.bucket_region, self.image_regions
419
+ )
420
+
421
+ images: Dict[str, _ImageInfo] = dict()
422
+ missing_regions: List[str] = []
423
+ for region in self.image_regions:
424
+ ec2client_region: EC2Client = boto3.client("ec2", region_name=region)
425
+ image_info: Optional[_ImageInfo] = self._get(ec2client_region)
426
+ if image_info:
427
+ if image_info.snapshot_id != snapshot_ids[region]:
428
+ logger.warning(
429
+ f"image with name '{self.image_name}' already exists ({image_info.image_id}) "
430
+ f"in region {ec2client_region.meta.region_name} but the root device "
431
+ f"snapshot id is unexpected (got {image_info.snapshot_id} but expected {snapshot_ids[region]})"
432
+ )
433
+ else:
434
+ logger.info(
435
+ f"image with name '{self.image_name}' already exists ({image_info.image_id}) "
436
+ f"in region {ec2client_region.meta.region_name}"
437
+ )
438
+ images[region] = image_info
439
+ else:
440
+ if image := self._register_image(snapshot_ids[region], ec2client_region):
441
+ images[region] = image
442
+ else:
443
+ missing_regions.append(region)
444
+ # wait for the images
445
+ logger.info(f"Waiting for {len(images)} images to be ready the regions ...")
446
+ for region, image_info in images.items():
447
+ ec2client_region_wait: EC2Client = boto3.client("ec2", region_name=region)
448
+ logger.info(
449
+ f"Waiting for {image_info.image_id} in {ec2client_region_wait.meta.region_name} "
450
+ "to exist/be available ..."
451
+ )
452
+ waiter_exists = ec2client_region_wait.get_waiter("image_exists")
453
+ waiter_exists.wait(ImageIds=[image_info.image_id])
454
+ waiter_available = ec2client_region_wait.get_waiter("image_available")
455
+ waiter_available.wait(ImageIds=[image_info.image_id])
456
+ logger.info(f"{len(images)} images are ready")
457
+
458
+ # share
459
+ if self.conf["share"]:
460
+ self._share(self.conf["share"], images)
461
+
462
+ if missing_regions:
463
+ logger.error("Failed to publish images to all regions", extra={"missing_regions": missing_regions})
464
+ raise exceptions.IncompleteImageSetException("Incomplete image set published")
465
+
466
+ return images
467
+
468
+ def _register_image(self, snapshot_id: str, ec2client: EC2Client) -> Optional[_ImageInfo]:
469
+ """
470
+ Register snapshot_id in region configured for ec2client_region
471
+
472
+ :param snapshot_id: snapshot id to use for image registration
473
+ :type snapshot_id: str
474
+ :param ec2client: EC2Client for the region to register image to
475
+ :type ec2client: EC2Client
476
+ :return: _ImageInfo containing the ImageId SnapshotId pair
477
+ :rtype: _ImageInfo
478
+ """
479
+ logger.info(f"creating image with name '{self.image_name}' in " f"region {ec2client.meta.region_name} ...")
480
+
481
+ register_image_kwargs = dict(
482
+ Name=self.image_name,
483
+ Description=self.conf.get("description", ""),
484
+ Architecture=self._ctx.conf["source"]["architecture"],
485
+ RootDeviceName=self.conf["root_device_name"],
486
+ BlockDeviceMappings=[
487
+ {
488
+ "Ebs": {
489
+ "SnapshotId": snapshot_id,
490
+ "VolumeType": self.conf["root_device_volume_type"],
491
+ "VolumeSize": self.conf["root_device_volume_size"],
492
+ },
493
+ "DeviceName": self.conf["root_device_name"],
494
+ },
495
+ # TODO: make those ephemeral block device mappings configurable
496
+ {"VirtualName": "ephemeral0", "DeviceName": "/dev/sdb"},
497
+ {"VirtualName": "ephemeral1", "DeviceName": "/dev/sdc"},
498
+ ],
499
+ EnaSupport=True,
500
+ SriovNetSupport="simple",
501
+ VirtualizationType="hvm",
502
+ BootMode=self.conf["boot_mode"],
503
+ )
504
+
505
+ if self.conf["tpm_support"]:
506
+ register_image_kwargs["TpmSupport"] = self.conf["tpm_support"]
507
+
508
+ if self.conf["imds_support"]:
509
+ register_image_kwargs["ImdsSupport"] = self.conf["imds_support"]
510
+
511
+ if self.conf["uefi_data"]:
512
+ with open(self.conf["uefi_data"], "r") as f:
513
+ uefi_data = f.read()
514
+ register_image_kwargs["UefiData"] = uefi_data
515
+
516
+ if self.conf["billing_products"]:
517
+ register_image_kwargs["BillingProducts"] = self.conf["billing_products"]
518
+
519
+ try:
520
+ resp = ec2client.register_image(**register_image_kwargs)
521
+ except botocore.exceptions.ClientError as e:
522
+ if e.response.get("Error", {}).get("Code", None) == "OperationNotPermitted":
523
+ logger.exception(
524
+ "Unable to register image",
525
+ extra={
526
+ "registration_options": register_image_kwargs,
527
+ "region": ec2client.meta.region_name,
528
+ },
529
+ )
530
+ return None
531
+ raise e
532
+
533
+ ec2client.create_tags(Resources=[resp["ImageId"]], Tags=self._tags)
534
+ return _ImageInfo(resp["ImageId"], snapshot_id)
535
+
536
+ def publish(self) -> None:
537
+ """
538
+ Handle all publication steps
539
+ - make image and underlying root device snapshot public if the public flag is set
540
+ - request a new marketplace version for the image in us-east-1 if the marketplace config is present
541
+ Note: if the temporary flag is set in the image, this method will do nothing
542
+ Note: this command doesn't unpublish anything!
543
+ """
544
+ # never publish temporary images
545
+ if self.conf["temporary"]:
546
+ logger.warning(f"image {self.image_name} marked as temporary. do not publish")
547
+ return
548
+
549
+ # make snapshot and image public if requested in the image
550
+ if self.conf["public"]:
551
+ self._public()
552
+ else:
553
+ logger.info(f"image {self.image_name} not marked as public. do not publish")
554
+
555
+ # handle SSM parameter store
556
+ if self.conf["ssm_parameter"]:
557
+ self._put_ssm_parameters()
558
+
559
+ # handle marketplace publication
560
+ if self.conf["marketplace"]:
561
+ # the "marketplace" configuration is only valid in the "aws" partition
562
+ partition = boto3.client("ec2").meta.partition
563
+ if partition == "aws":
564
+ logger.info(f"marketplace version request for {self.image_name}")
565
+ # image needs to be in us-east-1
566
+ ec2client: EC2Client = boto3.client("ec2", region_name="us-east-1")
567
+ image_info: Optional[_ImageInfo] = self._get(ec2client)
568
+ if image_info:
569
+ im = ImageMarketplace(self._ctx, self.image_name)
570
+ im.request_new_version(image_info.image_id)
571
+ else:
572
+ logger.error(
573
+ f"can not request marketplace version for {self.image_name} because no image found in us-east-1"
574
+ )
575
+ else:
576
+ logger.info(
577
+ f"found marketplace config for {self.image_name} and partition 'aws' but "
578
+ f"currently using partition {partition}. Ignoring marketplace config."
579
+ )
580
+
581
+ # send ssn notification
582
+ if self.conf["sns"]:
583
+ self._sns_publish()
584
+
585
+ def _verify(self, region: str) -> List[ImageVerificationErrors]:
586
+ """
587
+ Verify (but don't modify or create anything) the image in a single region
588
+ """
589
+ problems: List[ImageVerificationErrors] = []
590
+ ec2client_region: EC2Client = boto3.client("ec2", region_name=region)
591
+ image_info: Optional[_ImageInfo] = self._get(ec2client_region)
592
+
593
+ if not image_info:
594
+ problems.append(ImageVerificationErrors.NOT_EXIST)
595
+ return problems
596
+
597
+ image_aws = ec2client_region.describe_images(ImageIds=[image_info.image_id])["Images"][0]
598
+
599
+ # verify state
600
+ if image_aws["State"] != "available":
601
+ problems.append(ImageVerificationErrors.STATE_NOT_AVAILABLE)
602
+
603
+ # verify RootDeviceType
604
+ if image_aws["RootDeviceType"] != "ebs":
605
+ problems.append(ImageVerificationErrors.ROOT_DEVICE_TYPE)
606
+
607
+ # verify BootMode
608
+ if image_aws["BootMode"] != self.conf["boot_mode"]:
609
+ problems.append(ImageVerificationErrors.BOOT_MODE)
610
+
611
+ # verify RootDeviceVolumeType, RootDeviceVolumeSize and Snapshot
612
+ for bdm in image_aws["BlockDeviceMappings"]:
613
+ if bdm.get("DeviceName") and bdm["DeviceName"] == image_aws["RootDeviceName"]:
614
+ # here's the root device
615
+ if bdm["Ebs"]["VolumeType"] != self.conf["root_device_volume_type"]:
616
+ problems.append(ImageVerificationErrors.ROOT_DEVICE_VOLUME_TYPE)
617
+ if bdm["Ebs"]["VolumeSize"] != self.conf["root_device_volume_size"]:
618
+ problems.append(ImageVerificationErrors.ROOT_DEVICE_VOLUME_SIZE)
619
+
620
+ # verify snapshot
621
+ snapshot_aws = ec2client_region.describe_snapshots(SnapshotIds=[bdm["Ebs"]["SnapshotId"]])["Snapshots"][
622
+ 0
623
+ ]
624
+ if snapshot_aws["State"] != "completed":
625
+ problems.append(ImageVerificationErrors.ROOT_DEVICE_SNAPSHOT_NOT_COMPLETE)
626
+
627
+ # verify tpm support
628
+ if self.conf["tpm_support"] and image_aws.get("TpmSupport") != self.conf["tpm_support"]:
629
+ problems.append(ImageVerificationErrors.TPM_SUPPORT)
630
+
631
+ # verify imds support
632
+ if self.conf["imds_support"] and image_aws.get("ImdsSupport") != self.conf["imds_support"]:
633
+ problems.append(ImageVerificationErrors.IMDS_SUPPORT)
634
+
635
+ # billing products
636
+ if self.conf["billing_products"] and image_aws.get("BillingProducts") != self.conf["billing_products"]:
637
+ problems.append(ImageVerificationErrors.BILLING_PRODUCTS)
638
+
639
+ # verify tags
640
+ for tag in image_aws["Tags"]:
641
+ if tag["Key"] == "Name" and tag["Value"] != self.snapshot_name:
642
+ problems.append(ImageVerificationErrors.TAGS)
643
+
644
+ return problems
645
+
646
+ def verify(self) -> Dict[str, List[ImageVerificationErrors]]:
647
+ """
648
+ Verify (but don't modify or create anything) that the image configuration
649
+ matches what is on AWS
650
+ """
651
+ logger.info(f"Verifying image {self.image_name} ...")
652
+ problems: Dict[str, List[ImageVerificationErrors]] = dict()
653
+ for region in self.image_regions:
654
+ problems[region] = self._verify(region)
655
+
656
+ return problems