From 37fa5f8bf4fe4095c843f35a4bbf1df912b8262e Mon Sep 17 00:00:00 2001 From: Viren Nadkarni Date: Wed, 24 Aug 2022 14:53:21 +0530 Subject: [PATCH] EC2: implement ModifyVolumes and DescribeVolumesModifications (#5399) --- IMPLEMENTATION_COVERAGE.md | 6 +-- moto/ec2/models/elastic_block_store.py | 54 ++++++++++++++++++++ moto/ec2/responses/elastic_block_store.py | 58 ++++++++++++++++++++++ tests/test_ec2/test_elastic_block_store.py | 36 ++++++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 60e9bb278..e19c4f95a 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -1856,7 +1856,7 @@ - [ ] describe_volume_attribute - [ ] describe_volume_status - [X] describe_volumes -- [ ] describe_volumes_modifications +- [X] describe_volumes_modifications - [X] describe_vpc_attribute - [ ] describe_vpc_classic_link - [ ] describe_vpc_classic_link_dns_support @@ -1998,7 +1998,7 @@ - [X] modify_transit_gateway - [ ] modify_transit_gateway_prefix_list_reference - [X] modify_transit_gateway_vpc_attachment -- [ ] modify_volume +- [X] modify_volume - [ ] modify_volume_attribute - [X] modify_vpc_attribute - [ ] modify_vpc_endpoint @@ -6418,4 +6418,4 @@ - workspaces - workspaces-web - xray - \ No newline at end of file + diff --git a/moto/ec2/models/elastic_block_store.py b/moto/ec2/models/elastic_block_store.py index c45702f10..44833edf1 100644 --- a/moto/ec2/models/elastic_block_store.py +++ b/moto/ec2/models/elastic_block_store.py @@ -9,6 +9,7 @@ from ..exceptions import ( InvalidVolumeAttachmentError, InvalidVolumeDetachmentError, InvalidParameterDependency, + InvalidParameterValueError, ) from .core import TaggedEC2Resource from ..utils import ( @@ -19,6 +20,35 @@ from ..utils import ( ) +class VolumeModification(object): + def __init__(self, volume, target_size=None, target_volume_type=None): + if not any([target_size, target_volume_type]): + raise InvalidParameterValueError( + "Invalid input: Must specify at least one of size or type" + ) + + self.volume = volume + self.original_size = volume.size + self.original_volume_type = volume.volume_type + self.target_size = target_size or volume.size + self.target_volume_type = target_volume_type or volume.volume_type + + self.start_time = utc_date_and_time() + self.end_time = utc_date_and_time() + + def get_filter_value(self, filter_name): + if filter_name == "original-size": + return self.original_size + elif filter_name == "original-volume-type": + return self.original_volume_type + elif filter_name == "target-size": + return self.target_size + elif filter_name == "target-volume-type": + return self.target_volume_type + elif filter_name == "volume-id": + return self.volume.id + + class VolumeAttachment(CloudFormationModel): def __init__(self, volume, instance, device, status): self.volume = volume @@ -78,6 +108,16 @@ class Volume(TaggedEC2Resource, CloudFormationModel): self.ec2_backend = ec2_backend self.encrypted = encrypted self.kms_key_id = kms_key_id + self.modifications = [] + + def modify(self, target_size=None, target_volume_type=None): + modification = VolumeModification( + volume=self, target_size=target_size, target_volume_type=target_volume_type + ) + self.modifications.append(modification) + + self.size = modification.target_size + self.volume_type = modification.target_volume_type @staticmethod def cloudformation_name_type(): @@ -237,6 +277,20 @@ class EBSBackend: matches = generic_filter(filters, matches) return matches + def modify_volume(self, volume_id, target_size=None, target_volume_type=None): + volume = self.get_volume(volume_id) + volume.modify(target_size=target_size, target_volume_type=target_volume_type) + return volume + + def describe_volumes_modifications(self, volume_ids=None, filters=None): + volumes = self.describe_volumes(volume_ids) + modifications = [] + for volume in volumes: + modifications.extend(volume.modifications) + if filters: + modifications = generic_filter(filters, modifications) + return modifications + def get_volume(self, volume_id): volume = self.volumes.get(volume_id, None) if not volume: diff --git a/moto/ec2/responses/elastic_block_store.py b/moto/ec2/responses/elastic_block_store.py index 5a88a2a85..d99b8eed0 100644 --- a/moto/ec2/responses/elastic_block_store.py +++ b/moto/ec2/responses/elastic_block_store.py @@ -74,6 +74,27 @@ class ElasticBlockStore(EC2BaseResponse): template = self.response_template(CREATE_VOLUME_RESPONSE) return template.render(volume=volume) + def modify_volume(self): + volume_id = self._get_param("VolumeId") + target_size = self._get_param("Size") + target_volume_type = self._get_param("VolumeType") + + if self.is_not_dryrun("ModifyVolume"): + volume = self.ec2_backend.modify_volume( + volume_id, target_size, target_volume_type + ) + template = self.response_template(MODIFY_VOLUME_RESPONSE) + return template.render(volume=volume) + + def describe_volumes_modifications(self): + filters = self._filters_from_querystring() + volume_ids = self._get_multi_param("VolumeId") + modifications = self.ec2_backend.describe_volumes_modifications( + volume_ids=volume_ids, filters=filters + ) + template = self.response_template(DESCRIBE_VOLUMES_MODIFICATIONS_RESPONSE) + return template.render(modifications=modifications) + def delete_snapshot(self): snapshot_id = self._get_param("SnapshotId") if self.is_not_dryrun("DeleteSnapshot"): @@ -402,3 +423,40 @@ MODIFY_SNAPSHOT_ATTRIBUTE_RESPONSE = """ true """ + +MODIFY_VOLUME_RESPONSE = """ + + 59dbff89-35bd-4eac-99ed-be587EXAMPLE + + {% set volume_modification = volume.modifications[-1] %} + modifying + {{ volume_modification.original_size }} + {{ volume_modification.original_volume_type }} + 0 + {{ volume_modification.start_time }} + {{ volume_modification.target_size }} + {{ volume_modification.target_volume_type }} + {{ volume.id }} + +""" + +DESCRIBE_VOLUMES_MODIFICATIONS_RESPONSE = """ + + + 59dbff89-35bd-4eac-99ed-be587EXAMPLE + + {% for modification in modifications %} + + {{ modification.end_time }} + completed + {{ modification.original_size }} + {{ modification.original_volume_type }} + 100 + {{ modification.start_time }} + {{ modification.target_size }} + {{ modification.target_volume_type }} + {{ modification.volume.id }} + + {% endfor %} + +""" diff --git a/tests/test_ec2/test_elastic_block_store.py b/tests/test_ec2/test_elastic_block_store.py index 9ce9d226b..e80d0bb62 100644 --- a/tests/test_ec2/test_elastic_block_store.py +++ b/tests/test_ec2/test_elastic_block_store.py @@ -46,6 +46,42 @@ def test_create_and_delete_volume(): ex.value.response["Error"]["Code"].should.equal("InvalidVolume.NotFound") +@mock_ec2 +def test_modify_volumes(): + client = boto3.client("ec2", region_name="us-east-1") + ec2 = boto3.resource("ec2", region_name="us-east-1") + + old_size = 80 + new_size = 160 + new_type = "io2" + + volume_id = ec2.create_volume(Size=old_size, AvailabilityZone="us-east-1a").id + + # Ensure no modification records exist + modifications = client.describe_volumes_modifications() + modifications["VolumesModifications"].should.have.length_of(0) + + # Ensure volume size can be modified + response = client.modify_volume(VolumeId=volume_id, Size=new_size) + response["VolumeModification"]["OriginalSize"].should.equal(old_size) + response["VolumeModification"]["TargetSize"].should.equal(new_size) + client.describe_volumes(VolumeIds=[volume_id])["Volumes"][0]["Size"].should.equal( + new_size + ) + + # Ensure volume type can be modified + response = client.modify_volume(VolumeId=volume_id, VolumeType=new_type) + response["VolumeModification"]["OriginalVolumeType"].should.equal("gp2") + response["VolumeModification"]["TargetVolumeType"].should.equal(new_type) + client.describe_volumes(VolumeIds=[volume_id])["Volumes"][0][ + "VolumeType" + ].should.equal(new_type) + + # Ensure volume modifications are tracked + modifications = client.describe_volumes_modifications() + modifications["VolumesModifications"].should.have.length_of(2) + + @mock_ec2 def test_delete_attached_volume(): client = boto3.client("ec2", region_name="us-east-1")