Repository: getmoto/moto
Pull request #7243 (2024-01-23 15:23:07)

RDS instance restore to point in time - fix failing to copy DB subnet…
When restoring a RDS instance, where the source instance has a DB subnet group, Moto throws a 500 error. This is due to it not being able to deep copy DB subnet group. 
The attribute can be removed as the DB Subnet group (name) is part of the RestoreRDSInstanceToPointInTime endpoint. 

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/7243?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`987fb7d`)](https://app.codecov.io/gh/getmoto/moto/commit/987fb7d7c1acbf9b0f3d0f370730568a20f197fd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) 95.90% compared to head [(`0b17358`)](https://app.codecov.io/gh/getmoto/moto/pull/7243?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) 95.90%.
> Report is 1 commits behind head on master.


<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #7243   +/-   ##
=======================================
  Coverage   95.90%   95.90%           
=======================================
  Files         840      840           
  Lines       82750    82751    +1     
=======================================
+ Hits        79363    79364    +1     
  Misses       3387     3387           
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/7243/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/7243/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `35.47% <0.00%> (+0.01%)` | :arrow_up: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/7243/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `95.85% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/7243?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).


Patch:
diff --git a/moto/rds/models.py b/moto/rds/models.py
index 1ac46d05a..1237d167e 100644
--- a/moto/rds/models.py
+++ b/moto/rds/models.py
@@ -1818,7 +1818,13 @@ class RDSBackend(BaseBackend):
         db_instance = self.describe_db_instances(
             db_instance_identifier=source_db_identifier
         )[0]
-        new_instance_props = copy.deepcopy(db_instance.__dict__)
+
+        # remove the db subnet group as it cannot be copied
+        # and is not used in the restored instance
+        source_dict = db_instance.__dict__
+        del source_dict["db_subnet_group"]
+
+        new_instance_props = copy.deepcopy(source_dict)
         if not db_instance.option_group_supplied:
             # If the option group is not supplied originally, the 'option_group_name' will receive a default value
             # Force this reconstruction, and prevent any validation on the default value
diff --git a/moto/s3/exceptions.py b/moto/s3/exceptions.py
index 337a9492e..dfea0dec6 100644
--- a/moto/s3/exceptions.py
+++ b/moto/s3/exceptions.py
@@ -2,6 +2,8 @@ from typing import TYPE_CHECKING, Any, Optional, Union
 
 from moto.core.exceptions import RESTError
 
+from .notifications import S3NotificationEvent
+
 if TYPE_CHECKING:
     from moto.s3.models import FakeDeleteMarker
 
@@ -281,10 +283,13 @@ class InvalidNotificationDestination(S3ClientError):
 class InvalidNotificationEvent(S3ClientError):
     code = 400
 
-    def __init__(self) -> None:
+    def __init__(self, event_name: str) -> None:
         super().__init__(
             "InvalidArgument",
-            "The event is not supported for notifications",
+            (
+                f"The event '{event_name}' is not supported for notifications. "
+                f"Supported events are as follows: {S3NotificationEvent.events()}"
+            ),
         )
 
 
diff --git a/moto/s3/models.py b/moto/s3/models.py
index c115f96c1..480601e47 100644
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -2088,7 +2088,10 @@ class S3Backend(BaseBackend, CloudWatchMetricProvider):
         bucket.keys.setlist(key_name, keys)
 
         notifications.send_event(
-            self.account_id, notifications.S3_OBJECT_CREATE_PUT, bucket, new_key
+            self.account_id,
+            notifications.S3NotificationEvent.OBJECT_CREATED_PUT_EVENT,
+            bucket,
+            new_key,
         )
 
         return new_key
@@ -2640,7 +2643,10 @@ class S3Backend(BaseBackend, CloudWatchMetricProvider):
 
         # Send notifications that an object was copied
         notifications.send_event(
-            self.account_id, notifications.S3_OBJECT_CREATE_COPY, bucket, new_key
+            self.account_id,
+            notifications.S3NotificationEvent.OBJECT_CREATED_COPY_EVENT,
+            bucket,
+            new_key,
         )
 
     def put_bucket_acl(self, bucket_name: str, acl: Optional[FakeAcl]) -> None:
diff --git a/moto/s3/notifications.py b/moto/s3/notifications.py
index b43ab1908..389b60070 100644
--- a/moto/s3/notifications.py
+++ b/moto/s3/notifications.py
@@ -1,11 +1,53 @@
 import json
 from datetime import datetime
+from enum import Enum
 from typing import Any, Dict, List
 
 _EVENT_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
 
-S3_OBJECT_CREATE_COPY = "s3:ObjectCreated:Copy"
-S3_OBJECT_CREATE_PUT = "s3:ObjectCreated:Put"
+
+class S3NotificationEvent(str, Enum):
+    REDUCED_REDUNDANCY_LOST_OBJECT_EVENT = "s3:ReducedRedundancyLostObject"
+    OBJCET_CREATED_EVENT = "s3:ObjectCreated:*"
+    OBJECT_CREATED_PUT_EVENT = "s3:ObjectCreated:Put"
+    OBJECT_CREATED_POST_EVENT = "s3:ObjectCreated:Post"
+    OBJECT_CREATED_COPY_EVENT = "s3:ObjectCreated:Copy"
+    OBJECT_CREATED_COMPLETE_MULTIPART_UPLOAD_EVENT = (
+        "s3:ObjectCreated:CompleteMultipartUpload"
+    )
+    OBJECT_REMOVED_EVENT = "s3:ObjectRemoved:*"
+    OBJECTREMOVED_DELETE_EVENT = "s3:ObjectRemoved:Delete"
+    OBJECTREMOVED_DELETE_MARKER_CREATED_EVENT = "s3:ObjectRemoved:DeleteMarkerCreated"
+    OBJECT_RESTORE_EVENT = "s3:ObjectRestore:*"
+    OBJECT_RESTORE_POST_EVENT = "s3:ObjectRestore:Post"
+    OBJECT_RESTORE_COMPLETED_EVENT = "s3:ObjectRestore:Completed"
+    REPLICATION_EVENT = "s3:Replication:*"
+    REPLICATION_OPERATION_FAILED_REPLICATION_EVENT = (
+        "s3:Replication:OperationFailedReplication"
+    )
+    REPLICATION_OPERATION_NOT_TRACKED_EVENT = "s3:Replication:OperationNotTracked"
+    REPLICATION_OPERATION_MISSED_THRESHOLD_EVENT = (
+        "s3:Replication:OperationMissedThreshold"
+    )
+    REPLICATION_OPERATION_REPLICATED_AFTER_THRESHOLD_EVENT = (
+        "s3:Replication:OperationReplicatedAfterThreshold"
+    )
+    OBJECT_RESTORE_DELETE_EVENT = "s3:ObjectRestore:Delete"
+    LIFECYCLE_TRANSITION_EVENT = "s3:LifecycleTransition"
+    INTELLIGENT_TIERING_EVENT = "s3:IntelligentTiering"
+    OBJECT_ACL_EVENT = "s3:ObjectAcl:Put"
+    LIFECYCLE_EXPIRATION_EVENT = "s3:LifecycleExpiration:*"
+    LIFECYCLEEXPIRATION_DELETE_EVENT = "s3:LifecycleExpiration:Delete"
+    LIFECYCLE_EXPIRATION_DELETE_MARKER_CREATED_EVENT = (
+        "s3:LifecycleExpiration:DeleteMarkerCreated"
+    )
+    OBJECT_TAGGING_EVENT = "s3:ObjectTagging:*"
+    OBJECT_TAGGING_PUT_EVENT = "s3:ObjectTagging:Put"
+    OBJECTTAGGING_DELETE_EVENT = "s3:ObjectTagging:Delete"
+
+    @classmethod
+    def events(self) -> List[str]:
+        return sorted([item.value for item in S3NotificationEvent])
 
 
 def _get_s3_event(
@@ -41,7 +83,9 @@ def _get_region_from_arn(arn: str) -> str:
     return arn.split(":")[3]
 
 
-def send_event(account_id: str, event_name: str, bucket: Any, key: Any) -> None:
+def send_event(
+    account_id: str, event_name: S3NotificationEvent, bucket: Any, key: Any
+) -> None:
     if bucket.notification_configuration is None:
         return
 
diff --git a/moto/s3/responses.py b/moto/s3/responses.py
index efde8dea1..8d666ebf1 100644
--- a/moto/s3/responses.py
+++ b/moto/s3/responses.py
@@ -63,6 +63,7 @@ from .models import (
     get_canned_acl,
     s3_backends,
 )
+from .notifications import S3NotificationEvent
 from .select_object_content import serialize_select
 from .utils import (
     ARCHIVE_STORAGE_CLASSES,
@@ -2113,18 +2114,6 @@ class S3Response(BaseResponse):
             ("CloudFunction", "lambda"),
         ]
 
-        event_names = [
-            "s3:ReducedRedundancyLostObject",
-            "s3:ObjectCreated:*",
-            "s3:ObjectCreated:Put",
-            "s3:ObjectCreated:Post",
-            "s3:ObjectCreated:Copy",
-            "s3:ObjectCreated:CompleteMultipartUpload",
-            "s3:ObjectRemoved:*",
-            "s3:ObjectRemoved:Delete",
-            "s3:ObjectRemoved:DeleteMarkerCreated",
-        ]
-
         found_notifications = (
             0  # Tripwire -- if this is not ever set, then there were no notifications
         )
@@ -2151,8 +2140,8 @@ class S3Response(BaseResponse):
                         n["Event"] = [n["Event"]]
 
                     for event in n["Event"]:
-                        if event not in event_names:
-                            raise InvalidNotificationEvent()
+                        if event not in S3NotificationEvent.events():
+                            raise InvalidNotificationEvent(event)
 
                     # Parse out the filters:
                     if n.get("Filter"):
diff --git a/moto/sagemaker/exceptions.py b/moto/sagemaker/exceptions.py
index 8f5750573..c2286b9fe 100644
--- a/moto/sagemaker/exceptions.py
+++ b/moto/sagemaker/exceptions.py
@@ -41,6 +41,10 @@ class AWSValidationException(AWSError):
     TYPE = "ValidationException"
 
 
+class ResourceInUseException(AWSError):
+    TYPE = "ResourceInUse"
+
+
 class ResourceNotFound(JsonRESTError):
     def __init__(self, message: str):
         super().__init__(__class__.__name__, message)  # type: ignore
diff --git a/moto/sagemaker/models.py b/moto/sagemaker/models.py
index c284c65ec..241dbdfa3 100644
--- a/moto/sagemaker/models.py
+++ b/moto/sagemaker/models.py
@@ -14,6 +14,7 @@ from moto.utilities.paginator import paginate
 from .exceptions import (
     AWSValidationException,
     MissingModel,
+    ResourceInUseException,
     ResourceNotFound,
     ValidationError,
 )
@@ -1006,6 +1007,9 @@ class FeatureGroup(BaseObject):
             "Catalog": "AwsDataCatalog",
             "Database": "sagemaker_featurestore",
         }
+        offline_store_config["S3StorageConfig"][
+            "ResolvedOutputS3Uri"
+        ] = f'{offline_store_config["S3StorageConfig"]["S3Uri"]}/{account_id}/{region_name}/offline-store/{feature_group_name}-{int(datetime.now().timestamp())}/data'
 
         self.offline_store_config = offline_store_config
         self.role_arn = role_arn
@@ -3529,6 +3533,17 @@ class SageMakerModelBackend(BaseBackend):
         role_arn: str,
         tags: Any,
     ) -> str:
+        feature_group_arn = arn_formatter(
+            region_name=self.region_name,
+            account_id=self.account_id,
+            _type="feature-group",
+            _id=f"{feature_group_name.lower()}",
+        )
+        if feature_group_arn in self.feature_groups:
+            raise ResourceInUseException(
+                message=f"An error occurred (ResourceInUse) when calling the CreateFeatureGroup operation: Resource Already Exists: FeatureGroup with name {feature_group_name} already exists. Choose a different name.\nInfo: Feature Group '{feature_group_name}' already exists."
+            )
+
         feature_group = FeatureGroup(
             feature_group_name=feature_group_name,
             record_identifier_feature_name=record_identifier_feature_name,


Test patch:
diff --git a/tests/test_s3/test_s3.py b/tests/test_s3/test_s3.py
index f9deba6bc..6b6312355 100644
--- a/tests/test_s3/test_s3.py
+++ b/tests/test_s3/test_s3.py
@@ -2336,7 +2336,7 @@ def test_put_bucket_notification_errors():
     assert err.value.response["Error"]["Code"] == "InvalidArgument"
     assert (
         err.value.response["Error"]["Message"]
-        == "The event is not supported for notifications"
+        == "The event 'notarealeventname' is not supported for notifications. Supported events are as follows: ['s3:IntelligentTiering', 's3:LifecycleExpiration:*', 's3:LifecycleExpiration:Delete', 's3:LifecycleExpiration:DeleteMarkerCreated', 's3:LifecycleTransition', 's3:ObjectAcl:Put', 's3:ObjectCreated:*', 's3:ObjectCreated:CompleteMultipartUpload', 's3:ObjectCreated:Copy', 's3:ObjectCreated:Post', 's3:ObjectCreated:Put', 's3:ObjectRemoved:*', 's3:ObjectRemoved:Delete', 's3:ObjectRemoved:DeleteMarkerCreated', 's3:ObjectRestore:*', 's3:ObjectRestore:Completed', 's3:ObjectRestore:Delete', 's3:ObjectRestore:Post', 's3:ObjectTagging:*', 's3:ObjectTagging:Delete', 's3:ObjectTagging:Put', 's3:ReducedRedundancyLostObject', 's3:Replication:*', 's3:Replication:OperationFailedReplication', 's3:Replication:OperationMissedThreshold', 's3:Replication:OperationNotTracked', 's3:Replication:OperationReplicatedAfterThreshold']"
     )
 
 
diff --git a/tests/test_sagemaker/test_sagemaker_feature_groups.py b/tests/test_sagemaker/test_sagemaker_feature_groups.py
index c3f4a1475..029b042c3 100644
--- a/tests/test_sagemaker/test_sagemaker_feature_groups.py
+++ b/tests/test_sagemaker/test_sagemaker_feature_groups.py
@@ -3,6 +3,8 @@ import re
 from datetime import datetime
 
 import boto3
+import pytest
+from botocore.exceptions import ClientError
 
 from moto import mock_sagemaker
 
@@ -34,6 +36,29 @@ def test_create_feature_group():
         == "arn:aws:sagemaker:us-east-2:123456789012:feature-group/some-feature-group-name"
     )
 
+    with pytest.raises(ClientError) as raised_exception:
+        client.create_feature_group(
+            FeatureGroupName="some-feature-group-name",
+            RecordIdentifierFeatureName="some_record_identifier",
+            EventTimeFeatureName="EventTime",
+            FeatureDefinitions=[
+                {"FeatureName": "some_feature", "FeatureType": "String"},
+                {"FeatureName": "EventTime", "FeatureType": "Fractional"},
+                {"FeatureName": "some_record_identifier", "FeatureType": "String"},
+            ],
+            RoleArn="arn:aws:iam::123456789012:role/AWSFeatureStoreAccess",
+            OfflineStoreConfig={
+                "DisableGlueTableCreation": False,
+                "S3StorageConfig": {"S3Uri": "s3://mybucket"},
+            },
+        )
+
+    assert raised_exception.value.response["Error"]["Code"] == "ResourceInUse"
+    assert (
+        raised_exception.value.response["Error"]["Message"]
+        == "An error occurred (ResourceInUse) when calling the CreateFeatureGroup operation: Resource Already Exists: FeatureGroup with name some-feature-group-name already exists. Choose a different name.\nInfo: Feature Group 'some-feature-group-name' already exists."
+    )
+
 
 @mock_sagemaker
 def test_describe_feature_group():
@@ -55,7 +80,7 @@ def test_describe_feature_group():
         RoleArn=role_arn,
         OfflineStoreConfig={
             "DisableGlueTableCreation": False,
-            "S3StorageConfig": {"S3Uri": "s3://mybucket"},
+            "S3StorageConfig": {"S3Uri": "s3://mybucket/some-folder/some-subfolder"},
         },
     )
     resp = client.describe_feature_group(FeatureGroupName=feature_group_name)
@@ -70,7 +95,7 @@ def test_describe_feature_group():
     assert resp["FeatureDefinitions"] == feature_definitions
     assert resp["RoleArn"] == role_arn
     assert re.match(
-        r"^some_feature_group_name_[0-9]+$",
+        f"^{feature_group_name.replace('-', '_')}_[0-9]+$",
         resp["OfflineStoreConfig"]["DataCatalogConfig"]["TableName"],
     )
     assert (
@@ -80,6 +105,13 @@ def test_describe_feature_group():
         resp["OfflineStoreConfig"]["DataCatalogConfig"]["Database"]
         == "sagemaker_featurestore"
     )
-    assert resp["OfflineStoreConfig"]["S3StorageConfig"]["S3Uri"] == "s3://mybucket"
+    assert (
+        resp["OfflineStoreConfig"]["S3StorageConfig"]["S3Uri"]
+        == "s3://mybucket/some-folder/some-subfolder"
+    )
+    assert re.match(
+        f"^s3://mybucket/some-folder/some-subfolder/123456789012/us-east-2/offline-store/{feature_group_name}-[0-9]+/data$",
+        resp["OfflineStoreConfig"]["S3StorageConfig"]["ResolvedOutputS3Uri"],
+    )
     assert isinstance(resp["CreationTime"], datetime)
     assert resp["FeatureGroupStatus"] == "Created"