Repository: getmoto/moto
Pull request: Add instance type validation on add instance method
Fix to https://github.com/spulec/moto/issues/5131

Problem statement:
EC2 Instance Type Validation

Steps To Reproduce

1. Launch an EC2 instance with an invalid instance type such as t3.nano.
`
client.run_instances(InstanceType="t3.nano", ImageId="ami-00000000", MaxCount=1, MinCount=1, )
`

2. Run Describe Instances and see that the instance is launched with an invalid instance type.
`
{'Reservations': [{'Groups': [], 'Instances': [{'AmiLaunchIndex': 0, 'ImageId': 'ami-00000000', 'InstanceId': 'i-b4955ca3971326510', 'InstanceType': 't3.nano', ..............., 'StateReason': {'Code': '', 'Message': ''}, 'Tags': [], 'VirtualizationType': 'paravirtual'}], 'OwnerId': '123456789012', 'ReservationId': 'r-3e442b18'}], 'ResponseMetadata': {'RequestId': 'fdcdcab1-ae5c-489e-9c33-4637c5dda355', 'HTTPStatusCode': 200, 'HTTPHeaders': {'Content-Type': 'text/plain', 'server': 'amazon.com'}, 'RetryAttempts': 0}}
`

Referring to  'InstanceType': 't3.nano'


3. An InvalidParameterValueError should occur.

`
botocore.exceptions.ClientError: An error occurred (InvalidParameterValue) when calling the RunInstances operation: Invalid value 't3.nano' for InstanceType.
`

4. Only reproduced on python mocks so far, but could likely be reproduced on standalone server




No instance type validation on run instance
**Repro steps:**
    1. Start moto server.
    2. Prepare terraform file that will contain invalid instance type
    3. Run terraform init; terraform apply

**Description:**
When attempting to create an instance of inexistent instance type, moto doesn't verify that and creates an instance

**Observed results:**
It is possible to create an instance of nonexistent type

**expected results:**
Informative error with status code 400 - matching true AWS behaviour when attempting to create Instance with an inexistent instance type

Hints from issue discussion:
fix for #1695
Thanks for opening; setting this as a feature request for instance type validation.
@spulec Thanks! I have opened PR on this here #1696 
PR with proposed solution https://github.com/spulec/moto/pull/5132

Commit message: Merge branch 'master' into Add-instance-type-validation-on-add-instance-method

Patch to review:
diff --git a/moto/autoscaling/models.py b/moto/autoscaling/models.py
index a252fc531374..296adb4271e8 100644
--- a/moto/autoscaling/models.py
+++ b/moto/autoscaling/models.py
@@ -633,6 +633,7 @@ def replace_autoscaling_group_instances(self, count_needed, propagated_tags):
             tags={"instance": propagated_tags},
             placement=random.choice(self.availability_zones),
             launch_config=self.launch_config,
+            is_instance_type_default=False,
         )
         for instance in reservation.instances:
             instance.autoscaling_group = self
diff --git a/moto/batch/models.py b/moto/batch/models.py
index 37d72e5ebc9f..2123ba0a9d67 100644
--- a/moto/batch/models.py
+++ b/moto/batch/models.py
@@ -1110,6 +1110,7 @@ def create_compute_environment(
                     subnet_id=next(subnet_cycle),
                     key_name=compute_resources.get("ec2KeyPair", "AWS_OWNED"),
                     security_group_ids=compute_resources["securityGroupIds"],
+                    is_instance_type_default=False,
                 )
 
                 new_comp_env.add_instance(reservation.instances[0])
diff --git a/moto/ec2/models/instance_types.py b/moto/ec2/models/instance_types.py
index 85577883b2c4..5e2f7846d2b6 100644
--- a/moto/ec2/models/instance_types.py
+++ b/moto/ec2/models/instance_types.py
@@ -45,24 +45,25 @@ def describe_instance_type_offerings(self, location_type=None, filters=None):
         location_type = location_type or "region"
         matches = INSTANCE_TYPE_OFFERINGS[location_type]
         matches = matches.get(self.region_name, [])
+        matches = [
+            o for o in matches if self.matches_filters(o, filters or {}, location_type)
+        ]
+        return matches
 
-        def matches_filters(offering, filters):
-            def matches_filter(key, values):
-                if key == "location":
-                    if location_type in ("availability-zone", "availability-zone-id"):
-                        return offering.get("Location") in values
-                    elif location_type == "region":
-                        return any(
-                            v for v in values if offering.get("Location").startswith(v)
-                        )
-                    else:
-                        return False
-                elif key == "instance-type":
-                    return offering.get("InstanceType") in values
+    def matches_filters(self, offering, filters, location_type):
+        def matches_filter(key, values):
+            if key == "location":
+                if location_type in ("availability-zone", "availability-zone-id"):
+                    return offering.get("Location") in values
+                elif location_type == "region":
+                    return any(
+                        v for v in values if offering.get("Location").startswith(v)
+                    )
                 else:
                     return False
+            elif key == "instance-type":
+                return offering.get("InstanceType") in values
+            else:
+                return False
 
-            return all([matches_filter(key, values) for key, values in filters.items()])
-
-        matches = [o for o in matches if matches_filters(o, filters or {})]
-        return matches
+        return all([matches_filter(key, values) for key, values in filters.items()])
diff --git a/moto/ec2/models/instances.py b/moto/ec2/models/instances.py
index f7c93cbf1395..6dc121500a52 100644
--- a/moto/ec2/models/instances.py
+++ b/moto/ec2/models/instances.py
@@ -2,11 +2,15 @@
 import warnings
 from collections import OrderedDict
 from datetime import datetime
+from moto import settings
 
 from moto.core import get_account_id
 from moto.core import CloudFormationModel
 from moto.core.utils import camelcase_to_underscores
-from moto.ec2.models.instance_types import INSTANCE_TYPE_OFFERINGS
+from moto.ec2.models.instance_types import (
+    INSTANCE_TYPE_OFFERINGS,
+    InstanceTypeOfferingBackend,
+)
 from moto.packages.boto.ec2.blockdevicemapping import BlockDeviceMapping
 from moto.packages.boto.ec2.instance import Instance as BotoInstance
 from moto.packages.boto.ec2.instance import Reservation
@@ -15,6 +19,7 @@
     AvailabilityZoneNotFromRegionError,
     EC2ClientError,
     InvalidInstanceIdError,
+    InvalidInstanceTypeError,
     InvalidParameterValueErrorUnknownAttribute,
     OperationNotPermitted4,
 )
@@ -275,6 +280,7 @@ def create_from_cloudformation_json(
             count=1,
             security_group_names=group_names,
             instance_type=properties.get("InstanceType", "m1.small"),
+            is_instance_type_default=not properties.get("InstanceType"),
             subnet_id=properties.get("SubnetId"),
             key_name=properties.get("KeyName"),
             private_ip=properties.get("PrivateIpAddress"),
@@ -543,6 +549,7 @@ def get_instance(self, instance_id):
 
     def add_instances(self, image_id, count, user_data, security_group_names, **kwargs):
         location_type = "availability-zone" if kwargs.get("placement") else "region"
+        default_region = "us-east-1"
         valid_instance_types = INSTANCE_TYPE_OFFERINGS[location_type]
         if "region_name" in kwargs and kwargs.get("placement"):
             valid_availability_zones = {
@@ -551,6 +558,24 @@ def add_instances(self, image_id, count, user_data, security_group_names, **kwar
             }
             if kwargs["placement"] not in valid_availability_zones:
                 raise AvailabilityZoneNotFromRegionError(kwargs["placement"])
+        match_filters = InstanceTypeOfferingBackend().matches_filters
+        if not kwargs["is_instance_type_default"] and not any(
+            {
+                match_filters(
+                    valid_instance,
+                    {"instance-type": kwargs["instance_type"]},
+                    location_type,
+                )
+                for valid_instance in valid_instance_types.get(
+                    kwargs["region_name"]
+                    if "region_name" in kwargs
+                    else default_region,
+                    {},
+                )
+            },
+        ):
+            if settings.ec2_enable_instance_type_validation():
+                raise InvalidInstanceTypeError(kwargs["instance_type"])
         new_reservation = Reservation()
         new_reservation.id = random_reservation_id()
 
diff --git a/moto/ec2/models/spot_requests.py b/moto/ec2/models/spot_requests.py
index 935518e92a34..765fda528d17 100644
--- a/moto/ec2/models/spot_requests.py
+++ b/moto/ec2/models/spot_requests.py
@@ -104,6 +104,7 @@ def launch_instance(self):
             count=1,
             user_data=self.user_data,
             instance_type=self.launch_specification.instance_type,
+            is_instance_type_default=not self.launch_specification.instance_type,
             subnet_id=self.launch_specification.subnet_id,
             key_name=self.launch_specification.key_name,
             security_group_names=[],
diff --git a/moto/ec2/responses/instances.py b/moto/ec2/responses/instances.py
index e3587d3fa421..7f3a3d2cb544 100644
--- a/moto/ec2/responses/instances.py
+++ b/moto/ec2/responses/instances.py
@@ -49,6 +49,7 @@ def run_instances(self):
         security_group_names = self._get_multi_param("SecurityGroup")
         kwargs = {
             "instance_type": self._get_param("InstanceType", if_none="m1.small"),
+            "is_instance_type_default": not self._get_param("InstanceType"),
             "placement": self._get_param("Placement.AvailabilityZone"),
             "region_name": self.region,
             "subnet_id": self._get_param("SubnetId"),
diff --git a/moto/emr/models.py b/moto/emr/models.py
index 1fcdfa12f986..34bce3fb6cee 100644
--- a/moto/emr/models.py
+++ b/moto/emr/models.py
@@ -435,6 +435,7 @@ def add_instance_groups(self, cluster_id, instance_groups):
 
     def add_instances(self, cluster_id, instances, instance_group):
         cluster = self.clusters[cluster_id]
+        instances["is_instance_type_default"] = not instances.get("instance_type")
         response = self.ec2_backend.add_instances(
             EXAMPLE_AMI_ID, instances["instance_count"], "", [], **instances
         )
diff --git a/moto/opsworks/models.py b/moto/opsworks/models.py
index 271f4f79fba1..8ded0d766a5c 100644
--- a/moto/opsworks/models.py
+++ b/moto/opsworks/models.py
@@ -101,6 +101,7 @@ def start(self):
                 security_group_names=[],
                 security_group_ids=self.security_group_ids,
                 instance_type=self.instance_type,
+                is_instance_type_default=not self.instance_type,
                 key_name=self.ssh_keyname,
                 ebs_optimized=self.ebs_optimized,
                 subnet_id=self.subnet_id,
diff --git a/moto/settings.py b/moto/settings.py
index 3481ffd02a33..5197c7353c0a 100644
--- a/moto/settings.py
+++ b/moto/settings.py
@@ -18,6 +18,8 @@
 # How many seconds to wait before we "validate" a new certificate in ACM.
 ACM_VALIDATION_WAIT = int(os.environ.get("MOTO_ACM_VALIDATION_WAIT", "60"))
 
+ec2_enable_instance_type_validation = False
+
 
 def get_sf_execution_history_type():
     """
diff --git a/tests/test_ec2/test_instances.py b/tests/test_ec2/test_instances.py
index a2f1e0ab202b..91b76b673a40 100644
--- a/tests/test_ec2/test_instances.py
+++ b/tests/test_ec2/test_instances.py
@@ -1162,6 +1162,27 @@ def test_run_instance_with_placement():
     instance.placement.should.have.key("AvailabilityZone").equal("us-east-1b")
 
 
+@mock_ec2
+def test_run_instance_with_invalid_instance_type(mocker):
+    ec2 = boto3.resource("ec2", region_name="us-east-1")
+    mocker.patch(
+        "moto.ec2.models.instances.settings.ec2_enable_instance_type_validation",
+        return_value=True,
+    )
+    with pytest.raises(ClientError) as ex:
+        ec2.create_instances(
+            ImageId=EXAMPLE_AMI_ID,
+            InstanceType="invalid_type",
+            MinCount=1,
+            MaxCount=1,
+            Placement={"AvailabilityZone": "us-east-1b"},
+        )
+    ex.value.response["ResponseMetadata"]["HTTPStatusCode"].should.equal(400)
+    ex.value.response["Error"]["Message"].should.equal(
+        "The instance type 'invalid_type' does not exist"
+    )
+
+
 @mock_ec2
 def test_run_instance_with_availability_zone_not_from_region():
     ec2 = boto3.resource("ec2", region_name="us-east-1")
diff --git a/tests/test_emr/test_emr_boto3.py b/tests/test_emr/test_emr_boto3.py
index 62be08abe8e0..5e1014ef1e88 100644
--- a/tests/test_emr/test_emr_boto3.py
+++ b/tests/test_emr/test_emr_boto3.py
@@ -48,7 +48,7 @@
     {
         "InstanceCount": 6,
         "InstanceRole": "TASK",
-        "InstanceType": "c1.large",
+        "InstanceType": "c3.large",
         "Market": "SPOT",
         "Name": "task-1",
         "BidPrice": "0.07",


Code review comments:
diff_hunk: @@ -551,6 +558,24 @@ def add_instances(self, image_id, count, user_data, security_group_names, **kwar
             }
             if kwargs["placement"] not in valid_availability_zones:
                 raise AvailabilityZoneNotFromRegionError(kwargs["placement"])
+        match_filters = InstanceTypeOfferingBackend().matches_filters
+        if not kwargs["is_instance_type_default"] and not any(
+            {
+                match_filters(
+                    valid_instance,
+                    {"instance-type": kwargs["instance_type"]},
+                    location_type,
+                )
+                for valid_instance in valid_instance_types.get(
+                    kwargs["region_name"]
+                    if "region_name" in kwargs
+                    else default_region,
+                    {},
+                )
+            },
+        ):
+            if settings.ec2_enable_instance_type_validation():
path: moto/ec2/models/instances.py
text: @user1:
It is not a function.

diff_hunk: @@ -18,6 +18,8 @@
 # How many seconds to wait before we "validate" a new certificate in ACM.
 ACM_VALIDATION_WAIT = int(os.environ.get("MOTO_ACM_VALIDATION_WAIT", "60"))
 
+ec2_enable_instance_type_validation = False
path: moto/settings.py
text: @user1:
You need to read the environment variable like in the examples above. Please also note all others are uppercased.