Repository: getmoto/moto (python)

Issue:
add support to ec2 describe-key-pairs with filters
### Overview
Implement support for describe_key_pairs with filter option.

### Steps to reproduce
```shell
awslocal ec2 describe-key-pairs --region us-east-2 --filters "Name=tag:ManagedBy,Values=hyperswitch"
```
following is the error
```shell
An error occurred (InternalFailure) when calling the DescribeKeyPairs operation: The filter 'tag:ManagedBy' for DescribeKeyPairs has not been implemented in Moto yet. Feel free to open an issue at https://github.com/getmoto/moto/issues
``` 

Pull request:
EC2: KeyPairs now support tags
Fixes #6907 

`create_key_pair()` now supports the TagSpecifications-argument
`describe_key_pairs()` now returns the Tags-attribute
`describe_key_pairs()` now supports the `tag:` and `tag-key` filters

Patch:
diff --git a/moto/ec2/models/key_pairs.py b/moto/ec2/models/key_pairs.py
index 5ab46f529..dcdd1d357 100644
--- a/moto/ec2/models/key_pairs.py
+++ b/moto/ec2/models/key_pairs.py
@@ -1,8 +1,7 @@
-from typing import Any, Dict, List
+from typing import Any, Dict, List, Optional
 
-from moto.core import BaseModel
+from .core import TaggedEC2Resource
 from ..exceptions import (
-    FilterNotImplementedError,
     InvalidKeyPairNameError,
     InvalidKeyPairDuplicateError,
     InvalidKeyPairFormatError,
@@ -18,38 +17,55 @@ from ..utils import (
 from moto.core.utils import iso_8601_datetime_with_milliseconds, utcnow
 
 
-class KeyPair(BaseModel):
-    def __init__(self, name: str, fingerprint: str, material: str):
+class KeyPair(TaggedEC2Resource):
+    def __init__(
+        self,
+        name: str,
+        fingerprint: str,
+        material: str,
+        tags: Dict[str, str],
+        ec2_backend: Any,
+    ):
         self.id = random_key_pair_id()
         self.name = name
         self.fingerprint = fingerprint
         self.material = material
         self.create_time = utcnow()
+        self.ec2_backend = ec2_backend
+        self.add_tags(tags or {})
 
     @property
     def created_iso_8601(self) -> str:
         return iso_8601_datetime_with_milliseconds(self.create_time)
 
-    def get_filter_value(self, filter_name: str) -> str:
+    def get_filter_value(
+        self, filter_name: str, method_name: Optional[str] = None
+    ) -> str:
         if filter_name == "key-name":
             return self.name
         elif filter_name == "fingerprint":
             return self.fingerprint
         else:
-            raise FilterNotImplementedError(filter_name, "DescribeKeyPairs")
+            return super().get_filter_value(filter_name, "DescribeKeyPairs")
 
 
 class KeyPairBackend:
     def __init__(self) -> None:
         self.keypairs: Dict[str, KeyPair] = {}
 
-    def create_key_pair(self, name: str, key_type: str = "rsa") -> KeyPair:
+    def create_key_pair(
+        self, name: str, key_type: str, tags: Dict[str, str]
+    ) -> KeyPair:
         if name in self.keypairs:
             raise InvalidKeyPairDuplicateError(name)
         if key_type == "ed25519":
-            keypair = KeyPair(name, **random_ed25519_key_pair())
+            keypair = KeyPair(
+                name, **random_ed25519_key_pair(), tags=tags, ec2_backend=self
+            )
         else:
-            keypair = KeyPair(name, **random_rsa_key_pair())
+            keypair = KeyPair(
+                name, **random_rsa_key_pair(), tags=tags, ec2_backend=self
+            )
 
         self.keypairs[name] = keypair
         return keypair
@@ -77,7 +93,9 @@ class KeyPairBackend:
         else:
             return results
 
-    def import_key_pair(self, key_name: str, public_key_material: str) -> KeyPair:
+    def import_key_pair(
+        self, key_name: str, public_key_material: str, tags: Dict[str, str]
+    ) -> KeyPair:
         if key_name in self.keypairs:
             raise InvalidKeyPairDuplicateError(key_name)
 
@@ -88,7 +106,11 @@ class KeyPairBackend:
 
         fingerprint = public_key_fingerprint(public_key)
         keypair = KeyPair(
-            key_name, material=public_key_material, fingerprint=fingerprint
+            key_name,
+            material=public_key_material,
+            fingerprint=fingerprint,
+            tags=tags,
+            ec2_backend=self,
         )
         self.keypairs[key_name] = keypair
         return keypair
diff --git a/moto/ec2/responses/key_pairs.py b/moto/ec2/responses/key_pairs.py
index b0b80f01c..be24226d7 100644
--- a/moto/ec2/responses/key_pairs.py
+++ b/moto/ec2/responses/key_pairs.py
@@ -5,8 +5,9 @@ class KeyPairs(EC2BaseResponse):
     def create_key_pair(self) -> str:
         name = self._get_param("KeyName")
         key_type = self._get_param("KeyType")
+        tags = self._parse_tag_specification("key-pair").get("key-pair", {})
         self.error_on_dryrun()
-        keypair = self.ec2_backend.create_key_pair(name, key_type)
+        keypair = self.ec2_backend.create_key_pair(name, key_type, tags=tags)
         return self.response_template(CREATE_KEY_PAIR_RESPONSE).render(keypair=keypair)
 
     def delete_key_pair(self) -> str:
@@ -26,9 +27,10 @@ class KeyPairs(EC2BaseResponse):
     def import_key_pair(self) -> str:
         name = self._get_param("KeyName")
         material = self._get_param("PublicKeyMaterial")
+        tags = self._parse_tag_specification("key-pair").get("key-pair", {})
         self.error_on_dryrun()
 
-        keypair = self.ec2_backend.import_key_pair(name, material)
+        keypair = self.ec2_backend.import_key_pair(name, material, tags=tags)
         return self.response_template(IMPORT_KEYPAIR_RESPONSE).render(keypair=keypair)
 
 
@@ -41,6 +43,16 @@ DESCRIBE_KEY_PAIRS_RESPONSE = """<DescribeKeyPairsResponse xmlns="http://ec2.ama
            <keyPairId>{{ keypair.id }}</keyPairId>
            <keyName>{{ keypair.name }}</keyName>
            <keyFingerprint>{{ keypair.fingerprint }}</keyFingerprint>
+           {% if keypair.get_tags() %}
+             <tagSet>
+               {% for tag in keypair.get_tags() %}
+                 <item>
+                   <key>{{ tag.key }}</key>
+                   <value>{{ tag.value }}</value>
+                 </item>
+               {% endfor %}
+             </tagSet>
+           {% endif %}
       </item>
     {% endfor %}
     </keySet>
@@ -52,6 +64,16 @@ CREATE_KEY_PAIR_RESPONSE = """<CreateKeyPairResponse xmlns="http://ec2.amazonaws
    <keyName>{{ keypair.name }}</keyName>
    <keyFingerprint>{{ keypair.fingerprint }}</keyFingerprint>
    <keyMaterial>{{ keypair.material }}</keyMaterial>
+   {% if keypair.get_tags() %}
+   <tagSet>
+     {% for tag in keypair.get_tags() %}
+       <item>
+         <key>{{ tag.key }}</key>
+         <value>{{ tag.value }}</value>
+       </item>
+     {% endfor %}
+   </tagSet>
+   {% endif %}
 </CreateKeyPairResponse>"""
 
 
@@ -66,4 +88,14 @@ IMPORT_KEYPAIR_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
     <keyPairId>{{ keypair.id }}</keyPairId>
     <keyName>{{ keypair.name }}</keyName>
     <keyFingerprint>{{ keypair.fingerprint }}</keyFingerprint>
+    {% if keypair.get_tags() %}
+   <tagSet>
+     {% for tag in keypair.get_tags() %}
+       <item>
+         <key>{{ tag.key }}</key>
+         <value>{{ tag.value }}</value>
+       </item>
+     {% endfor %}
+   </tagSet>
+   {% endif %}
   </ImportKeyPairResponse>"""
diff --git a/moto/iot/models.py b/moto/iot/models.py
index 1ebce5eaa..9ef417831 100644
--- a/moto/iot/models.py
+++ b/moto/iot/models.py
@@ -883,7 +883,8 @@ class IoTBackend(BaseBackend):
             if not do_merge:
                 thing.attributes = attributes
             else:
-                thing.attributes = {k: v for k, v in attributes.items() if v}
+                thing.attributes.update(attributes)
+            thing.attributes = {k: v for k, v in thing.attributes.items() if v}
 
     def create_keys_and_certificate(
         self, set_as_active: bool
diff --git a/moto/route53/models.py b/moto/route53/models.py
index a6077cfb2..a6bbeaeec 100644
--- a/moto/route53/models.py
+++ b/moto/route53/models.py
@@ -4,6 +4,8 @@ import itertools
 import re
 import string
 from collections import defaultdict
+from datetime import datetime
+
 from jinja2 import Template
 from typing import Any, Dict, List, Optional, Tuple
 
@@ -36,6 +38,12 @@ def create_route53_zone_id() -> str:
     return "".join([random.choice(ROUTE53_ID_CHOICE) for _ in range(0, 15)])
 
 
+def create_route53_caller_reference() -> str:
+    timestamp = datetime.now().strftime("%H:%M:%S.%f")
+    random_string = "".join(random.choice(string.ascii_letters) for _ in range(6))
+    return f"{random_string} {timestamp}"
+
+
 class DelegationSet(BaseModel):
     def __init__(
         self,
@@ -310,6 +318,7 @@ class FakeZone(CloudFormationModel):
         name: str,
         id_: str,
         private_zone: bool,
+        caller_reference: str,
         comment: Optional[str] = None,
         delegation_set: Optional[DelegationSet] = None,
     ):
@@ -318,6 +327,7 @@ class FakeZone(CloudFormationModel):
         self.vpcs: List[Dict[str, Any]] = []
         if comment is not None:
             self.comment = comment
+        self.caller_reference = caller_reference
         self.private_zone = private_zone
         self.rrsets: List[RecordSet] = []
         self.delegation_set = delegation_set
@@ -503,12 +513,14 @@ class Route53Backend(BaseBackend):
         self,
         name: str,
         private_zone: bool,
+        caller_reference: Optional[str] = None,
         vpcid: Optional[str] = None,
         vpcregion: Optional[str] = None,
         comment: Optional[str] = None,
         delegation_set_id: Optional[str] = None,
     ) -> FakeZone:
         new_id = create_route53_zone_id()
+        caller_reference = caller_reference or create_route53_caller_reference()
         delegation_set = self.create_reusable_delegation_set(
             caller_reference=f"DelSet_{name}", delegation_set_id=delegation_set_id
         )
@@ -518,6 +530,7 @@ class Route53Backend(BaseBackend):
         new_zone = FakeZone(
             name,
             new_id,
+            caller_reference=caller_reference,
             private_zone=private_zone,
             comment=comment,
             delegation_set=delegation_set,
diff --git a/moto/route53/responses.py b/moto/route53/responses.py
index 514b75793..eea217093 100644
--- a/moto/route53/responses.py
+++ b/moto/route53/responses.py
@@ -66,6 +66,7 @@ class Route53(BaseResponse):
                     vpcregion = zone_request["VPC"].get("VPCRegion", None)
 
             name = zone_request["Name"]
+            caller_reference = zone_request["CallerReference"]
 
             if name[-1] != ".":
                 name += "."
@@ -75,6 +76,7 @@ class Route53(BaseResponse):
                 name,
                 comment=comment,
                 private_zone=private_zone,
+                caller_reference=caller_reference,
                 vpcid=vpcid,
                 vpcregion=vpcregion,
                 delegation_set_id=delegation_set_id,
@@ -619,6 +621,7 @@ GET_HOSTED_ZONE_RESPONSE = """<GetHostedZoneResponse xmlns="https://route53.amaz
    <HostedZone>
       <Id>/hostedzone/{{ zone.id }}</Id>
       <Name>{{ zone.name }}</Name>
+        <CallerReference>{{ zone.caller_reference }}</CallerReference>
       <ResourceRecordSetCount>{{ zone.rrsets|count }}</ResourceRecordSetCount>
       <Config>
         {% if zone.comment %}


Test patch:
diff --git a/tests/test_ec2/test_key_pairs.py b/tests/test_ec2/test_key_pairs.py
index 45c659cc9..cf684fdf1 100644
--- a/tests/test_ec2/test_key_pairs.py
+++ b/tests/test_ec2/test_key_pairs.py
@@ -254,3 +254,51 @@ def test_key_pair_filters_boto3():
         Filters=[{"Name": "fingerprint", "Values": [kp3.key_fingerprint]}]
     )["KeyPairs"]
     assert set([kp["KeyName"] for kp in kp_by_name]) == set([kp3.name])
+
+
+@mock_ec2
+def test_key_pair_with_tags():
+    client = boto3.client("ec2", "us-east-1")
+
+    key_name_1 = str(uuid4())[0:6]
+    key_name_2 = str(uuid4())[0:6]
+    key_name_3 = str(uuid4())[0:6]
+    key_name_4 = str(uuid4())[0:6]
+    resp = client.create_key_pair(
+        KeyName=key_name_1,
+        TagSpecifications=[
+            {"ResourceType": "key-pair", "Tags": [{"Key": "key", "Value": "val1"}]}
+        ],
+    )
+    assert resp["Tags"] == [{"Key": "key", "Value": "val1"}]
+    kp1 = resp["KeyPairId"]
+
+    kp2 = client.create_key_pair(
+        KeyName=key_name_2,
+        TagSpecifications=[
+            {"ResourceType": "key-pair", "Tags": [{"Key": "key", "Value": "val2"}]}
+        ],
+    )["KeyPairId"]
+
+    assert "Tags" not in client.create_key_pair(KeyName=key_name_3)
+
+    key_pairs = client.describe_key_pairs(
+        Filters=[{"Name": "tag-key", "Values": ["key"]}]
+    )["KeyPairs"]
+    assert [kp["KeyPairId"] for kp in key_pairs] == [kp1, kp2]
+
+    key_pairs = client.describe_key_pairs(
+        Filters=[{"Name": "tag:key", "Values": ["val1"]}]
+    )["KeyPairs"]
+    assert len(key_pairs) == 1
+    assert key_pairs[0]["KeyPairId"] == kp1
+    assert key_pairs[0]["Tags"] == [{"Key": "key", "Value": "val1"}]
+
+    resp = client.import_key_pair(
+        KeyName=key_name_4,
+        PublicKeyMaterial=RSA_PUBLIC_KEY_OPENSSH,
+        TagSpecifications=[
+            {"ResourceType": "key-pair", "Tags": [{"Key": "key", "Value": "val4"}]}
+        ],
+    )
+    assert resp["Tags"] == [{"Key": "key", "Value": "val4"}]
diff --git a/tests/test_iot/test_iot_things.py b/tests/test_iot/test_iot_things.py
index 773bdab3e..ddaffdc66 100644
--- a/tests/test_iot/test_iot_things.py
+++ b/tests/test_iot/test_iot_things.py
@@ -52,7 +52,32 @@ def test_update_thing():
     assert len(res["things"]) == 1
     assert res["things"][0]["thingName"] is not None
     assert res["things"][0]["thingArn"] is not None
-    assert res["things"][0]["attributes"]["k1"] == "v1"
+    assert res["things"][0]["attributes"] == {"k1": "v1"}
+
+    client.update_thing(thingName=name, attributePayload={"attributes": {"k2": "v2"}})
+    res = client.list_things()
+    assert len(res["things"]) == 1
+    assert res["things"][0]["thingName"] is not None
+    assert res["things"][0]["thingArn"] is not None
+    assert res["things"][0]["attributes"] == {"k2": "v2"}
+
+    client.update_thing(
+        thingName=name, attributePayload={"attributes": {"k1": "v1"}, "merge": True}
+    )
+    res = client.list_things()
+    assert len(res["things"]) == 1
+    assert res["things"][0]["thingName"] is not None
+    assert res["things"][0]["thingArn"] is not None
+    assert res["things"][0]["attributes"] == {"k1": "v1", "k2": "v2"}
+
+    client.update_thing(
+        thingName=name, attributePayload={"attributes": {"k1": "v1.1"}, "merge": True}
+    )
+    res = client.list_things()
+    assert len(res["things"]) == 1
+    assert res["things"][0]["thingName"] is not None
+    assert res["things"][0]["thingArn"] is not None
+    assert res["things"][0]["attributes"] == {"k1": "v1.1", "k2": "v2"}
 
     client.update_thing(
         thingName=name, attributePayload={"attributes": {"k1": ""}, "merge": True}
@@ -61,6 +86,13 @@ def test_update_thing():
     assert len(res["things"]) == 1
     assert res["things"][0]["thingName"] is not None
     assert res["things"][0]["thingArn"] is not None
+    assert res["things"][0]["attributes"] == {"k2": "v2"}
+
+    client.update_thing(thingName=name, attributePayload={"attributes": {"k2": ""}})
+    res = client.list_things()
+    assert len(res["things"]) == 1
+    assert res["things"][0]["thingName"] is not None
+    assert res["things"][0]["thingArn"] is not None
     assert res["things"][0]["attributes"] == {}
 
 
diff --git a/tests/test_route53/test_route53.py b/tests/test_route53/test_route53.py
index 60c7a9621..c19f7531f 100644
--- a/tests/test_route53/test_route53.py
+++ b/tests/test_route53/test_route53.py
@@ -32,6 +32,19 @@ def test_create_hosted_zone():
         assert "<Name>testdns.aws.com.</Name>" in requests.get(location).text
 
 
+@mock_route53
+def test_get_hosted_zone():
+    conn = boto3.client("route53", region_name="us-east-1")
+    name = "testdns.aws.com."
+    caller_ref = str(hash("foo"))
+
+    zone = conn.create_hosted_zone(Name=name, CallerReference=caller_ref)["HostedZone"]
+
+    res = conn.get_hosted_zone(Id=zone["Id"])
+    assert res["HostedZone"]["Name"] == name
+    assert res["HostedZone"]["CallerReference"] == caller_ref
+
+
 @mock_route53
 def test_list_hosted_zones():
     conn = boto3.client("route53", region_name="us-east-1")