Skip to content

Commit 0f61407

Browse files
committed
feat: allow partial image registration
this changes the `awspub create` path to allow for partial snapshot registration when OperationNotPermitted errors occur. in the event of an OperationNotPermitted ClientError all remaining regions will be attempted.
1 parent a0bd8fb commit 0f61407

File tree

3 files changed

+90
-5
lines changed

3 files changed

+90
-5
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: 25 additions & 4 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,8 +437,10 @@ def create(self) -> Dict[str, _ImageInfo]:
435437
)
436438
images[region] = image_info
437439
else:
438-
images[region] = self._register_image(snapshot_ids[region], region, ec2client_region)
439-
440+
if image := self._register_image(snapshot_ids[region], ec2client_region):
441+
images[region] = image
442+
else:
443+
missing_regions.append(region)
440444
# wait for the images
441445
logger.info(f"Waiting for {len(images)} images to be ready the regions ...")
442446
for region, image_info in images.items():
@@ -455,9 +459,13 @@ def create(self) -> Dict[str, _ImageInfo]:
455459
if self.conf["share"]:
456460
self._share(self.conf["share"], images)
457461

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+
458466
return images
459467

460-
def _register_image(self, snapshot_id: str, ec2client: EC2Client) -> _ImageInfo:
468+
def _register_image(self, snapshot_id: str, ec2client: EC2Client) -> Optional[_ImageInfo]:
461469
"""
462470
Register snapshot_id in region configured for ec2client_region
463471
@@ -508,7 +516,20 @@ def _register_image(self, snapshot_id: str, ec2client: EC2Client) -> _ImageInfo:
508516
if self.conf["billing_products"]:
509517
register_image_kwargs["BillingProducts"] = self.conf["billing_products"]
510518

511-
resp = ec2client.register_image(**register_image_kwargs)
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+
512533
ec2client.create_tags(Resources=[resp["ImageId"]], Tags=self._tags)
513534
return _ImageInfo(resp["ImageId"], snapshot_id)
514535

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)