Repository: getmoto/moto
Pull request #7239 (2024-01-22 16:07:55)

SageMaker: Add exception when resource already exists

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/7239?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 [(`287535a`)](https://app.codecov.io/gh/getmoto/moto/commit/287535a2a2ea02500793a6c382e4180ebbb3edfc?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) 95.91% compared to head [(`36a2ca8`)](https://app.codecov.io/gh/getmoto/moto/pull/7239?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) 95.90%.
> Report is 2 commits behind head on master.


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


```diff
@@            Coverage Diff             @@
##           master    #7239      +/-   ##
==========================================
- Coverage   95.91%   95.90%   -0.01%     
==========================================
  Files         840      840              
  Lines       82750    82756       +6     
==========================================
+ Hits        79366    79369       +3     
- Misses       3384     3387       +3     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/7239/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/7239/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `35.47% <33.33%> (-0.01%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/7239/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_down: |

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/7239?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/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"