Repository: getmoto/moto (python)

Issue:
EC2 instance with predefined IP and SubnetId: network interface stays around after instance termination
Hello, and many thanks for Moto!

I ran Moto against some use cases and found a few discrepancies between AWS and Moto. This is my first issue. I hope to have found a real bug, and not to have made a mistake.

When deploying EC2 instances where an IP address has been manually specified, and DeleteOnTermination is set to True, I found that Moto keeps the Network Interface around even after termination. When deploying a new EC2 instance with the same IP, this results in an exception "IP already in use". Here is example code to demonstrate this behaviour, ran with AlmaLinux 8 / Python 3.9.19 in a venv with only moto[server] 5.0.11:

```
import boto3
from moto.server import ThreadedMotoServer

server = ThreadedMotoServer()
server.start()
server._server.passthrough_errors = True  # otherwise we just see a 500

ec2_client = boto3.client("ec2", endpoint_url="http://localhost:5000")

existing_subnet_id = ec2_client.describe_subnets()["Subnets"][0]["SubnetId"]

def create_instance():
    instance = ec2_client.run_instances(
        MaxCount=1,
        MinCount=1,
        ImageId="ami-bb9a6bc2",
        InstanceType="t3a.small",
        NetworkInterfaces=[{
            "DeleteOnTermination": True,
            "PrivateIpAddress": "172.31.0.5",
            "DeviceIndex": 0,
            "SubnetId": existing_subnet_id
        }]
    )["Instances"][0]    
    ec2_client.get_waiter("instance_running").wait(InstanceIds=[instance["InstanceId"]])
    return instance

def get_interfaces():
    return ec2_client.describe_network_interfaces(
        Filters=[{"Name": "subnet-id", "Values": [existing_subnet_id]}]
    )["NetworkInterfaces"]
    
assert len(get_interfaces()) == 0

first_instance = create_instance()
assert len(get_interfaces()) == 1
    
ec2_client.terminate_instances(InstanceIds=[first_instance["InstanceId"]])
ec2_client.get_waiter("instance_terminated").wait(InstanceIds=[first_instance["InstanceId"]])

print("After termination:")
print(get_interfaces())
# => a network interface still exists, with "DeleteOnTermination": False

create_instance()
# => Exception("IP already in use")

server.stop()
```

This happens with the existing subnet (as in the code above) or with a newly created subnet. However, if I do not specify the SubnetId in the run_instances() call, the same existing subnet is used, the network interface is still visible after termination, it still shows DeleteOnTermination=False, but the second run_instances() succeeds and there is no duplicate IP exception.

Pull request:
EC2: Terminating an instance now also terminates any NIC's
Fixes #7896 

The `DeleteOnTermination` attribute is now also returned properly - before it would always come back as False, regardless of what was configured.

Patch:
diff --git a/moto/ec2/exceptions.py b/moto/ec2/exceptions.py
index 06e923974..290842b5a 100644
--- a/moto/ec2/exceptions.py
+++ b/moto/ec2/exceptions.py
@@ -170,7 +170,7 @@ class InvalidNetworkInterfaceIdError(EC2ClientError):
     def __init__(self, eni_id: str):
         super().__init__(
             "InvalidNetworkInterfaceID.NotFound",
-            f"The network interface ID '{eni_id}' does not exist",
+            f"The networkInterface ID '{eni_id}' does not exist",
         )
 
 
diff --git a/moto/ec2/models/elastic_network_interfaces.py b/moto/ec2/models/elastic_network_interfaces.py
index a3a733644..9c12986d1 100644
--- a/moto/ec2/models/elastic_network_interfaces.py
+++ b/moto/ec2/models/elastic_network_interfaces.py
@@ -34,6 +34,7 @@ class NetworkInterface(TaggedEC2Resource, CloudFormationModel):
         group_ids: Optional[List[str]] = None,
         description: Optional[str] = None,
         tags: Optional[Dict[str, str]] = None,
+        delete_on_termination: Optional[bool] = False,
         **kwargs: Any,
     ):
         self.ec2_backend = ec2_backend
@@ -51,7 +52,7 @@ class NetworkInterface(TaggedEC2Resource, CloudFormationModel):
         self.instance: Optional[Instance] = None
         self.attachment_id: Optional[str] = None
         self.attach_time: Optional[str] = None
-        self.delete_on_termination = False
+        self.delete_on_termination = delete_on_termination
         self.description = description
         self.source_dest_check = True
 
@@ -201,6 +202,9 @@ class NetworkInterface(TaggedEC2Resource, CloudFormationModel):
         )
         return network_interface
 
+    def delete(self) -> None:
+        self.ec2_backend.delete_network_interface(eni_id=self.id)
+
     def stop(self) -> None:
         if self.public_ip_auto_assign:
             self.public_ip = None
@@ -278,6 +282,7 @@ class NetworkInterfaceBackend:
         group_ids: Optional[List[str]] = None,
         description: Optional[str] = None,
         tags: Optional[Dict[str, str]] = None,
+        delete_on_termination: Optional[bool] = False,
         **kwargs: Any,
     ) -> NetworkInterface:
         eni = NetworkInterface(
@@ -288,6 +293,7 @@ class NetworkInterfaceBackend:
             group_ids=group_ids,
             description=description,
             tags=tags,
+            delete_on_termination=delete_on_termination,
             **kwargs,
         )
         self.enis[eni.id] = eni
diff --git a/moto/ec2/models/instances.py b/moto/ec2/models/instances.py
index 91efe920a..9ba06ba8e 100644
--- a/moto/ec2/models/instances.py
+++ b/moto/ec2/models/instances.py
@@ -394,6 +394,8 @@ class Instance(TaggedEC2Resource, BotoInstance, CloudFormationModel):
 
         for nic in self.nics.values():
             nic.stop()
+            if nic.delete_on_termination:
+                nic.delete()
 
         self.teardown_defaults()
 
@@ -538,6 +540,7 @@ class Instance(TaggedEC2Resource, BotoInstance, CloudFormationModel):
                     device_index=device_index,
                     public_ip_auto_assign=nic.get("AssociatePublicIpAddress", False),
                     group_ids=group_ids,
+                    delete_on_termination=nic.get("DeleteOnTermination") == "true",
                 )
 
             self.attach_eni(use_nic, device_index)
diff --git a/moto/ec2/responses/elastic_network_interfaces.py b/moto/ec2/responses/elastic_network_interfaces.py
index fe25513ca..6e2a80388 100644
--- a/moto/ec2/responses/elastic_network_interfaces.py
+++ b/moto/ec2/responses/elastic_network_interfaces.py
@@ -321,7 +321,7 @@ DESCRIBE_NETWORK_INTERFACES_RESPONSE = """<DescribeNetworkInterfacesResponse xml
             <attachment>
                 <attachTime>{{ eni.attach_time }}</attachTime>
                 <attachmentId>{{ eni.attachment_id }}</attachmentId>
-                <deleteOnTermination>{{ eni.delete_on_termination }}</deleteOnTermination>
+                <deleteOnTermination>{{ 'true' if eni.delete_on_termination else 'false' }}</deleteOnTermination>
                 <deviceIndex>{{ eni.device_index }}</deviceIndex>
                 <networkCardIndex>0</networkCardIndex>
                 <instanceId>{{ eni.instance.id }}</instanceId>
@@ -418,7 +418,7 @@ DESCRIBE_NETWORK_INTERFACE_ATTRIBUTE_RESPONSE_ATTACHMENT = """
     <attachment>
         <attachTime>{{ eni.attach_time }}</attachTime>
         <attachmentId>{{ eni.attachment_id }}</attachmentId>
-        <deleteOnTermination>{{ eni.delete_on_termination }}</deleteOnTermination>
+        <deleteOnTermination>{{ 'true' if eni.delete_on_termination else 'false' }}</deleteOnTermination>
         <deviceIndex>{{ eni.device_index }}</deviceIndex>
         <networkCardIndex>0</networkCardIndex>
         <instanceId>{{ eni.instance.id }}</instanceId>
diff --git a/moto/sagemaker/models.py b/moto/sagemaker/models.py
index 1090d5702..57a09c537 100644
--- a/moto/sagemaker/models.py
+++ b/moto/sagemaker/models.py
@@ -3179,6 +3179,23 @@ class SageMakerModelBackend(BaseBackend):
 
             for trial_component_summary in trial_component_summaries:
                 result["Results"].append({"TrialComponent": trial_component_summary})
+
+        if resource == "ModelPackageGroup":
+            package_groups = [
+                {
+                    "ModelPackageGroupArn": group.model_package_group_arn,
+                    "ModelPackageGroupDescription": group.model_package_group_description,
+                    "CreationTime": group.creation_time.strftime("%Y-%m-%d %H:%M:%S"),
+                    "ModelPackageGroupName": group.model_package_group_name,
+                    "ModelPackageGroupStatus": group.model_package_group_status,
+                    "Tags": group.tags,
+                }
+                for group in self.model_package_groups.values()
+                if evaluate_search_expression(group)
+            ]
+
+            for group in package_groups:
+                result["Results"].append({"ModelPackageGroup": group})
         return result
 
     def delete_experiment(self, experiment_name: str) -> None:


Test patch:
diff --git a/tests/test_ec2/test_elastic_network_interfaces.py b/tests/test_ec2/test_elastic_network_interfaces.py
index 2bd53e327..cd68efca3 100644
--- a/tests/test_ec2/test_elastic_network_interfaces.py
+++ b/tests/test_ec2/test_elastic_network_interfaces.py
@@ -8,7 +8,7 @@ from botocore.exceptions import ClientError
 from moto import mock_aws, settings
 from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
 from moto.ec2.utils import random_private_ip
-from tests import EXAMPLE_AMI_ID
+from tests import EXAMPLE_AMI_ID, aws_verified
 
 
 @mock_aws
@@ -969,6 +969,57 @@ def test_eni_detachment():
     )
 
 
+@aws_verified
+@pytest.mark.aws_verified
+@pytest.mark.parametrize(
+    "delete_eni", [True, False], ids=["DeleteOnTermination", "KeepOnTermination"]
+)
+def test_create_instance__termination_deletes_eni(delete_eni):
+    ssm = boto3.client("ssm", "us-east-1")
+    kernel_61 = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64"
+    ami_id = ssm.get_parameter(Name=kernel_61)["Parameter"]["Value"]
+
+    ec2_client = boto3.client("ec2", "us-east-1")
+    existing_subnet_id = ec2_client.describe_subnets()["Subnets"][0]["SubnetId"]
+
+    instance = ec2_client.run_instances(
+        MaxCount=1,
+        MinCount=1,
+        ImageId=ami_id,
+        InstanceType="t3a.small",
+        NetworkInterfaces=[
+            {
+                "DeleteOnTermination": delete_eni,
+                "DeviceIndex": 0,
+                "SubnetId": existing_subnet_id,
+            }
+        ],
+    )["Instances"][0]
+    instance_id = instance["InstanceId"]
+    ec2_client.get_waiter("instance_running").wait(InstanceIds=[instance_id])
+
+    eni_id = instance["NetworkInterfaces"][0]["NetworkInterfaceId"]
+    eni = ec2_client.describe_network_interfaces(NetworkInterfaceIds=[eni_id])[
+        "NetworkInterfaces"
+    ][0]
+    assert eni["Attachment"]["DeleteOnTermination"] is delete_eni
+
+    ec2_client.terminate_instances(InstanceIds=[instance_id])
+    ec2_client.get_waiter("instance_terminated").wait(InstanceIds=[instance_id])
+
+    if delete_eni:
+        with pytest.raises(ClientError) as exc:
+            ec2_client.describe_network_interfaces(NetworkInterfaceIds=[eni_id])
+        err = exc.value.response["Error"]
+        assert err["Code"] == "InvalidNetworkInterfaceID.NotFound"
+        assert err["Message"] == f"The networkInterface ID '{eni_id}' does not exist"
+    else:
+        # Still exists - let's manually delete
+        ec2_client.describe_network_interfaces(NetworkInterfaceIds=[eni_id])
+
+        ec2_client.delete_network_interface(NetworkInterfaceId=eni_id)
+
+
 def setup_vpc(boto3):  # pylint: disable=W0621
     ec2resource = boto3.resource("ec2", region_name="us-east-1")
     ec2client = boto3.client("ec2", "us-east-1")
diff --git a/tests/test_sagemaker/test_sagemaker_model_package_groups.py b/tests/test_sagemaker/test_sagemaker_model_package_groups.py
index 9ed8c30a6..927a9491b 100644
--- a/tests/test_sagemaker/test_sagemaker_model_package_groups.py
+++ b/tests/test_sagemaker/test_sagemaker_model_package_groups.py
@@ -267,3 +267,35 @@ def test_delete_tags_model_package_group():
     for response in response_iterator:
         remaining_tags.extend(response["Tags"])
     assert remaining_tags == tags[20:]
+
+
+@mock_aws
+def test_search_model_package_groups():
+    client = boto3.client("sagemaker", region_name="eu-west-1")
+    group1 = "test-model-package-group-1"
+    desc1 = "test-model-package-group-description-1"
+    arn1 = client.create_model_package_group(
+        ModelPackageGroupName=group1, ModelPackageGroupDescription=desc1
+    )["ModelPackageGroupArn"]
+    client.add_tags(ResourceArn=arn1, Tags=[{"Key": "k1", "Value": "v1"}])
+
+    group2 = "test-model-package-group-2"
+    desc2 = "test-model-package-group-description-2"
+    client.create_model_package_group(
+        ModelPackageGroupName=group2,
+        ModelPackageGroupDescription=desc2,
+    )
+
+    summary = client.search(Resource="ModelPackageGroup")["Results"]
+    assert len(summary) == 2
+
+    assert (
+        summary[0]["ModelPackageGroup"]["ModelPackageGroupName"]
+        == "test-model-package-group-1"
+    )
+    assert summary[0]["ModelPackageGroup"]["Tags"] == [{"Key": "k1", "Value": "v1"}]
+
+    assert (
+        summary[1]["ModelPackageGroup"]["ModelPackageGroupName"]
+        == "test-model-package-group-2"
+    )