diff --git a/moto/ec2/exceptions.py b/moto/ec2/exceptions.py index 243c2eafa..8db419354 100644 --- a/moto/ec2/exceptions.py +++ b/moto/ec2/exceptions.py @@ -49,6 +49,11 @@ class InvalidDHCPOptionsIdError(EC2ClientError): ) +class InvalidRequest(EC2ClientError): + def __init__(self): + super().__init__("InvalidRequest", "The request received was invalid") + + class InvalidParameterCombination(EC2ClientError): def __init__(self, msg): super().__init__("InvalidParameterCombination", msg) diff --git a/moto/ec2/models.py b/moto/ec2/models.py index da2359b9c..cadf9a5eb 100644 --- a/moto/ec2/models.py +++ b/moto/ec2/models.py @@ -1155,14 +1155,15 @@ class InstanceBackend(object): "DeleteOnTermination", False ) kms_key_id = block_device["Ebs"].get("KmsKeyId") - new_instance.add_block_device( - volume_size, - device_name, - snapshot_id, - encrypted, - delete_on_termination, - kms_key_id, - ) + if block_device.get("NoDevice") != "": + new_instance.add_block_device( + volume_size, + device_name, + snapshot_id, + encrypted, + delete_on_termination, + kms_key_id, + ) else: new_instance.setup_defaults() if kwargs.get("instance_market_options"): diff --git a/moto/ec2/responses/instances.py b/moto/ec2/responses/instances.py index 475c14422..11313f29c 100644 --- a/moto/ec2/responses/instances.py +++ b/moto/ec2/responses/instances.py @@ -1,7 +1,11 @@ from moto.autoscaling import autoscaling_backends from moto.core.responses import BaseResponse from moto.core.utils import camelcase_to_underscores -from moto.ec2.exceptions import MissingParameterError, InvalidParameterCombination +from moto.ec2.exceptions import ( + MissingParameterError, + InvalidParameterCombination, + InvalidRequest, +) from moto.ec2.utils import ( filters_from_querystring, dict_from_querystring, @@ -320,6 +324,7 @@ class InstanceResponse(BaseResponse): device_mapping.get("ebs._encrypted", False) ) device_template["Ebs"]["KmsKeyId"] = device_mapping.get("ebs._kms_key_id") + device_template["NoDevice"] = device_mapping.get("no_device") mappings.append(device_template) return mappings @@ -327,6 +332,22 @@ class InstanceResponse(BaseResponse): @staticmethod def _validate_block_device_mapping(device_mapping): + from botocore import __version__ as botocore_version + + if "no_device" in device_mapping: + assert isinstance( + device_mapping["no_device"], str + ), "botocore {} isn't limiting NoDevice to str type anymore, it is type:{}".format( + botocore_version, type(device_mapping["no_device"]) + ) + if device_mapping["no_device"] == "": + # the only legit value it can have is empty string + # and none of the other checks here matter if NoDevice + # is being used + return + else: + raise InvalidRequest() + if not any(mapping for mapping in device_mapping if mapping.startswith("ebs.")): raise MissingParameterError("ebs") if ( @@ -349,6 +370,7 @@ class InstanceResponse(BaseResponse): BLOCK_DEVICE_MAPPING_TEMPLATE = { "VirtualName": None, "DeviceName": None, + "NoDevice": None, "Ebs": { "SnapshotId": None, "VolumeSize": None, diff --git a/tests/test_ec2/test_instances.py b/tests/test_ec2/test_instances.py index b108c4731..b05078e2d 100644 --- a/tests/test_ec2/test_instances.py +++ b/tests/test_ec2/test_instances.py @@ -1,4 +1,4 @@ -from botocore.exceptions import ClientError +from botocore.exceptions import ClientError, ParamValidationError import pytest from unittest import SkipTest @@ -174,6 +174,8 @@ def test_instance_terminate_keep_volumes_implicit(): for volume in instance.volumes.all(): instance_volume_ids.append(volume.volume_id) + instance_volume_ids.shouldnt.be.empty + instance.terminate() instance.wait_until_terminated() @@ -1530,6 +1532,48 @@ def test_run_instance_with_block_device_mappings_missing_ebs(): ) +@mock_ec2 +def test_run_instance_with_block_device_mappings_using_no_device(): + ec2_client = boto3.client("ec2", region_name="us-east-1") + + kwargs = { + "MinCount": 1, + "MaxCount": 1, + "ImageId": EXAMPLE_AMI_ID, + "KeyName": "the_key", + "InstanceType": "t1.micro", + "BlockDeviceMappings": [{"DeviceName": "/dev/sda2", "NoDevice": ""}], + } + resp = ec2_client.run_instances(**kwargs) + instance_id = resp["Instances"][0]["InstanceId"] + + instances = ec2_client.describe_instances(InstanceIds=[instance_id]) + # Assuming that /dev/sda2 is not the root device and that there is a /dev/sda1, boto would + # create an instance with one block device instead of two. However, moto's modeling of + # BlockDeviceMappings is simplified, so we will accept that moto creates an instance without + # block devices for now + # instances["Reservations"][0]["Instances"][0].shouldnt.have.key("BlockDeviceMappings") + + # moto gives the key with an empty list instead of not having it at all, that's also fine + instances["Reservations"][0]["Instances"][0]["BlockDeviceMappings"].should.be.empty + + # passing None with NoDevice should raise ParamValidationError + kwargs["BlockDeviceMappings"][0]["NoDevice"] = None + with pytest.raises(ParamValidationError) as ex: + ec2_client.run_instances(**kwargs) + + # passing a string other than "" with NoDevice should raise InvalidRequest + kwargs["BlockDeviceMappings"][0]["NoDevice"] = "yes" + with pytest.raises(ClientError) as ex: + ec2_client.run_instances(**kwargs) + + ex.value.response["Error"]["Code"].should.equal("InvalidRequest") + ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400) + ex.value.response["Error"]["Message"].should.equal( + "The request received was invalid" + ) + + @mock_ec2 def test_run_instance_with_block_device_mappings_missing_size(): ec2_client = boto3.client("ec2", region_name="us-east-1")