Repository: getmoto/moto
Pull request #6316 (2023-05-11 18:42:03)

EC2: vpc endpoint as a route target
Closes https://github.com/getmoto/moto/issues/6301


Patch:
diff --git a/moto/cloudformation/parsing.py b/moto/cloudformation/parsing.py
index 9731008a2..cf4f06113 100644
--- a/moto/cloudformation/parsing.py
+++ b/moto/cloudformation/parsing.py
@@ -793,17 +793,7 @@ class ResourceMap(collections_abc.Mapping):  # type: ignore[type-arg]
         for logical_name in resource_names_by_action["Remove"]:
             resource_json = self._resource_json_map[logical_name]
             resource = self._parsed_resources[logical_name]
-            # ToDo: Standardize this.
-            if hasattr(resource, "physical_resource_id"):
-                resource_name = self._parsed_resources[
-                    logical_name
-                ].physical_resource_id
-            else:
-                resource_name = None
-            parse_and_delete_resource(
-                resource_name, resource_json, self._account_id, self._region_name
-            )
-            self._parsed_resources.pop(logical_name)
+            self._delete_resource(resource, resource_json)
 
         self._template = template
         if parameters:
@@ -859,24 +849,12 @@ class ResourceMap(collections_abc.Mapping):  # type: ignore[type-arg]
                         not isinstance(parsed_resource, str)
                         and parsed_resource is not None
                     ):
-                        try:
-                            parsed_resource.delete(self._account_id, self._region_name)
-                        except (TypeError, AttributeError):
-                            if hasattr(parsed_resource, "physical_resource_id"):
-                                resource_name = parsed_resource.physical_resource_id
-                            else:
-                                resource_name = None
-
-                            resource_json = self._resource_json_map[
-                                parsed_resource.logical_resource_id
-                            ]
-
-                            parse_and_delete_resource(
-                                resource_name,
-                                resource_json,
-                                self._account_id,
-                                self._region_name,
-                            )
+
+                        resource_json = self._resource_json_map[
+                            parsed_resource.logical_resource_id
+                        ]
+
+                        self._delete_resource(parsed_resource, resource_json)
 
                         self._parsed_resources.pop(parsed_resource.logical_resource_id)
                 except Exception as e:
@@ -889,6 +867,24 @@ class ResourceMap(collections_abc.Mapping):  # type: ignore[type-arg]
         if tries == 5:
             raise last_exception
 
+    def _delete_resource(
+        self, parsed_resource: Any, resource_json: Dict[str, Any]
+    ) -> None:
+        try:
+            parsed_resource.delete(self._account_id, self._region_name)
+        except (TypeError, AttributeError):
+            if hasattr(parsed_resource, "physical_resource_id"):
+                resource_name = parsed_resource.physical_resource_id
+            else:
+                resource_name = None
+
+            parse_and_delete_resource(
+                resource_name,
+                resource_json,
+                self._account_id,
+                self._region_name,
+            )
+
 
 class OutputMap(collections_abc.Mapping):  # type: ignore[type-arg]
     def __init__(self, resources: ResourceMap, template: Dict[str, Any], stack_id: str):
diff --git a/moto/ec2/exceptions.py b/moto/ec2/exceptions.py
index 6a93607d0..4b3373fb7 100644
--- a/moto/ec2/exceptions.py
+++ b/moto/ec2/exceptions.py
@@ -234,6 +234,15 @@ class RouteAlreadyExistsError(EC2ClientError):
         )
 
 
+class RouteNotSupportedError(EC2ClientError):
+    def __init__(self, vpce_id: str):
+        super().__init__(
+            "RouteNotSupported",
+            f"Route table contains unsupported route target: {vpce_id}. "
+            f"VPC Endpoints of this type cannot be used as route targets.",
+        )
+
+
 class InvalidInstanceIdError(EC2ClientError):
     def __init__(self, instance_id: Any):
         if isinstance(instance_id, str):
diff --git a/moto/ec2/models/route_tables.py b/moto/ec2/models/route_tables.py
index 4215a320c..de6217d09 100644
--- a/moto/ec2/models/route_tables.py
+++ b/moto/ec2/models/route_tables.py
@@ -19,6 +19,7 @@ from ..exceptions import (
     InvalidAssociationIdError,
     InvalidDestinationCIDRBlockParameterError,
     RouteAlreadyExistsError,
+    RouteNotSupportedError,
 )
 from ..utils import (
     EC2_RESOURCE_TO_PREFIX,
@@ -141,6 +142,7 @@ class Route(CloudFormationModel):
         interface: Optional[NetworkInterface] = None,
         vpc_pcx: Optional[VPCPeeringConnection] = None,
         carrier_gateway: Optional[CarrierGateway] = None,
+        vpc_endpoint_id: Optional[str] = None,
     ):
         self.id = generate_route_id(
             route_table.id,
@@ -161,6 +163,7 @@ class Route(CloudFormationModel):
         self.interface = interface
         self.vpc_pcx = vpc_pcx
         self.carrier_gateway = carrier_gateway
+        self.vpc_endpoint_id = vpc_endpoint_id
 
     @property
     def physical_resource_id(self) -> str:
@@ -359,6 +362,7 @@ class RouteBackend:
         interface_id: Optional[str] = None,
         vpc_peering_connection_id: Optional[str] = None,
         carrier_gateway_id: Optional[str] = None,
+        vpc_endpoint_id: Optional[str] = None,
     ) -> Route:
         gateway = None
         nat_gateway = None
@@ -368,6 +372,13 @@ class RouteBackend:
         destination_prefix_list = None
         carrier_gateway = None
 
+        if vpc_endpoint_id:
+            vpce = self.describe_vpc_endpoints(vpc_end_point_ids=[vpc_endpoint_id])  # type: ignore[attr-defined]
+            if not vpce[0].endpoint_type == "GatewayLoadBalancer":
+                # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/create_route.html
+                # VpcEndpointId (string) – The ID of a VPC endpoint. Supported for Gateway Load Balancer endpoints only.
+                raise RouteNotSupportedError(vpc_endpoint_id)
+
         route_table = self.get_route_table(route_table_id)
 
         if interface_id:
@@ -414,6 +425,7 @@ class RouteBackend:
             transit_gateway=transit_gateway,
             interface=interface,
             carrier_gateway=carrier_gateway,
+            vpc_endpoint_id=vpc_endpoint_id,
             vpc_pcx=self.get_vpc_peering_connection(vpc_peering_connection_id)  # type: ignore[attr-defined]
             if vpc_peering_connection_id
             else None,
diff --git a/moto/ec2/responses/route_tables.py b/moto/ec2/responses/route_tables.py
index bab437680..be8aeaf04 100644
--- a/moto/ec2/responses/route_tables.py
+++ b/moto/ec2/responses/route_tables.py
@@ -25,6 +25,7 @@ class RouteTables(EC2BaseResponse):
         interface_id = self._get_param("NetworkInterfaceId")
         pcx_id = self._get_param("VpcPeeringConnectionId")
         carrier_gateway_id = self._get_param("CarrierGatewayId")
+        vpc_endpoint_id = self._get_param("VpcEndpointId")
 
         self.ec2_backend.create_route(
             route_table_id,
@@ -39,6 +40,7 @@ class RouteTables(EC2BaseResponse):
             interface_id=interface_id,
             vpc_peering_connection_id=pcx_id,
             carrier_gateway_id=carrier_gateway_id,
+            vpc_endpoint_id=vpc_endpoint_id,
         )
 
         template = self.response_template(CREATE_ROUTE_RESPONSE)
@@ -212,6 +214,11 @@ DESCRIBE_ROUTE_TABLES_RESPONSE = """
                   <origin>CreateRoute</origin>
                   <state>active</state>
                 {% endif %}
+                {% if route.vpc_endpoint_id %}
+                  <gatewayId>{{ route.vpc_endpoint_id }}</gatewayId>
+                  <origin>CreateRoute</origin>
+                  <state>active</state>
+                {% endif %}
                 {% if route.instance %}
                   <instanceId>{{ route.instance.id }}</instanceId>
                   <origin>CreateRoute</origin>
diff --git a/moto/secretsmanager/models.py b/moto/secretsmanager/models.py
index bb492c4ab..53d049ef0 100644
--- a/moto/secretsmanager/models.py
+++ b/moto/secretsmanager/models.py
@@ -88,6 +88,8 @@ class FakeSecret:
         self.auto_rotate_after_days = 0
         self.deleted_date: Optional[float] = None
         self.policy: Optional[str] = None
+        self.next_rotation_date: Optional[int] = None
+        self.last_rotation_date: Optional[int] = None
 
     def update(
         self,
@@ -167,9 +169,10 @@ class FakeSecret:
             "RotationEnabled": self.rotation_enabled,
             "RotationLambdaARN": self.rotation_lambda_arn,
             "RotationRules": {"AutomaticallyAfterDays": self.auto_rotate_after_days},
-            "LastRotatedDate": None,
+            "LastRotatedDate": self.last_rotation_date,
             "LastChangedDate": self.last_changed_date,
             "LastAccessedDate": None,
+            "NextRotationDate": self.next_rotation_date,
             "DeletedDate": self.deleted_date,
             "Tags": self.tags,
             "VersionIdsToStages": version_id_to_stages,
@@ -535,6 +538,10 @@ class SecretsManagerBackend(BaseBackend):
                     )
                     raise InvalidParameterException(msg)
 
+                self.secrets[secret_id].next_rotation_date = int(time.time()) + (
+                    int(rotation_period) * 86400
+                )
+
         secret = self.secrets[secret_id]
 
         # The rotation function must end with the versions of the secret in
@@ -625,7 +632,7 @@ class SecretsManagerBackend(BaseBackend):
                 secret.versions[new_version_id], new_version_id
             )
             secret.versions[new_version_id]["version_stages"] = ["AWSCURRENT"]
-
+        self.secrets[secret_id].last_rotation_date = int(time.time())
         return secret.to_short_dict()
 
     def get_random_password(
diff --git a/moto/sns/models.py b/moto/sns/models.py
index a3cf2afd0..fd43eb181 100644
--- a/moto/sns/models.py
+++ b/moto/sns/models.py
@@ -139,36 +139,20 @@ class Topic(CloudFormationModel):
     @classmethod
     def update_from_cloudformation_json(  # type: ignore[misc]
         cls,
-        original_resource: Any,
+        original_resource: "Topic",
         new_resource_name: str,
         cloudformation_json: Any,
         account_id: str,
         region_name: str,
     ) -> "Topic":
-        cls.delete_from_cloudformation_json(
-            original_resource.name, cloudformation_json, account_id, region_name
-        )
+        original_resource.delete(account_id, region_name)
         return cls.create_from_cloudformation_json(
             new_resource_name, cloudformation_json, account_id, region_name
         )
 
-    @classmethod
-    def delete_from_cloudformation_json(  # type: ignore[misc]
-        cls,
-        resource_name: str,
-        cloudformation_json: Any,
-        account_id: str,
-        region_name: str,
-    ) -> None:
-        sns_backend = sns_backends[account_id][region_name]
-        properties = cloudformation_json["Properties"]
-
-        topic_name = properties.get(cls.cloudformation_name_type()) or resource_name
-        topic_arn = make_arn_for_topic(account_id, topic_name, sns_backend.region_name)
-        subscriptions, _ = sns_backend.list_subscriptions(topic_arn)
-        for subscription in subscriptions:
-            sns_backend.unsubscribe(subscription.arn)
-        sns_backend.delete_topic(topic_arn)
+    def delete(self, account_id: str, region_name: str) -> None:
+        sns_backend: SNSBackend = sns_backends[account_id][region_name]
+        sns_backend.delete_topic(self.arn)
 
     def _create_default_topic_policy(
         self, region_name: str, account_id: str, name: str


Test patch:
diff --git a/tests/test_ec2/test_route_tables.py b/tests/test_ec2/test_route_tables.py
index 51c37a646..70b70bd67 100644
--- a/tests/test_ec2/test_route_tables.py
+++ b/tests/test_ec2/test_route_tables.py
@@ -1041,6 +1041,62 @@ def test_create_route_with_unknown_egress_only_igw():
     err["Message"].should.equal("The eigw ID 'eoigw' does not exist")
 
 
+@mock_ec2
+def test_create_route_with_vpc_endpoint():
+    # Setup
+    _, ec2_client, route_table, vpc = setup_vpc()
+    dest_cidr = "0.0.0.0/0"
+    vpc_end_point = ec2_client.create_vpc_endpoint(
+        VpcId=vpc.id,
+        ServiceName="com.amazonaws.vpce.eu-central-1.vpce-svc-084fa044c50cb1290",
+        RouteTableIds=[route_table.id],
+        VpcEndpointType="GatewayLoadBalancer",
+    )
+    vpce_id = vpc_end_point["VpcEndpoint"]["VpcEndpointId"]
+
+    # Execute
+    ec2_client.create_route(
+        DestinationCidrBlock=dest_cidr,
+        VpcEndpointId=vpce_id,
+        RouteTableId=route_table.id,
+    )
+    rt = ec2_client.describe_route_tables()
+    new_route = rt["RouteTables"][-1]["Routes"][1]
+
+    # Verify
+    assert new_route["DestinationCidrBlock"] == dest_cidr
+    assert new_route["GatewayId"] == vpce_id
+
+
+@mock_ec2
+def test_create_route_with_invalid_vpc_endpoint():
+    # Setup
+    _, ec2_client, route_table, vpc = setup_vpc()
+    dest_cidr = "0.0.0.0/0"
+    vpc_end_point = ec2_client.create_vpc_endpoint(
+        VpcId=vpc.id,
+        ServiceName="com.amazonaws.us-east-1.s3",
+        RouteTableIds=[route_table.id],
+        VpcEndpointType="Gateway",
+    )
+    vpce_id = vpc_end_point["VpcEndpoint"]["VpcEndpointId"]
+
+    # Execute
+    with pytest.raises(ClientError) as ex:
+        ec2_client.create_route(
+            DestinationCidrBlock=dest_cidr,
+            VpcEndpointId=vpce_id,
+            RouteTableId=route_table.id,
+        )
+    # Verify
+    err = ex.value.response["Error"]
+    assert err["Code"] == "RouteNotSupported"
+    assert (
+        err["Message"] == f"Route table contains unsupported route target: {vpce_id}. "
+        "VPC Endpoints of this type cannot be used as route targets."
+    )
+
+
 @mock_ec2
 def test_associate_route_table_by_gateway():
     ec2 = boto3.client("ec2", region_name="us-west-1")
@@ -1087,3 +1143,17 @@ def test_associate_route_table_by_subnet():
     verify[0]["Associations"][0]["SubnetId"].should.equals(subnet_id)
     verify[0]["Associations"][0]["RouteTableAssociationId"].should.equal(assoc_id)
     verify[0]["Associations"][0].doesnt.have.key("GatewayId")
+
+
+def setup_vpc():
+    ec2_resource = boto3.resource("ec2", region_name="eu-central-1")
+    ec2_client = boto3.client("ec2", region_name="eu-central-1")
+
+    vpc = ec2_resource.create_vpc(CidrBlock="10.0.0.0/16")
+    ec2_resource.create_subnet(
+        VpcId=vpc.id, CidrBlock="10.0.0.0/24", AvailabilityZone="us-west-2a"
+    )
+
+    route_table = ec2_resource.create_route_table(VpcId=vpc.id)
+
+    return ec2_resource, ec2_client, route_table, vpc
diff --git a/tests/test_secretsmanager/test_secretsmanager.py b/tests/test_secretsmanager/test_secretsmanager.py
index 6eee8b762..f2f78ec22 100644
--- a/tests/test_secretsmanager/test_secretsmanager.py
+++ b/tests/test_secretsmanager/test_secretsmanager.py
@@ -711,21 +711,41 @@ def test_cancel_rotate_secret():
 
 @mock_secretsmanager
 def test_rotate_secret():
-    conn = boto3.client("secretsmanager", region_name="us-west-2")
-    conn.create_secret(
-        Name=DEFAULT_SECRET_NAME, SecretString="foosecret", Description="foodescription"
-    )
-
-    rotated_secret = conn.rotate_secret(SecretId=DEFAULT_SECRET_NAME)
-
-    assert rotated_secret
-    assert rotated_secret["ARN"] != ""  # Test arn not empty
-    assert rotated_secret["Name"] == DEFAULT_SECRET_NAME
-    assert rotated_secret["VersionId"] != ""
-
-    describe_secret = conn.describe_secret(SecretId=DEFAULT_SECRET_NAME)
+    # Setup
+    frozen_time = datetime(2023, 5, 20, 10, 20, 30, tzinfo=tzlocal())
+    rotate_after_days = 10
+    with freeze_time(frozen_time):
+        conn = boto3.client("secretsmanager", region_name="us-west-2")
+        conn.create_secret(
+            Name=DEFAULT_SECRET_NAME,
+            SecretString="foosecret",
+            Description="foodescription",
+        )
 
-    assert describe_secret["Description"] == "foodescription"
+        # Execute
+        rotated_secret = conn.rotate_secret(
+            SecretId=DEFAULT_SECRET_NAME,
+            RotationRules={"AutomaticallyAfterDays": rotate_after_days},
+        )
+        describe_secret = conn.describe_secret(SecretId=DEFAULT_SECRET_NAME)
+
+        # Verify
+        assert rotated_secret
+        assert rotated_secret["ARN"] != ""  # Test arn not empty
+        assert rotated_secret["Name"] == DEFAULT_SECRET_NAME
+        assert rotated_secret["VersionId"] != ""
+        assert describe_secret["Description"] == "foodescription"
+        assert "NextRotationDate" in describe_secret
+        assert "LastRotatedDate" in describe_secret
+
+        # can't do freeze time tests in servermode tests
+        if settings.TEST_SERVER_MODE:
+            return
+
+        assert describe_secret["LastChangedDate"] == frozen_time
+        assert describe_secret["NextRotationDate"] == frozen_time + timedelta(
+            days=rotate_after_days
+        )
 
 
 @mock_secretsmanager
diff --git a/tests/test_sns/test_sns_cloudformation.py b/tests/test_sns/test_sns_cloudformation.py
index 17cd25e05..df8e1eb03 100644
--- a/tests/test_sns/test_sns_cloudformation.py
+++ b/tests/test_sns/test_sns_cloudformation.py
@@ -1,6 +1,7 @@
 import boto3
 import json
 import sure  # noqa # pylint: disable=unused-import
+import pytest
 
 from moto import mock_cloudformation, mock_sns
 
@@ -8,24 +9,7 @@ from moto import mock_cloudformation, mock_sns
 @mock_cloudformation
 @mock_sns
 def test_sns_topic():
-    dummy_template = {
-        "AWSTemplateFormatVersion": "2010-09-09",
-        "Resources": {
-            "MySNSTopic": {
-                "Type": "AWS::SNS::Topic",
-                "Properties": {
-                    "Subscription": [
-                        {"Endpoint": "https://example.com", "Protocol": "https"}
-                    ],
-                    "TopicName": "my_topics",
-                },
-            }
-        },
-        "Outputs": {
-            "topic_name": {"Value": {"Fn::GetAtt": ["MySNSTopic", "TopicName"]}},
-            "topic_arn": {"Value": {"Ref": "MySNSTopic"}},
-        },
-    }
+    dummy_template = get_template(with_properties=True)
     template_json = json.dumps(dummy_template)
     cf = boto3.client("cloudformation", region_name="us-west-1")
     cf.create_stack(StackName="test_stack", TemplateBody=template_json)
@@ -56,24 +40,7 @@ def test_sns_topic():
 @mock_cloudformation
 @mock_sns
 def test_sns_update_topic():
-    dummy_template = {
-        "AWSTemplateFormatVersion": "2010-09-09",
-        "Resources": {
-            "MySNSTopic": {
-                "Type": "AWS::SNS::Topic",
-                "Properties": {
-                    "Subscription": [
-                        {"Endpoint": "https://example.com", "Protocol": "https"}
-                    ],
-                    "TopicName": "my_topics",
-                },
-            }
-        },
-        "Outputs": {
-            "topic_name": {"Value": {"Fn::GetAtt": ["MySNSTopic", "TopicName"]}},
-            "topic_arn": {"Value": {"Ref": "MySNSTopic"}},
-        },
-    }
+    dummy_template = get_template(with_properties=True)
     sns_template_json = json.dumps(dummy_template)
     cf = boto3.client("cloudformation", region_name="us-west-1")
     cf.create_stack(StackName="test_stack", TemplateBody=sns_template_json)
@@ -104,25 +71,9 @@ def test_sns_update_topic():
 
 @mock_cloudformation
 @mock_sns
-def test_sns_update_remove_topic():
-    dummy_template = {
-        "AWSTemplateFormatVersion": "2010-09-09",
-        "Resources": {
-            "MySNSTopic": {
-                "Type": "AWS::SNS::Topic",
-                "Properties": {
-                    "Subscription": [
-                        {"Endpoint": "https://example.com", "Protocol": "https"}
-                    ],
-                    "TopicName": "my_topics",
-                },
-            }
-        },
-        "Outputs": {
-            "topic_name": {"Value": {"Fn::GetAtt": ["MySNSTopic", "TopicName"]}},
-            "topic_arn": {"Value": {"Ref": "MySNSTopic"}},
-        },
-    }
+@pytest.mark.parametrize("with_properties", [True, False])
+def test_sns_update_remove_topic(with_properties):
+    dummy_template = get_template(with_properties)
     sns_template_json = json.dumps(dummy_template)
     cf = boto3.client("cloudformation", region_name="us-west-1")
     cf.create_stack(StackName="test_stack", TemplateBody=sns_template_json)
@@ -142,26 +93,9 @@ def test_sns_update_remove_topic():
 
 @mock_cloudformation
 @mock_sns
-def test_sns_delete_topic():
-    dummy_template = {
-        "AWSTemplateFormatVersion": "2010-09-09",
-        "Resources": {
-            "MySNSTopic": {
-                "Type": "AWS::SNS::Topic",
-                "Properties": {
-                    "Subscription": [
-                        {"Endpoint": "https://example.com", "Protocol": "https"}
-                    ],
-                    "TopicName": "my_topics",
-                },
-            }
-        },
-        "Outputs": {
-            "topic_name": {"Value": {"Fn::GetAtt": ["MySNSTopic", "TopicName"]}},
-            "topic_arn": {"Value": {"Ref": "MySNSTopic"}},
-        },
-    }
-    sns_template_json = json.dumps(dummy_template)
+@pytest.mark.parametrize("with_properties", [True, False])
+def test_sns_delete_topic(with_properties):
+    sns_template_json = json.dumps(get_template(with_properties))
     cf = boto3.client("cloudformation", region_name="us-west-1")
     cf.create_stack(StackName="test_stack", TemplateBody=sns_template_json)
 
@@ -173,3 +107,20 @@ def test_sns_delete_topic():
 
     topics = sns.list_topics()["Topics"]
     topics.should.have.length_of(0)
+
+
+def get_template(with_properties):
+    dummy_template = {
+        "AWSTemplateFormatVersion": "2010-09-09",
+        "Resources": {"MySNSTopic": {"Type": "AWS::SNS::Topic"}},
+        "Outputs": {
+            "topic_name": {"Value": {"Fn::GetAtt": ["MySNSTopic", "TopicName"]}},
+            "topic_arn": {"Value": {"Ref": "MySNSTopic"}},
+        },
+    }
+    if with_properties:
+        dummy_template["Resources"]["MySNSTopic"]["Properties"] = {
+            "Subscription": [{"Endpoint": "https://example.com", "Protocol": "https"}],
+            "TopicName": "my_topics",
+        }
+    return dummy_template