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!

Resolving patch:
diff --git a/moto/ec2/exceptions.py b/moto/ec2/exceptions.py
--- a/moto/ec2/exceptions.py
+++ b/moto/ec2/exceptions.py
@@ -585,6 +585,15 @@ def __init__(self, client_region, pcx_id, acceptor_region):
         )
 
 
+class OperationNotPermitted4(EC2ClientError):
+    def __init__(self, instance_id):
+        super(OperationNotPermitted4, self).__init__(
+            "OperationNotPermitted",
+            "The instance '{0}' may not be terminated. Modify its 'disableApiTermination' "
+            "instance attribute and try again.".format(instance_id),
+        )
+
+
 class InvalidLaunchTemplateNameError(EC2ClientError):
     def __init__(self):
         super(InvalidLaunchTemplateNameError, self).__init__(
diff --git a/moto/ec2/models.py b/moto/ec2/models.py
--- a/moto/ec2/models.py
+++ b/moto/ec2/models.py
@@ -103,6 +103,7 @@
     OperationNotPermitted,
     OperationNotPermitted2,
     OperationNotPermitted3,
+    OperationNotPermitted4,
     ResourceAlreadyAssociatedError,
     RulesPerSecurityGroupLimitExceededError,
     TagLimitExceeded,
@@ -1024,6 +1025,8 @@ def terminate_instances(self, instance_ids):
                 "InvalidParameterCombination", "No instances specified"
             )
         for instance in self.get_multi_instances_by_id(instance_ids):
+            if instance.disable_api_termination == "true":
+                raise OperationNotPermitted4(instance.id)
             instance.terminate()
             terminated_instances.append(instance)
 


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
@@ -1753,3 +1753,35 @@ def test_filter_wildcard_in_specified_tag_only():
     instances = [i for r in response["Reservations"] for i in r["Instances"]]
     instances.should.have.length_of(1)
     instances[0]["Tags"][0].should.have.key("Key").should.equal("Name")
+
+
+@mock_ec2
+def test_instance_termination_protection():
+    client = boto3.client("ec2", region_name="us-west-1")
+
+    resp = client.run_instances(ImageId=EXAMPLE_AMI_ID, MinCount=1, MaxCount=1)
+    instance_id = resp["Instances"][0]["InstanceId"]
+
+    client.modify_instance_attribute(
+        InstanceId=instance_id, DisableApiTermination={"Value": True}
+    )
+    client.stop_instances(InstanceIds=[instance_id], Force=True)
+
+    with pytest.raises(ClientError) as ex:
+        client.terminate_instances(InstanceIds=[instance_id])
+    error = ex.value.response["Error"]
+    error["Code"].should.equal("OperationNotPermitted")
+    ex.value.response["Error"]["Message"].should.match(
+        r"The instance '{}' may not be terminated.*$".format(instance_id)
+    )
+
+    client.modify_instance_attribute(
+        InstanceId=instance_id, DisableApiTermination={"Value": False}
+    )
+    client.terminate_instances(InstanceIds=[instance_id])
+
+    resp = client.describe_instances(InstanceIds=[instance_id])
+    instances = resp["Reservations"][0]["Instances"]
+    instances.should.have.length_of(1)
+    instance = instances[0]
+    instance["State"]["Name"].should.equal("terminated")