Repository: getmoto/moto

Issue:
terminate_instances() should fail when its DisableApiTermination = True 
# environment
platform darwin -- Python 3.8.11, pytest-6.2.4, moto 2.1.0, boto3 1.18.5

# test code
```python
@mock_ec2
def test_terminate_protection_4moto(aws_credentials):
    ec2_client = boto3.client("ec2")

    # create an instance
    image_response = ec2_client.describe_images()
    imageId = image_response["Images"][0]["ImageId"]
    instances = ec2_client.run_instances(ImageId=imageId, MinCount=1, MaxCount=1)["Instances"]
    id1 = instances[0]["InstanceId"]

    # disable termination
    ec2_client.modify_instance_attribute(
        InstanceId=id1, DisableApiTermination={"Value": True}
    )
    ec2_client.stop_instances(InstanceIds=[id1], Force=True)

    # before calling terminate instance
    res = ec2_client.describe_instance_status(
        Filters=[{"Name": "instance-state-name", "Values": ["terminated"]}],
        IncludeAllInstances=True,
    )
    assert len(res["InstanceStatuses"]) == 0

    ec2_client.terminate_instances(InstanceIds=[id1])
    print(ec2_client.describe_instances(InstanceIds=[id1]))

    # after calling terminate instance
    res = ec2_client.describe_instance_status(
        Filters=[{"Name": "instance-state-name", "Values": ["terminated"]}],
        IncludeAllInstances=True,
    )
    assert len(res["InstanceStatuses"]) == 0
```

# log
```log
❯ pytest -s -k 'test_terminate_protection_4moto'
=========================================================== test session starts ===========================================================
platform darwin -- Python 3.8.11, pytest-6.2.4, py-1.10.0, pluggy-1.0.0.dev0
rootdir: /Users/xxx/repos/xxx/tests
collecting ... Lambda begin ...
collected 7 items / 6 deselected / 1 selected

test_cleanup_ec2_exception.py {'Reservations': [{'Groups': [], 'Instances': [{'AmiLaunchIndex': 0, 'ImageId': 'ami-03cf127a', 'InstanceId': 'i-a129037498895595b', 'InstanceType': 'm1.small', 'KernelId': 'None', 'KeyName': 'None', 'LaunchTime': datetime.datetime(2021, 7, 28, 1, 12, 47, tzinfo=tzutc()), 'Monitoring': {'State': 'disabled'}, 'Placement': {'AvailabilityZone': 'ap-southeast-2a', 'GroupName': '', 'Tenancy': 'default'}, 'Platform': 'windows', 'PrivateDnsName': 'ip-10-112-33-75.ap-southeast-2.compute.internal', 'PrivateIpAddress': '10.112.33.75', 'ProductCodes': [], 'PublicDnsName': 'None', 'State': {'Code': 48, 'Name': 'terminated'}, 'StateTransitionReason': 'User initiated (2021-07-28 01:12:47 UTC)', 'SubnetId': 'subnet-7381bd3c', 'VpcId': 'vpc-0e504fe4', 'Architecture': 'x86_64', 'BlockDeviceMappings': [], 'ClientToken': 'ABCDE1234567890123', 'EbsOptimized': False, 'Hypervisor': 'xen', 'NetworkInterfaces': [{'Attachment': {'AttachTime': datetime.datetime(2015, 1, 1, 0, 0, tzinfo=tzutc()), 'AttachmentId': 'eni-attach-c917fb5d', 'DeleteOnTermination': True, 'DeviceIndex': 0, 'Status': 'attached'}, 'Description': 'Primary network interface', 'Groups': [], 'MacAddress': '1b:2b:3c:4d:5e:6f', 'NetworkInterfaceId': 'eni-80eb81c2', 'OwnerId': '123456789012', 'PrivateIpAddress': '10.112.33.75', 'PrivateIpAddresses': [{'Primary': True, 'PrivateIpAddress': '10.112.33.75'}], 'SourceDestCheck': True, 'Status': 'in-use', 'SubnetId': 'subnet-7381bd3c', 'VpcId': 'vpc-0e504fe4'}], 'RootDeviceName': '/dev/sda1', 'RootDeviceType': 'ebs', 'SecurityGroups': [], 'SourceDestCheck': True, 'StateReason': {'Code': 'Client.UserInitiatedShutdown', 'Message': 'Client.UserInitiatedShutdown: User initiated shutdown'}, 'VirtualizationType': 'hvm'}], 'OwnerId': '123456789012', 'ReservationId': 'r-94c22747'}], 'ResponseMetadata': {'RequestId': 'fdcdcab1-ae5c-489e-9c33-4637c5dda355', 'HTTPStatusCode': 200, 'HTTPHeaders': {'server': 'amazon.com'}, 'RetryAttempts': 0}}
F

================================================================ FAILURES =================================================================
_____________________________________________________ test_terminate_protection_4moto _____________________________________________________

aws_credentials = None

    @mock_ec2
    def test_terminate_protection_4moto(aws_credentials):
        ec2_client = boto3.client("ec2")

        # create an instance
        image_response = ec2_client.describe_images()
        imageId = image_response["Images"][0]["ImageId"]
        instances = ec2_client.run_instances(ImageId=imageId, MinCount=1, MaxCount=1)["Instances"]
        id1 = instances[0]["InstanceId"]

        # disable termination
        ec2_client.modify_instance_attribute(
            InstanceId=id1, DisableApiTermination={"Value": True}
        )
        ec2_client.stop_instances(InstanceIds=[id1], Force=True)

        # before calling terminate instance
        res = ec2_client.describe_instance_status(
            Filters=[{"Name": "instance-state-name", "Values": ["terminated"]}],
            IncludeAllInstances=True,
        )
        assert len(res["InstanceStatuses"]) == 0

        ec2_client.terminate_instances(InstanceIds=[id1])
        print(ec2_client.describe_instances(InstanceIds=[id1]))

        # after called terminate instance
        res = ec2_client.describe_instance_status(
            Filters=[{"Name": "instance-state-name", "Values": ["terminated"]}],
            IncludeAllInstances=True,
        )
>       assert len(res["InstanceStatuses"]) == 0
E       AssertionError: assert 1 == 0
E        +  where 1 = len([{'AvailabilityZone': 'ap-southeast-2a', 'InstanceId': 'i-a129037498895595b', 'InstanceState': {'Code': 48, 'Name': 'terminated'}, 'InstanceStatus': {'Status': 'not-applicable'}, ...}])

test_cleanup_ec2_exception.py:60: AssertionError
========================================================= short test summary info =========================================================
FAILED test_cleanup_ec2_exception.py::test_terminate_protection_4moto - AssertionError: assert 1 == 0
===================================================== 1 failed, 6 deselected in 1.65s =====================================================

```

Please let me know if I were wrong or more information you'd like to see. Thank you.


Discussion:
Hi @kentnsw, it doesn't look like that behaviour is supported yet - we store the value for `DisableApiTermination`, but we don't actually do anything with it.
Markign it as an enhancement.

Thanks for raising this!
Reopening, following a note from @kentnsw:
> 
> 
> One think I found that it's not working very well with the following code.
> 
> ```python
> # this cannot prevent the instance from being terminated
> ec2_client.modify_instance_attribute(InstanceId=id1, Attribute="disableApiTermination", Value="true")
> ```
> 
> However, the following is working.
> 
> ```python
> # this is working well. I can see the error raised when trying to terminate the instance
> ec2_client.modify_instance_attribute(InstanceId=id1, DisableApiTermination={"Value": True})
> ```

Moto should support both ways of marking an instance as protected.


Resolving patch:
diff --git a/moto/ec2/responses/instances.py b/moto/ec2/responses/instances.py
--- a/moto/ec2/responses/instances.py
+++ b/moto/ec2/responses/instances.py
@@ -177,6 +177,7 @@ def describe_instance_credit_specifications(self):
 
     def modify_instance_attribute(self):
         handlers = [
+            self._attribute_value_handler,
             self._dot_value_instance_attribute_handler,
             self._block_device_mapping_handler,
             self._security_grp_instance_attribute_handler,
@@ -258,6 +259,21 @@ def _dot_value_instance_attribute_handler(self):
             )
             return EC2_MODIFY_INSTANCE_ATTRIBUTE
 
+    def _attribute_value_handler(self):
+        attribute_key = self._get_param("Attribute")
+
+        if attribute_key is None:
+            return
+
+        if self.is_not_dryrun("ModifyInstanceAttribute"):
+            value = self._get_param("Value")
+            normalized_attribute = camelcase_to_underscores(attribute_key)
+            instance_id = self._get_param("InstanceId")
+            self.ec2_backend.modify_instance_attribute(
+                instance_id, normalized_attribute, value
+            )
+            return EC2_MODIFY_INSTANCE_ATTRIBUTE
+
     def _security_grp_instance_attribute_handler(self):
         new_security_grp_list = []
         for key, value in self.querystring.items():


Test patch:
diff --git a/tests/test_ec2/test_instances.py b/tests/test_ec2/test_instances.py
--- a/tests/test_ec2/test_instances.py
+++ b/tests/test_ec2/test_instances.py
@@ -1775,8 +1775,9 @@ def test_instance_termination_protection():
         r"The instance '{}' may not be terminated.*$".format(instance_id)
     )
 
+    # Use alternate request syntax for setting attribute.
     client.modify_instance_attribute(
-        InstanceId=instance_id, DisableApiTermination={"Value": False}
+        InstanceId=instance_id, Attribute="disableApiTermination", Value="false"
     )
     client.terminate_instances(InstanceIds=[instance_id])