Skip to content

Commit b9d7c20

Browse files
authored
S3: Adding notification for eventbridge (#7252)
1 parent 59248f3 commit b9d7c20

6 files changed

Lines changed: 307 additions & 1 deletion

File tree

moto/s3/models.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,7 @@ def __init__(
883883
topic: Optional[List[Dict[str, Any]]] = None,
884884
queue: Optional[List[Dict[str, Any]]] = None,
885885
cloud_function: Optional[List[Dict[str, Any]]] = None,
886+
event_bridge: Optional[Dict[str, Any]] = None,
886887
):
887888
self.topic = (
888889
[
@@ -923,6 +924,7 @@ def __init__(
923924
if cloud_function
924925
else []
925926
)
927+
self.event_bridge = event_bridge
926928

927929
def to_config_dict(self) -> Dict[str, Any]:
928930
data: Dict[str, Any] = {"configurations": {}}
@@ -945,6 +947,8 @@ def to_config_dict(self) -> Dict[str, Any]:
945947
cf_config["type"] = "LambdaConfiguration"
946948
data["configurations"][cloud_function.id] = cf_config
947949

950+
if self.event_bridge is not None:
951+
data["configurations"]["EventBridgeConfiguration"] = self.event_bridge
948952
return data
949953

950954

@@ -1325,6 +1329,7 @@ def set_notification_configuration(
13251329
topic=notification_config.get("TopicConfiguration"),
13261330
queue=notification_config.get("QueueConfiguration"),
13271331
cloud_function=notification_config.get("CloudFunctionConfiguration"),
1332+
event_bridge=notification_config.get("EventBridgeConfiguration"),
13281333
)
13291334

13301335
# Validate that the region is correct:
@@ -2315,9 +2320,9 @@ def put_bucket_notification_configuration(
23152320
- AWSLambda
23162321
- SNS
23172322
- SQS
2323+
- EventBridge
23182324
23192325
For the following events:
2320-
23212326
- 's3:ObjectCreated:Copy'
23222327
- 's3:ObjectCreated:Put'
23232328
"""

moto/s3/notifications.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
import copy
12
import json
23
from datetime import datetime
34
from enum import Enum
45
from typing import Any, Dict, List
56

7+
from moto.core.utils import unix_time
8+
69
_EVENT_TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f"
710

811

@@ -122,6 +125,9 @@ def send_event(
122125

123126
_send_sns_message(account_id, event_body, topic_arn, region_name)
124127

128+
if bucket.notification_configuration.event_bridge is not None:
129+
_send_event_bridge_message(account_id, bucket, event_name, key)
130+
125131

126132
def _send_sqs_message(
127133
account_id: str, event_body: Any, queue_name: str, region_name: str
@@ -157,6 +163,98 @@ def _send_sns_message(
157163
pass
158164

159165

166+
def _send_event_bridge_message(
167+
account_id: str,
168+
bucket: Any,
169+
event_name: str,
170+
key: Any,
171+
) -> None:
172+
try:
173+
from moto.events.models import events_backends
174+
from moto.events.utils import _BASE_EVENT_MESSAGE
175+
176+
event = copy.deepcopy(_BASE_EVENT_MESSAGE)
177+
event["detail-type"] = _detail_type(event_name)
178+
event["source"] = "aws.s3"
179+
event["account"] = account_id
180+
event["time"] = unix_time()
181+
event["region"] = bucket.region_name
182+
event["resources"] = [f"arn:aws:s3:::{bucket.name}"]
183+
event["detail"] = {
184+
"version": "0",
185+
"bucket": {"name": bucket.name},
186+
"object": {
187+
"key": key.name,
188+
"size": key.size,
189+
"eTag": key.etag.replace('"', ""),
190+
"version-id": key.version_id,
191+
"sequencer": "617f08299329d189",
192+
},
193+
"request-id": "N4N7GDK58NMKJ12R",
194+
"requester": "123456789012",
195+
"source-ip-address": "1.2.3.4",
196+
# ex) s3:ObjectCreated:Put -> ObjectCreated
197+
"reason": event_name.split(":")[1],
198+
}
199+
200+
events_backend = events_backends[account_id][bucket.region_name]
201+
for event_bus in events_backend.event_buses.values():
202+
for rule in event_bus.rules.values():
203+
rule.send_to_targets(event)
204+
205+
except: # noqa
206+
# This is an async action in AWS.
207+
# Even if this part fails, the calling function should pass, so catch all errors
208+
# Possible exceptions that could be thrown:
209+
# - EventBridge does not exist
210+
pass
211+
212+
213+
def _detail_type(event_name: str) -> str:
214+
"""Detail type field values for event messages of s3 EventBridge notification
215+
216+
document: https://docs.aws.amazon.com/AmazonS3/latest/userguide/EventBridge.html
217+
"""
218+
if event_name in [e for e in S3NotificationEvent.events() if "ObjectCreated" in e]:
219+
return "Object Created"
220+
elif event_name in [
221+
e
222+
for e in S3NotificationEvent.events()
223+
if "ObjectRemoved" in e or "LifecycleExpiration" in e
224+
]:
225+
return "Object Deleted"
226+
elif event_name in [
227+
e for e in S3NotificationEvent.events() if "ObjectRestore" in e
228+
]:
229+
if event_name == S3NotificationEvent.OBJECT_RESTORE_POST_EVENT:
230+
return "Object Restore Initiated"
231+
elif event_name == S3NotificationEvent.OBJECT_RESTORE_COMPLETED_EVENT:
232+
return "Object Restore Completed"
233+
else:
234+
# s3:ObjectRestore:Delete event
235+
return "Object Restore Expired"
236+
elif event_name in [
237+
e for e in S3NotificationEvent.events() if "LifecycleTransition" in e
238+
]:
239+
return "Object Storage Class Changed"
240+
elif event_name in [
241+
e for e in S3NotificationEvent.events() if "IntelligentTiering" in e
242+
]:
243+
return "Object Access Tier Changed"
244+
elif event_name in [e for e in S3NotificationEvent.events() if "ObjectAcl" in e]:
245+
return "Object ACL Updated"
246+
elif event_name in [e for e in S3NotificationEvent.events() if "ObjectTagging"]:
247+
if event_name == S3NotificationEvent.OBJECT_TAGGING_PUT_EVENT:
248+
return "Object Tags Added"
249+
else:
250+
# s3:ObjectTagging:Delete event
251+
return "Object Tags Deleted"
252+
else:
253+
raise ValueError(
254+
f"unsupported event `{event_name}` for s3 eventbridge notification (https://docs.aws.amazon.com/AmazonS3/latest/userguide/EventBridge.html)"
255+
)
256+
257+
160258
def _invoke_awslambda(
161259
account_id: str, event_body: Any, fn_arn: str, region_name: str
162260
) -> None:

moto/s3/responses.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2092,12 +2092,19 @@ def _notification_config_from_body(self) -> Dict[str, Any]:
20922092
("Topic", "sns"),
20932093
("Queue", "sqs"),
20942094
("CloudFunction", "lambda"),
2095+
("EventBridge", "events"),
20952096
]
20962097

20972098
found_notifications = (
20982099
0 # Tripwire -- if this is not ever set, then there were no notifications
20992100
)
21002101
for name, arn_string in notification_fields:
2102+
# EventBridgeConfiguration is passed as an empty dict.
2103+
if name == "EventBridge":
2104+
events_field = f"{name}Configuration"
2105+
if events_field in parsed_xml["NotificationConfiguration"]:
2106+
parsed_xml["NotificationConfiguration"][events_field] = {}
2107+
found_notifications += 1
21012108
# 1st verify that the proper notification configuration has been passed in (with an ARN that is close
21022109
# to being correct -- nothing too complex in the ARN logic):
21032110
the_notification = parsed_xml["NotificationConfiguration"].get(

tests/test_s3/test_s3_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ def test_s3_notification_config_dict():
339339
},
340340
}
341341
],
342+
"EventBridgeConfiguration": {},
342343
}
343344

344345
s3_config_query.backends[DEFAULT_ACCOUNT_ID][
@@ -389,6 +390,7 @@ def test_s3_notification_config_dict():
389390
"queueARN": "arn:aws:lambda:us-west-2:012345678910:function:mylambda",
390391
"type": "LambdaConfiguration",
391392
},
393+
"EventBridgeConfiguration": {},
392394
}
393395
}
394396

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import json
2+
from uuid import uuid4
3+
4+
import boto3
5+
6+
from moto import mock_aws
7+
from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
8+
9+
REGION_NAME = "us-east-1"
10+
11+
12+
@mock_aws
13+
def test_pub_object_notification():
14+
s3_res = boto3.resource("s3", region_name=REGION_NAME)
15+
s3_client = boto3.client("s3", region_name=REGION_NAME)
16+
events_client = boto3.client("events", region_name=REGION_NAME)
17+
logs_client = boto3.client("logs", region_name=REGION_NAME)
18+
19+
rule_name = "test-rule"
20+
events_client.put_rule(
21+
Name=rule_name, EventPattern=json.dumps({"account": [ACCOUNT_ID]})
22+
)
23+
log_group_name = "/test-group"
24+
logs_client.create_log_group(logGroupName=log_group_name)
25+
events_client.put_targets(
26+
Rule=rule_name,
27+
Targets=[
28+
{
29+
"Id": "test",
30+
"Arn": f"arn:aws:logs:{REGION_NAME}:{ACCOUNT_ID}:log-group:{log_group_name}",
31+
}
32+
],
33+
)
34+
35+
# Create S3 bucket
36+
bucket_name = str(uuid4())
37+
s3_res.create_bucket(Bucket=bucket_name)
38+
39+
# Put Notification
40+
s3_client.put_bucket_notification_configuration(
41+
Bucket=bucket_name,
42+
NotificationConfiguration={"EventBridgeConfiguration": {}},
43+
)
44+
45+
# Put Object
46+
s3_client.put_object(Bucket=bucket_name, Key="keyname", Body="bodyofnewobject")
47+
48+
events = sorted(
49+
logs_client.filter_log_events(logGroupName=log_group_name)["events"],
50+
key=lambda item: item["eventId"],
51+
)
52+
assert len(events) == 1
53+
event_message = json.loads(events[0]["message"])
54+
assert event_message["detail-type"] == "Object Created"
55+
assert event_message["source"] == "aws.s3"
56+
assert event_message["account"] == ACCOUNT_ID
57+
assert event_message["region"] == REGION_NAME
58+
assert event_message["detail"]["bucket"]["name"] == bucket_name
59+
assert event_message["detail"]["reason"] == "ObjectCreated"
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import json
2+
from typing import List
3+
from unittest import SkipTest
4+
from uuid import uuid4
5+
6+
import boto3
7+
import pytest
8+
9+
from moto import mock_aws, settings
10+
from moto.core import DEFAULT_ACCOUNT_ID as ACCOUNT_ID
11+
from moto.s3.models import FakeBucket, FakeKey
12+
from moto.s3.notifications import (
13+
S3NotificationEvent,
14+
_detail_type,
15+
_send_event_bridge_message,
16+
)
17+
18+
REGION_NAME = "us-east-1"
19+
20+
21+
@pytest.mark.parametrize(
22+
"event_names, expected_event_message",
23+
[
24+
(
25+
[
26+
S3NotificationEvent.OBJECT_CREATED_PUT_EVENT,
27+
S3NotificationEvent.OBJECT_CREATED_POST_EVENT,
28+
S3NotificationEvent.OBJECT_CREATED_COPY_EVENT,
29+
S3NotificationEvent.OBJECT_CREATED_COMPLETE_MULTIPART_UPLOAD_EVENT,
30+
],
31+
"Object Created",
32+
),
33+
(
34+
[
35+
S3NotificationEvent.OBJECT_REMOVED_DELETE_EVENT,
36+
S3NotificationEvent.OBJECT_REMOVED_DELETE_MARKER_CREATED_EVENT,
37+
],
38+
"Object Deleted",
39+
),
40+
([S3NotificationEvent.OBJECT_RESTORE_POST_EVENT], "Object Restore Initiated"),
41+
(
42+
[S3NotificationEvent.OBJECT_RESTORE_COMPLETED_EVENT],
43+
"Object Restore Completed",
44+
),
45+
(
46+
[S3NotificationEvent.OBJECT_RESTORE_DELETE_EVENT],
47+
"Object Restore Expired",
48+
),
49+
(
50+
[S3NotificationEvent.LIFECYCLE_TRANSITION_EVENT],
51+
"Object Storage Class Changed",
52+
),
53+
([S3NotificationEvent.INTELLIGENT_TIERING_EVENT], "Object Access Tier Changed"),
54+
([S3NotificationEvent.OBJECT_ACL_EVENT], "Object ACL Updated"),
55+
([S3NotificationEvent.OBJECT_TAGGING_PUT_EVENT], "Object Tags Added"),
56+
([S3NotificationEvent.OBJECT_TAGGING_DELETE_EVENT], "Object Tags Deleted"),
57+
],
58+
)
59+
def test_detail_type(event_names: List[str], expected_event_message: str):
60+
for event_name in event_names:
61+
assert _detail_type(event_name) == expected_event_message
62+
63+
64+
def test_detail_type_unknown_event():
65+
with pytest.raises(ValueError) as ex:
66+
_detail_type("unknown event")
67+
assert (
68+
str(ex.value)
69+
== "unsupported event `unknown event` for s3 eventbridge notification (https://docs.aws.amazon.com/AmazonS3/latest/userguide/EventBridge.html)"
70+
)
71+
72+
73+
@mock_aws
74+
def test_send_event_bridge_message():
75+
# setup mocks
76+
events_client = boto3.client("events", region_name=REGION_NAME)
77+
logs_client = boto3.client("logs", region_name=REGION_NAME)
78+
rule_name = "test-rule"
79+
events_client.put_rule(
80+
Name=rule_name, EventPattern=json.dumps({"account": [ACCOUNT_ID]})
81+
)
82+
log_group_name = "/test-group"
83+
logs_client.create_log_group(logGroupName=log_group_name)
84+
mocked_bucket = FakeBucket(str(uuid4()), ACCOUNT_ID, REGION_NAME)
85+
mocked_key = FakeKey(
86+
"test-key", bytes("test content", encoding="utf-8"), ACCOUNT_ID
87+
)
88+
89+
# do nothing if event target does not exists.
90+
_send_event_bridge_message(
91+
ACCOUNT_ID,
92+
mocked_bucket,
93+
S3NotificationEvent.OBJECT_CREATED_PUT_EVENT,
94+
mocked_key,
95+
)
96+
assert (
97+
len(logs_client.filter_log_events(logGroupName=log_group_name)["events"]) == 0
98+
)
99+
100+
# do nothing even if an error is raised while sending events.
101+
events_client.put_targets(
102+
Rule=rule_name,
103+
Targets=[
104+
{
105+
"Id": "test",
106+
"Arn": f"arn:aws:logs:{REGION_NAME}:{ACCOUNT_ID}:log-group:{log_group_name}",
107+
}
108+
],
109+
)
110+
111+
_send_event_bridge_message(ACCOUNT_ID, mocked_bucket, "unknown-event", mocked_key)
112+
assert (
113+
len(logs_client.filter_log_events(logGroupName=log_group_name)["events"]) == 0
114+
)
115+
116+
if not settings.TEST_DECORATOR_MODE:
117+
raise SkipTest(("Doesn't quite work right with the Proxy or Server"))
118+
# an event is correctly sent to the log group.
119+
_send_event_bridge_message(
120+
ACCOUNT_ID,
121+
mocked_bucket,
122+
S3NotificationEvent.OBJECT_CREATED_PUT_EVENT,
123+
mocked_key,
124+
)
125+
events = logs_client.filter_log_events(logGroupName=log_group_name)["events"]
126+
assert len(events) == 1
127+
event_msg = json.loads(events[0]["message"])
128+
assert event_msg["detail-type"] == "Object Created"
129+
assert event_msg["source"] == "aws.s3"
130+
assert event_msg["region"] == REGION_NAME
131+
assert event_msg["resources"] == [f"arn:aws:s3:::{mocked_bucket.name}"]
132+
event_detail = event_msg["detail"]
133+
assert event_detail["bucket"] == {"name": mocked_bucket.name}
134+
assert event_detail["object"]["key"] == mocked_key.name
135+
assert event_detail["reason"] == "ObjectCreated"

0 commit comments

Comments
 (0)