Skip to content

Commit 4dfa2b1

Browse files
authored
Merge pull request #77 from rpocase/20250109-attempt-all-regions
feat: allow partial image registration
2 parents 7355655 + 0f61407 commit 4dfa2b1

File tree

3 files changed

+143
-48
lines changed

3 files changed

+143
-48
lines changed

awspub/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ class MultipleImagesException(Exception):
1010
pass
1111

1212

13+
class IncompleteImageSetException(Exception):
14+
pass
15+
16+
1317
class BucketDoesNotExistException(Exception):
1418
def __init__(self, bucket_name: str, *args, **kwargs):
1519
msg = f"The bucket named '{bucket_name}' does not exist. You will need to create the bucket before proceeding."

awspub/image.py

Lines changed: 78 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any, Dict, List, Optional
66

77
import boto3
8+
import botocore.exceptions
89
from mypy_boto3_ec2.client import EC2Client
910
from mypy_boto3_ssm import SSMClient
1011

@@ -418,6 +419,7 @@ def create(self) -> Dict[str, _ImageInfo]:
418419
)
419420

420421
images: Dict[str, _ImageInfo] = dict()
422+
missing_regions: List[str] = []
421423
for region in self.image_regions:
422424
ec2client_region: EC2Client = boto3.client("ec2", region_name=region)
423425
image_info: Optional[_ImageInfo] = self._get(ec2client_region)
@@ -435,53 +437,10 @@ def create(self) -> Dict[str, _ImageInfo]:
435437
)
436438
images[region] = image_info
437439
else:
438-
logger.info(
439-
f"creating image with name '{self.image_name}' in "
440-
f"region {ec2client_region.meta.region_name} ..."
441-
)
442-
443-
register_image_kwargs = dict(
444-
Name=self.image_name,
445-
Description=self.conf.get("description", ""),
446-
Architecture=self._ctx.conf["source"]["architecture"],
447-
RootDeviceName=self.conf["root_device_name"],
448-
BlockDeviceMappings=[
449-
{
450-
"Ebs": {
451-
"SnapshotId": snapshot_ids[region],
452-
"VolumeType": self.conf["root_device_volume_type"],
453-
"VolumeSize": self.conf["root_device_volume_size"],
454-
},
455-
"DeviceName": self.conf["root_device_name"],
456-
},
457-
# TODO: make those ephemeral block device mappings configurable
458-
{"VirtualName": "ephemeral0", "DeviceName": "/dev/sdb"},
459-
{"VirtualName": "ephemeral1", "DeviceName": "/dev/sdc"},
460-
],
461-
EnaSupport=True,
462-
SriovNetSupport="simple",
463-
VirtualizationType="hvm",
464-
BootMode=self.conf["boot_mode"],
465-
)
466-
467-
if self.conf["tpm_support"]:
468-
register_image_kwargs["TpmSupport"] = self.conf["tpm_support"]
469-
470-
if self.conf["imds_support"]:
471-
register_image_kwargs["ImdsSupport"] = self.conf["imds_support"]
472-
473-
if self.conf["uefi_data"]:
474-
with open(self.conf["uefi_data"], "r") as f:
475-
uefi_data = f.read()
476-
register_image_kwargs["UefiData"] = uefi_data
477-
478-
if self.conf["billing_products"]:
479-
register_image_kwargs["BillingProducts"] = self.conf["billing_products"]
480-
481-
resp = ec2client_region.register_image(**register_image_kwargs)
482-
ec2client_region.create_tags(Resources=[resp["ImageId"]], Tags=self._tags)
483-
images[region] = _ImageInfo(resp["ImageId"], snapshot_ids[region])
484-
440+
if image := self._register_image(snapshot_ids[region], ec2client_region):
441+
images[region] = image
442+
else:
443+
missing_regions.append(region)
485444
# wait for the images
486445
logger.info(f"Waiting for {len(images)} images to be ready the regions ...")
487446
for region, image_info in images.items():
@@ -500,8 +459,80 @@ def create(self) -> Dict[str, _ImageInfo]:
500459
if self.conf["share"]:
501460
self._share(self.conf["share"], images)
502461

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+
503466
return images
504467

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+
505536
def publish(self) -> None:
506537
"""
507538
Handle all publication steps

awspub/tests/test_image.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pathlib
2-
from unittest.mock import patch
2+
from unittest.mock import MagicMock, patch
33

4+
import botocore.exceptions
45
import pytest
56

67
from awspub import context, exceptions, image
@@ -494,3 +495,62 @@ def test_image__share_list_filtered(partition, imagename, share_list_expected):
494495
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
495496
img = image.Image(ctx, imagename)
496497
assert img._share_list_filtered(img.conf["share"]) == share_list_expected
498+
499+
500+
@patch("awspub.s3.S3.bucket_region", return_value="region1")
501+
def test_create__should_allow_partial_registration(s3_bucket_mock):
502+
"""
503+
Test that the create() method allows a partial upload set
504+
"""
505+
with patch("boto3.client") as bclient_mock:
506+
instance = bclient_mock.return_value
507+
508+
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
509+
img = image.Image(ctx, "test-image-6")
510+
img._image_regions = ["region1", "region2"]
511+
img._image_regions_cached = True
512+
with patch.object(img, "_get") as get_mock, patch.object(img._snapshot, "copy") as copy_mock:
513+
copy_mock.return_value = {r: f"snapshot{i}" for i, r in enumerate(img.image_regions)}
514+
get_mock.return_value = None
515+
instance.register_image.side_effect = [
516+
botocore.exceptions.ClientError(
517+
{
518+
"Error": {
519+
"Code": "OperationNotPermitted",
520+
"Message": "Intentional permission failure for snapshot0",
521+
}
522+
},
523+
"awspub Testing",
524+
),
525+
{"ImageId": "id1"},
526+
]
527+
with pytest.raises(exceptions.IncompleteImageSetException):
528+
img.create() == {"region2": image._ImageInfo("id1", "snapshot1")}
529+
# register and create_tags should be called since at least one snapshot made it
530+
assert instance.register_image.called
531+
assert instance.create_tags.called
532+
533+
534+
def test_register_image__should_return_none_on_permission_failures():
535+
instance = MagicMock()
536+
537+
instance.register_image.side_effect = botocore.exceptions.ClientError(
538+
{"Error": {"Code": "OperationNotPermitted", "Message": "Testing"}}, "Testing"
539+
)
540+
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
541+
img = image.Image(ctx, "test-image-6")
542+
snapshot_ids = {"eu-central-1": "my-snapshot"}
543+
assert img._register_image(snapshot_ids["eu-central-1"], instance) is None
544+
545+
546+
def test_register_image__should_raise_on_unhandled_client_error():
547+
instance = MagicMock()
548+
549+
instance.register_image.side_effect = botocore.exceptions.ClientError(
550+
{"Error": {"Code": "UnsupportedOperation", "Message": "Testing"}}, "Testing"
551+
)
552+
ctx = context.Context(curdir / "fixtures/config1.yaml", None)
553+
img = image.Image(ctx, "test-image-6")
554+
snapshot_ids = {"eu-central-1": "my-snapshot"}
555+
with pytest.raises(botocore.exceptions.ClientError):
556+
img._register_image(snapshot_ids["eu-central-1"], instance) is None

0 commit comments

Comments
 (0)