├── tests
├── __init__.py
├── unit
│ ├── __init__.py
│ └── test_handler.py
├── integration
│ ├── __init__.py
│ └── test_ec2_event.py
└── requirements.txt
├── lambdas
├── files_generator
│ ├── requirements.txt
│ └── app.py
├── on_file_converted
│ ├── requirements.txt
│ └── app.py
├── on_file_validated
│ ├── requirements.txt
│ └── app.py
└── on_file_receive
│ ├── requirements.txt
│ └── app.py
├── conftest.py
├── images
├── architecture.png
└── flow.drawio
├── CODE_OF_CONDUCT.md
├── templates
├── api.yaml
└── non-sam-resources.yaml
├── events
├── nf-valido.xml
├── nf-invalido-placa.xml
└── nf-invalido-schema.xml
├── LICENSE
├── CONTRIBUTING.md
├── .gitignore
├── README.md
└── template.yaml
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/unit/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/integration/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/requirements.txt:
--------------------------------------------------------------------------------
1 | pytest
2 | pytest-mock
3 | boto3
--------------------------------------------------------------------------------
/lambdas/files_generator/requirements.txt:
--------------------------------------------------------------------------------
1 | aws-lambda-powertools
2 | boto3
3 | wheel
--------------------------------------------------------------------------------
/lambdas/on_file_converted/requirements.txt:
--------------------------------------------------------------------------------
1 | aws-lambda-powertools
2 | boto3
3 | wheel
--------------------------------------------------------------------------------
/lambdas/on_file_validated/requirements.txt:
--------------------------------------------------------------------------------
1 | aws-lambda-powertools
2 | boto3
3 | wheel
--------------------------------------------------------------------------------
/lambdas/on_file_receive/requirements.txt:
--------------------------------------------------------------------------------
1 | aws-lambda-powertools
2 | xmltodict
3 | boto3
4 | wheel
--------------------------------------------------------------------------------
/conftest.py:
--------------------------------------------------------------------------------
1 | import sys, os
2 |
3 | here = os.path.abspath("hello_world_function")
4 | sys.path.insert(0, here)
--------------------------------------------------------------------------------
/images/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/aws-samples/event-driven-arch-eventbridge-lambda/HEAD/images/architecture.png
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | ## Code of Conduct
2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
4 | opensource-codeofconduct@amazon.com with any additional questions or comments.
5 |
--------------------------------------------------------------------------------
/templates/api.yaml:
--------------------------------------------------------------------------------
1 | openapi: "3.0.1"
2 | info:
3 | title: "Blogpost API"
4 | paths:
5 | /filesgenerator:
6 | post:
7 | responses:
8 | default:
9 | description: "EventBridge response"
10 | x-amazon-apigateway-integration:
11 | integrationSubtype: "EventBridge-PutEvents"
12 | credentials:
13 | Fn::GetAtt: [FilesGeneratorAPIRole, Arn]
14 | requestParameters:
15 | Detail: "$request.body"
16 | DetailType: "file_generator_request"
17 | Source: "NFProcessor.api"
18 | payloadFormatVersion: "1.0"
19 | type: "aws_proxy"
20 | connectionType: "INTERNET"
--------------------------------------------------------------------------------
/templates/non-sam-resources.yaml:
--------------------------------------------------------------------------------
1 | AWSTemplateFormatVersion: 2010-09-09
2 |
3 | Parameters:
4 | CloudTrailBucketName:
5 | Type: String
6 | Description: Bucket that will be used by cloudtrail
7 | Resources:
8 | EventBridgeTrail:
9 | Type: AWS::CloudTrail::Trail
10 | Properties:
11 | EventSelectors:
12 | - DataResources:
13 | - Type: AWS::S3::Object
14 | Values:
15 | - !Sub "arn:aws:s3:::"
16 | IncludeManagementEvents: false
17 | ReadWriteType: All
18 | IncludeGlobalServiceEvents: false
19 | IsLogging: true
20 | S3BucketName: !Ref CloudTrailBucketName
21 | TrailName: "blogpost-eventdriven-trail"
--------------------------------------------------------------------------------
/events/nf-valido.xml:
--------------------------------------------------------------------------------
1 |
2 | 2
3 | N
4 | 2021-07-23T21:14:00-03:00
5 |
6 | 11
7 | 2021-07-23T21:14:00-03:00
8 | 76500680000174
9 | 012345678901234
10 | 1.970942
11 | 6.680008
12 | E
13 | PIA2A19
14 | 3
15 | 120
16 | cXVhZXF1YWVxdWFlcXVhZQ==
17 | 95
18 | 120
19 | 3
20 |
21 |
--------------------------------------------------------------------------------
/events/nf-invalido-placa.xml:
--------------------------------------------------------------------------------
1 |
2 | 2
3 | N
4 | 2021-07-23T21:14:00-03:00
5 |
6 | 11
7 | 2021-07-23T21:14:00-03:00
8 | 76500680000174
9 | 012345678901234
10 | 1.970942
11 | 6.680008
12 | E
13 | PIA2A199
14 | 3
15 | 120
16 | cXVhZXF1YWVxdWFlcXVhZQ==
17 | 95
18 | 120
19 | 3
20 |
21 |
--------------------------------------------------------------------------------
/events/nf-invalido-schema.xml:
--------------------------------------------------------------------------------
1 |
2 | 2
3 | N
4 | 2021-07-23T21:14:00-03:00
5 |
6 | 11
7 | 2021-07-23T21:14:00-03:00
8 | 76500680000174
9 | 012345678901234
10 | 1.970942
11 | 6.680008
12 | E
13 | PIA2A191
14 | 3
15 | 120
16 | cXVhZXF1YWVxdWFlcXVhZQ==
17 | 95
18 | 120
19 | 3
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | this software and associated documentation files (the "Software"), to deal in
5 | the Software without restriction, including without limitation the rights to
6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7 | the Software, and to permit persons to whom the Software is furnished to do so.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15 |
16 |
--------------------------------------------------------------------------------
/tests/unit/test_handler.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from hello_world import app
4 | from model.aws.ec2 import AWSEvent
5 | from model.aws.ec2.ec2_instance_state_change_notification import EC2InstanceStateChangeNotification
6 | from model.aws.ec2 import Marshaller
7 |
8 | @pytest.fixture()
9 | def eventBridgeec2InstanceEvent():
10 | """ Generates EventBridge EC2 Instance Notification Event"""
11 |
12 | return {
13 | "version":"0",
14 | "id":"7bf73129-1428-4cd3-a780-95db273d1602",
15 | "detail-type":"EC2 Instance State-change Notification",
16 | "source":"aws.ec2",
17 | "account":"123456789012",
18 | "time":"2015-11-11T21:29:54Z",
19 | "region":"us-east-1",
20 | "resources":[
21 | "arn:aws:ec2:us-east-1:123456789012:instance/i-abcd1111"
22 | ],
23 | "detail":{
24 | "instance-id":"i-abcd1111",
25 | "state":"pending"
26 | }
27 | }
28 |
29 |
30 | def test_lambda_handler(eventBridgeec2InstanceEvent, mocker):
31 |
32 | ret = app.lambda_handler(eventBridgeec2InstanceEvent, "")
33 |
34 | awsEventRet:AWSEvent = Marshaller.unmarshall(ret, AWSEvent)
35 | detailRet:EC2InstanceStateChangeNotification = awsEventRet.detail
36 |
37 | assert detailRet.instance_id == "i-abcd1111"
38 | assert awsEventRet.detail_type.startswith("HelloWorldFunction updated event of ")
--------------------------------------------------------------------------------
/lambdas/on_file_validated/app.py:
--------------------------------------------------------------------------------
1 | from aws_lambda_powertools.logging.logger import Logger
2 | import boto3
3 | import os
4 | from datetime import datetime
5 |
6 | logger = Logger()
7 | ddb = boto3.resource('dynamodb')
8 | ddb_resource = boto3.resource('dynamodb')
9 | _ddb_table_name = os.getenv("DDB_TABLE_NAME")
10 |
11 |
12 | def handler(event, context):
13 | filename = event["detail"]["filename"]
14 | validationMessage = event["detail"]["validationMessage"]
15 |
16 | saveToDatabase(filename, validationMessage)
17 |
18 | logger.info({
19 | "message": "Received request to save event for file {} on database".format(filename)
20 | })
21 |
22 |
23 | def saveToDatabase(filename, validationMessage):
24 | """
25 | Saves the record to the database
26 | """
27 | table = ddb_resource.Table(_ddb_table_name)
28 | status = "OK" if validationMessage is None else "NOT_OK"
29 | logger.info({
30 | "message": "Saving record on ddb for file {} with status {}".format(filename, status),
31 | "validationMessage": validationMessage
32 | })
33 | response= table.put_item(
34 | Item={
35 | 'filename': filename,
36 | 'status': status,
37 | 'datetime': datetime.utcnow().isoformat(),
38 | 'validation_error': validationMessage
39 | }
40 | )
41 |
42 | logger.debug({
43 | "message": "Return from DDB",
44 | "ddb_return": response
45 | })
--------------------------------------------------------------------------------
/lambdas/on_file_converted/app.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 | from aws_lambda_powertools.logging.logger import Logger
3 | import json
4 | import boto3
5 | import json
6 | import datetime
7 |
8 | logger = Logger()
9 |
10 | def handler(event, context):
11 | filename = event["detail"]["filename"]
12 | fileContent = event["detail"]
13 |
14 | logger.info({
15 | "message": "Received event for file {}".format(filename),
16 | "fileContent": fileContent
17 | })
18 |
19 | validationMessage = validateContent(filename, fileContent)
20 | generateEvent(json.dumps({"filename": filename, "validationMessage": validationMessage}))
21 |
22 | def validateContent(filename, payload):
23 | """
24 | Validates the json payload
25 | """
26 | logger.debug({
27 | "message": "Performing file validation for file{}".format(filename)
28 | })
29 |
30 | if len(payload["nf"]["infLeitura"]["placa"]) != 7:
31 | logger.info({
32 | "message": "Field placa is invalid"
33 | })
34 | return "PLACA_INVALIDA"
35 | return None
36 |
37 |
38 | def generateEvent(payload, status="file-validated"):
39 | eventBridge = boto3.client("events")
40 | logger.info({
41 | "message": "Sending event {}".format(status),
42 | "payload": payload
43 | })
44 | event = {
45 | "Time": datetime.datetime.now(),
46 | "Source": "NFProcessor.file_validator",
47 | "DetailType": status,
48 | "Detail": payload
49 | }
50 | logger.debug({
51 | "message": "event final payload",
52 | "payload": payload
53 | })
54 |
55 | eventBridgePutReturn = eventBridge.put_events(Entries = [event])
56 | logger.debug({
57 | "message": "Event Bridge PUT return",
58 | "eventBridgePutReturn": eventBridgePutReturn
59 | })
--------------------------------------------------------------------------------
/lambdas/on_file_receive/app.py:
--------------------------------------------------------------------------------
1 | from aws_lambda_powertools.logging.logger import Logger
2 | import json
3 | import xmltodict
4 | import boto3
5 | import datetime
6 |
7 | logger = Logger()
8 | s3 = boto3.resource('s3')
9 | eventBridge = boto3.client("events")
10 |
11 | def handler(event, context):
12 | bucket = event["detail"]["bucket"]["name"]
13 | key = event["detail"]["object"]["key"]
14 |
15 | logger.debug({
16 | "message": "Starting to process file {} on bucket{}".format(key, bucket),
17 | "file": key
18 | })
19 |
20 | jsonPayload = convertFromXmlToJson(bucket, key)
21 | if jsonPayload is None:
22 | return None
23 |
24 | generateEvent(jsonPayload)
25 |
26 | def convertFromXmlToJson(bucket, key):
27 | fileContent = s3.Object(bucket, key).get()['Body'].read().decode('utf-8')
28 | try:
29 | jsonContent = xmltodict.parse(fileContent)
30 | jsonContent.update({"filename": key})
31 | return json.dumps(jsonContent)
32 | except Exception as e:
33 | logger.error({
34 | "message": "Error converting file {} on bucket{}".format(key, bucket),
35 | "file": key,
36 | },
37 | exc_info=True)
38 | # in case of errors, we send the content a little bit more strucuted
39 | # in order to help on further steps
40 | payload = json.dumps({
41 | "filename": key,
42 | "exception": str(e),
43 | "fileContent": str(fileContent)
44 | })
45 | generateEvent(payload, status="file-converted-error")
46 | return None
47 |
48 | def generateEvent(payload, status="file-converted"):
49 | logger.info({
50 | "message": "Sending event {}".format(status),
51 | "payload": payload
52 | })
53 | event = {
54 | "Time": datetime.datetime.now(),
55 | "Source": "NFProcessor.file_receiver",
56 | "DetailType": status,
57 | "Detail": payload
58 | }
59 | logger.debug({
60 | "message": "event final payload",
61 | "payload": payload
62 | })
63 |
64 | eventBridgePutReturn = eventBridge.put_events(Entries = [event])
65 | logger.debug({
66 | "message": "Event Bridge PUT return",
67 | "eventBridgePutReturn": eventBridgePutReturn
68 | })
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guidelines
2 |
3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional
4 | documentation, we greatly value feedback and contributions from our community.
5 |
6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary
7 | information to effectively respond to your bug report or contribution.
8 |
9 |
10 | ## Reporting Bugs/Feature Requests
11 |
12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features.
13 |
14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already
15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
16 |
17 | * A reproducible test case or series of steps
18 | * The version of our code being used
19 | * Any modifications you've made relevant to the bug
20 | * Anything unusual about your environment or deployment
21 |
22 |
23 | ## Contributing via Pull Requests
24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that:
25 |
26 | 1. You are working against the latest source on the *main* branch.
27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already.
28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted.
29 |
30 | To send us a pull request, please:
31 |
32 | 1. Fork the repository.
33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change.
34 | 3. Ensure local tests pass.
35 | 4. Commit to your fork using clear commit messages.
36 | 5. Send us a pull request, answering any default questions in the pull request interface.
37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
38 |
39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and
40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/).
41 |
42 |
43 | ## Finding contributions to work on
44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start.
45 |
46 |
47 | ## Code of Conduct
48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
50 | opensource-codeofconduct@amazon.com with any additional questions or comments.
51 |
52 |
53 | ## Security issue notifications
54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue.
55 |
56 |
57 | ## Licensing
58 |
59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
60 |
--------------------------------------------------------------------------------
/lambdas/files_generator/app.py:
--------------------------------------------------------------------------------
1 | from aws_lambda_powertools.logging.logger import Logger
2 | from datetime import datetime
3 | import time
4 | import os
5 | import boto3
6 | import random
7 | import json
8 |
9 |
10 | logger = Logger()
11 | s3 = boto3.resource('s3')
12 | _max_allowed_files=5000
13 | _incorrect_factor = 0.20
14 |
15 | def handler(event, context):
16 | logger.debug({
17 | "event": event
18 | })
19 | bucket = os.getenv("DESTINATION_BUCKET")
20 | numberOfFiles = event["detail"]['numberOfFiles']
21 |
22 | if numberOfFiles > _max_allowed_files:
23 | logger.warning({
24 | "message": "User requested {} files, but API allows only {} at this point. Will proceed with {} requests".format(numberOfFiles, _max_allowed_files, _max_allowed_files)
25 | })
26 | numberOfFiles = _max_allowed_files
27 |
28 | logger.info({
29 | "message": "Starting to generate {} files on bucket{}".format(numberOfFiles, bucket)
30 | })
31 |
32 | generateFiles(bucket, numberOfFiles)
33 |
34 | logger.info({
35 | "message": "Finished to generate files"
36 | })
37 |
38 | return {
39 | "statusCode": 200,
40 | "body": json.dumps({"message": "Generated {} files correctly AND {} with incorrect format".format(numberOfFiles, round(numberOfFiles*_incorrect_factor))}),
41 | "headers": {
42 | "Content-Type": "application/json"
43 | }
44 | }
45 |
46 | def generateFiles(bucket, numberOfFiles):
47 | xmlTemplate = """
48 | 2
49 | N
50 | {}
51 |
52 | 11
53 | 2021-07-23T21:14:00-03:00
54 | 76500680000174
55 | 012345678901234
56 | 1.970942
57 | 6.680008
58 | E
59 | {}
60 | 3
61 | 120
62 | cXVhZXF1YWVxdWFlcXVhZQ==
63 | 95
64 | 120
65 | 3
66 |
67 | """
68 |
69 | #Generating WELL-FORMED files
70 | for x in range(numberOfFiles):
71 | logger.debug({
72 | "message": "Generating file {} out of {}".format(x, numberOfFiles)
73 | })
74 | isValid = bool(random.getrandbits(1))
75 | placa = "AWS2022" if isValid else "AWS20222"
76 | timeNow = datetime.now()
77 | xmlBody = xmlTemplate.format(timeNow, placa).encode("utf-8")
78 | key = "nf-{}.xml".format(time.time())
79 | s3.Bucket(bucket).put_object(Key=key, Body=xmlBody)
80 |
81 | #Generating BAD-FORMED files(20%)
82 | numberOfIncorrectFiles = round(numberOfFiles * _incorrect_factor)
83 | for x in range(numberOfIncorrectFiles):
84 | logger.debug({
85 | "message": "Generating bad-formated file {} out of {}".format(x, numberOfIncorrectFiles)
86 | })
87 | placa = "AWS2022"
88 | timeNow = datetime.now()
89 | xmlBody = xmlTemplate.format(timeNow, placa).replace("", "").encode("utf-8")
90 | key = "invalid-nf-{}.xml".format(time.time())
91 | s3.Bucket(bucket).put_object(Key=key, Body=xmlBody)
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode
3 |
4 | ### Linux ###
5 | *~
6 |
7 | # temporary files which can be created if a process still has a handle open of a deleted file
8 | .fuse_hidden*
9 |
10 | # KDE directory preferences
11 | .directory
12 |
13 | # Linux trash folder which might appear on any partition or disk
14 | .Trash-*
15 |
16 | # .nfs files are created when an open file is removed but is still being accessed
17 | .nfs*
18 |
19 | ### OSX ###
20 | *.DS_Store
21 | .AppleDouble
22 | .LSOverride
23 |
24 | # Icon must end with two \r
25 | Icon
26 |
27 | # Thumbnails
28 | ._*
29 |
30 | # Files that might appear in the root of a volume
31 | .DocumentRevisions-V100
32 | .fseventsd
33 | .Spotlight-V100
34 | .TemporaryItems
35 | .Trashes
36 | .VolumeIcon.icns
37 | .com.apple.timemachine.donotpresent
38 |
39 | # Directories potentially created on remote AFP share
40 | .AppleDB
41 | .AppleDesktop
42 | Network Trash Folder
43 | Temporary Items
44 | .apdisk
45 |
46 | ### PyCharm ###
47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
49 |
50 | # User-specific stuff:
51 | .idea/**/workspace.xml
52 | .idea/**/tasks.xml
53 | .idea/dictionaries
54 |
55 | # Sensitive or high-churn files:
56 | .idea/**/dataSources/
57 | .idea/**/dataSources.ids
58 | .idea/**/dataSources.xml
59 | .idea/**/dataSources.local.xml
60 | .idea/**/sqlDataSources.xml
61 | .idea/**/dynamic.xml
62 | .idea/**/uiDesigner.xml
63 |
64 | # Gradle:
65 | .idea/**/gradle.xml
66 | .idea/**/libraries
67 |
68 | # CMake
69 | cmake-build-debug/
70 |
71 | # Mongo Explorer plugin:
72 | .idea/**/mongoSettings.xml
73 |
74 | ## File-based project format:
75 | *.iws
76 |
77 | ## Plugin-specific files:
78 |
79 | # IntelliJ
80 | /out/
81 |
82 | # mpeltonen/sbt-idea plugin
83 | .idea_modules/
84 |
85 | # JIRA plugin
86 | atlassian-ide-plugin.xml
87 |
88 | # Cursive Clojure plugin
89 | .idea/replstate.xml
90 |
91 | # Ruby plugin and RubyMine
92 | /.rakeTasks
93 |
94 | # Crashlytics plugin (for Android Studio and IntelliJ)
95 | com_crashlytics_export_strings.xml
96 | crashlytics.properties
97 | crashlytics-build.properties
98 | fabric.properties
99 |
100 | ### PyCharm Patch ###
101 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
102 |
103 | # *.iml
104 | # modules.xml
105 | # .idea/misc.xml
106 | # *.ipr
107 |
108 | # Sonarlint plugin
109 | .idea/sonarlint
110 |
111 | ### Python ###
112 | # Byte-compiled / optimized / DLL files
113 | __pycache__/
114 | *.py[cod]
115 | *$py.class
116 |
117 | # C extensions
118 | *.so
119 |
120 | # Distribution / packaging
121 | .Python
122 | build/
123 | develop-eggs/
124 | dist/
125 | downloads/
126 | eggs/
127 | .eggs/
128 | lib/
129 | lib64/
130 | parts/
131 | sdist/
132 | var/
133 | wheels/
134 | *.egg-info/
135 | .installed.cfg
136 | *.egg
137 |
138 | # PyInstaller
139 | # Usually these files are written by a python script from a template
140 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
141 | *.manifest
142 | *.spec
143 |
144 | # Installer logs
145 | pip-log.txt
146 | pip-delete-this-directory.txt
147 |
148 | # Unit test / coverage reports
149 | htmlcov/
150 | .tox/
151 | .coverage
152 | .coverage.*
153 | .cache
154 | .pytest_cache/
155 | nosetests.xml
156 | coverage.xml
157 | *.cover
158 | .hypothesis/
159 |
160 | # Translations
161 | *.mo
162 | *.pot
163 |
164 | # Flask stuff:
165 | instance/
166 | .webassets-cache
167 |
168 | # Scrapy stuff:
169 | .scrapy
170 |
171 | # Sphinx documentation
172 | docs/_build/
173 |
174 | # PyBuilder
175 | target/
176 |
177 | # Jupyter Notebook
178 | .ipynb_checkpoints
179 |
180 | # pyenv
181 | .python-version
182 |
183 | # celery beat schedule file
184 | celerybeat-schedule.*
185 |
186 | # SageMath parsed files
187 | *.sage.py
188 |
189 | # Environments
190 | .env
191 | .venv
192 | env/*
193 | venv/
194 | ENV/
195 | env.bak/
196 | venv.bak/
197 |
198 | # Spyder project settings
199 | .spyderproject
200 | .spyproject
201 |
202 | # Rope project settings
203 | .ropeproject
204 |
205 | # mkdocs documentation
206 | /site
207 |
208 | # mypy
209 | .mypy_cache/
210 |
211 | ### VisualStudioCode ###
212 | .vscode/*
213 | .history
214 |
215 | ### Windows ###
216 | # Windows thumbnail cache files
217 | Thumbs.db
218 | ehthumbs.db
219 | ehthumbs_vista.db
220 |
221 | # Folder config file
222 | Desktop.ini
223 |
224 | # Recycle Bin used on file shares
225 | $RECYCLE.BIN/
226 |
227 | # Windows Installer files
228 | *.cab
229 | *.msi
230 | *.msm
231 | *.msp
232 |
233 | # Windows shortcuts
234 | *.lnk
235 |
236 | # Build folder
237 |
238 | */build/*
239 |
240 | # AWS SAM
241 | .aws-sam/*
242 | samconfig.toml
243 |
244 | # Dynamodb Local
245 | local/dynamodb
246 |
247 | # outfiles generated by manual tests
248 | outfile.txt
249 |
250 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode
251 |
--------------------------------------------------------------------------------
/tests/integration/test_ec2_event.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | from time import sleep, time
4 | from unittest import TestCase
5 |
6 | import boto3
7 | from botocore.exceptions import ClientError
8 |
9 | """
10 | Make sure env variable AWS_SAM_STACK_NAME exists with the name of the stack we are going to test.
11 | """
12 |
13 |
14 | class TestEC2Event(TestCase):
15 | """
16 | This integration test will create an EC2 and verify the lambda function is invoked by checking the cloudwatch log
17 | The EC2 instance will be deleted when test completes.
18 | """
19 |
20 | function_name: str
21 | instance_id: str # temporary EC2 instance ID
22 |
23 | @classmethod
24 | def get_and_verify_stack_name(cls) -> str:
25 | stack_name = os.environ.get("AWS_SAM_STACK_NAME")
26 | if not stack_name:
27 | raise Exception(
28 | "Cannot find env var AWS_SAM_STACK_NAME. \n"
29 | "Please setup this environment variable with the stack name where we are running integration tests."
30 | )
31 |
32 | # Verify stack exists
33 | client = boto3.client("cloudformation")
34 | try:
35 | client.describe_stacks(StackName=stack_name)
36 | except Exception as e:
37 | raise Exception(
38 | f"Cannot find stack {stack_name}. \n" f'Please make sure stack with the name "{stack_name}" exists.'
39 | ) from e
40 |
41 | return stack_name
42 |
43 | @classmethod
44 | def setUpClass(cls) -> None:
45 | stack_name = TestEC2Event.get_and_verify_stack_name()
46 |
47 | client = boto3.client("cloudformation")
48 | response = client.list_stack_resources(StackName=stack_name)
49 | resources = response["StackResourceSummaries"]
50 | function_resources = [
51 | resource for resource in resources if resource["LogicalResourceId"] == "HelloWorldFunction"
52 | ]
53 | if not function_resources:
54 | raise Exception("Cannot find HelloWorldFunction")
55 |
56 | cls.function_name = function_resources[0]["PhysicalResourceId"]
57 |
58 | def setUp(self) -> None:
59 | client = boto3.client("ec2")
60 | response = client.run_instances(
61 | ImageId="resolve:ssm:/aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2",
62 | InstanceType="t2.nano",
63 | MaxCount=1,
64 | MinCount=1,
65 | )
66 | self.assertIn("Instances", response, "Fail to create an EC2 instance")
67 | self.instance_id = response["Instances"][0]["InstanceId"]
68 |
69 | def tearDown(self) -> None:
70 | client = boto3.client("ec2")
71 | client.terminate_instances(InstanceIds=[self.instance_id])
72 |
73 | def test_ec2_event(self):
74 | log_group_name = f"/aws/lambda/{self.function_name}"
75 |
76 | # cloudwatch log might be deplayed, give it 5 retries
77 | retries = 5
78 | start_time = int(time() - 60) * 1000
79 | while retries >= 0:
80 | # use the lastest one
81 | log_stream_name = self._get_latest_log_stream_name(log_group_name)
82 | if not log_stream_name:
83 | sleep(5)
84 | continue
85 |
86 | match_events = self._get_matched_events(log_group_name, log_stream_name, start_time)
87 | if match_events:
88 | return
89 | else:
90 | logging.info(f"Cannot find matching events containing instance id {self.instance_id}, waiting")
91 | retries -= 1
92 | sleep(5)
93 |
94 | self.fail(f"Cannot find matching events containing instance id {self.instance_id} after 5 retries")
95 |
96 | def _get_latest_log_stream_name(self, log_group_name: str):
97 | """
98 | Find the name of latest log stream name in group,
99 | return None if the log group does not exists or does not have any stream
100 | (for lambda function that has never invoked before).
101 | """
102 | client = boto3.client("logs")
103 | try:
104 | response = client.describe_log_streams(
105 | logGroupName=log_group_name,
106 | orderBy="LastEventTime",
107 | descending=True,
108 | )
109 | except ClientError as e:
110 | if e.response["Error"]["Code"] == "ResourceNotFoundException":
111 | logging.info(f"Cannot find log group {log_group_name}, waiting")
112 | return None
113 | raise e
114 |
115 | log_streams = response["logStreams"]
116 | self.assertTrue(log_streams, "Cannot find log streams")
117 |
118 | # use the lastest one
119 | return log_streams[0]
120 |
121 | def _get_matched_events(self, log_group_name, log_stream_name, start_time):
122 | """
123 | Return a list of events with body containing self.instance_id after start_time
124 | """
125 | client = boto3.client("logs")
126 | response = client.get_log_events(
127 | logGroupName=log_group_name,
128 | logStreamName=log_stream_name,
129 | startTime=start_time,
130 | endTime=int(time()) * 1000,
131 | startFromHead=False,
132 | )
133 | events = response["events"]
134 | return [event for event in events if self.instance_id in event["message"]]
135 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Serverless event-driven architecture
2 |
3 | **[Feature request](https://github.com/aws-samples/event-driven-architecture-using-s3-event-notifications/issues/new)** | **Detailed blog posts - [PT-BR](https://aws.amazon.com/pt/blogs/aws-brasil/criando-uma-arquitetura-baseada-em-eventos-utilizando-amazon-eventbridge-e-amazon-lambda/)**
4 |
5 | This sample application showcases how to set up an event-driven architecture for files processing using the following services:
6 |
7 | 
8 |
9 | * [AWS Serverless Application Model(SAM)](https://aws.amazon.com/serverless/sam/)
10 | * [Amazon API Gateway](https://aws.amazon.com/api-gateway/)
11 | * [Amazon EventBridge](https://docs.aws.amazon.com/eventbridge/)
12 | * [Amazon Lambda](https://aws.amazon.com/lambda/)
13 | * [Amazon Simple Storage Service(S3)](https://aws.amazon.com/s3/)
14 | * [Amazon Simple Queue Service(SQS)](https://aws.amazon.com/sqs/)
15 |
16 |
17 | # Setup
18 |
19 | ## Requirements
20 |
21 | * [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) configured
22 | * [AWS CLI](https://aws.amazon.com/cli/) configured
23 | * [Docker](https://docs.docker.com/get-docker/) installed and running.
24 | * (if on Microsoft Windows) [Windows Powershell](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.2) installed and running
25 |
26 | ## Deploy
27 |
28 | The main template [template.yaml](template.yaml) is used to set up all resources using AWS SAM cli.
29 |
30 | To build the stack, issue the following command.
31 | **Note**: You need to have docker installed and running to perform this.
32 | ```
33 | sam build --use-container --container-env-var WRAPT_EXTENSIONS=false
34 | ```
35 |
36 | Afterwards, you will need to deploy the generated stack. The following command will do it:
37 | ```
38 | sam deploy --guided
39 | ```
40 |
41 | You'll need to provide the following:
42 |
43 | **Stack name**: a meaningfull name to identify the stack and its resources. Eg: *blogpost-eventdriven*
44 | **AWS region**: AWS region where your resources will execute. Recommendation is *us-east-1*, but any region can be used.
45 | **Confirm changes before deploy**: Allows you to choose whenever you want to confirm changes to be applied.
46 | **Allow SAM CLI IAM role creation**: Allows SAM cli to create IAM roles if necessary.
47 | **Disable rollback**: Disables automatic rollback of your changes if somethinng goes wrong during deploy.
48 | **Save arguments to configuration file**: Saves the previous answers to a file, so you can run new deploys quickier on next executions.
49 | **SAM configuration file**: Name of you arguments file.
50 | **SAM configuration environment**: Name of the profile that will be saved on the configuration file.
51 |
52 | After providing these parameters, SAM will start the deployment. It may take a few minutes to complete. Afterwards, you should see an output similar to the bellow:
53 |
54 | ```
55 | CloudFormation outputs from deployed stack
56 | ------------------------------------------------------------------------------------------------------------
57 | Outputs
58 | ------------------------------------------------------------------------------------------------------------
59 | Key CloudTrailBucket
60 | Description S3 bucket that will store the cloudtrail events for event bridge
61 | Value blogpost-eventdriven-cloudtrailbucket-xxxxxxxxxxxx
62 |
63 | Key FileReceiverBucket
64 | Description S3 bucket containing all generated files
65 | Value blogpost-eventdriven-filereceiverbucket-xxxxxxxxxxxx
66 |
67 | Key FileGenerationCommandLinuxMacOS
68 | Description Command to generate the files for testing
69 | Value curl --location --request POST \
70 | 'https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/filesgenerator' \
71 | --header 'Content-Type: application/json' --data-raw '{"numberOfFiles": 100}'
72 |
73 | Key FileGenerationCommandWindows
74 | Description Command to generate the files for testing
75 | Value $body = '{"numberOfFiles": 100}'
76 | Invoke-RestMethod -uri 'https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/filesgenerator' -method POST -body $body -ContentType "application/json"
77 | ------------------------------------------------------------------------------
78 | ```
79 |
80 | Take note of the output(e.g., copy and paste in a text file).
81 |
82 |
83 | # Running
84 | To start the workflow execution, run one of the output commands(`FileGenerationCommandLinuxMacOS` or `FileGenerationCommandWindows`, depending on your operating system accordingly).
85 |
86 | E.g., on Linux you'll likely execute:
87 |
88 | ```
89 | curl --location --request POST \
90 | 'https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com/filesgenerator' \
91 | --header 'Content-Type: application/json' --data-raw '{"numberOfFiles": 100}'
92 | ```
93 |
94 | You should get a return like the following:
95 | ```
96 | {"Entries":[{"EventId":"092d76e2-ad83-c4b7-c135-abcd12345678"}],"FailedEntryCount":0}
97 | ```
98 |
99 | Meaning an event has been created. Refer to the blogpost for further details.
100 |
101 | # Undeploy
102 | To remove all the created resources, perform the following actions on [AWS Console](http://console.aws.amazon.com):
103 |
104 | 1. Go to your AWS S3 console
105 | 2. Search for the `FileReceiver` bucket on list. Select it
106 | 3. Click on "Empty" button and confirm the exclusion.
107 | 4. Search for the `CloudTrailBucket` bucket on list. Select it
108 | 5. Click on "Empty" button and confirm the exclusion.
109 | 6. Go to AWS CloudFormation console page
110 | 7. Select the stack created using SAM
111 | 8. Click on Delete button and confirm
112 |
113 | ## Security
114 |
115 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information.
116 |
117 |
118 | ## License
119 |
120 | This library is licensed under the MIT-0 License. See the LICENSE file.
--------------------------------------------------------------------------------
/template.yaml:
--------------------------------------------------------------------------------
1 | AWSTemplateFormatVersion: '2010-09-09'
2 | Transform: AWS::Serverless-2016-10-31
3 | Description: >
4 | bp-event-driven-architecture-using-event-bridge
5 |
6 | Sample SAM Template for bp-event-driven-architecture-using-event-bridge
7 |
8 | # More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
9 | Globals:
10 | Function:
11 | Runtime: python3.11
12 | Architectures:
13 | - arm64
14 | Handler: app.handler
15 | Timeout: 3
16 | Tracing: Active
17 | Environment:
18 | Variables:
19 | LOG_LEVEL: DEBUG
20 |
21 | Resources:
22 | #cloudtrail bucket and policy for eventbridge
23 | #this cloudtrail bucket will be used on temapltes/non-sam-resources.yaml
24 | #as SAM does not handle cloudtrail creation
25 | CloudTrailBucket:
26 | Type: AWS::S3::Bucket
27 | Properties:
28 | PublicAccessBlockConfiguration:
29 | BlockPublicAcls : true
30 | BlockPublicPolicy : true
31 | IgnorePublicAcls : true
32 | RestrictPublicBuckets : true
33 |
34 | CloudTrailBucketPolicy:
35 | Type: AWS::S3::BucketPolicy
36 | Properties:
37 | Bucket: !Ref CloudTrailBucket
38 | PolicyDocument:
39 | Version: "2012-10-17"
40 | Statement:
41 | - Sid: "AWSCloudTrailAclCheck"
42 | Effect: Allow
43 | Principal:
44 | Service: 'cloudtrail.amazonaws.com'
45 | Action: "s3:GetBucketAcl"
46 | Resource: !Sub arn:aws:s3:::${CloudTrailBucket}
47 | - Sid: "AWSCloudTrailWrite"
48 | Effect: Allow
49 | Principal:
50 | Service: 'cloudtrail.amazonaws.com'
51 | Action: "s3:PutObject"
52 | Resource: !Sub arn:aws:s3:::${CloudTrailBucket}/AWSLogs/${AWS::AccountId}/*
53 | Condition:
54 | StringEquals:
55 | 's3:x-amz-acl': 'bucket-owner-full-control'
56 |
57 | #file generator API
58 | #role for API
59 | FilesGeneratorAPIRole:
60 | Type: "AWS::IAM::Role"
61 | Properties:
62 | AssumeRolePolicyDocument:
63 | Version: "2012-10-17"
64 | Statement:
65 | - Effect: "Allow"
66 | Principal:
67 | Service: "apigateway.amazonaws.com"
68 | Action:
69 | - "sts:AssumeRole"
70 | Policies:
71 | - PolicyName: ApiDirectWriteEventBridge
72 | PolicyDocument:
73 | Version: '2012-10-17'
74 | Statement:
75 | Action:
76 | - events:PutEvents
77 | Effect: Allow
78 | Resource:
79 | - !Sub arn:aws:events:${AWS::Region}:${AWS::AccountId}:event-bus/default
80 | #API definition
81 | FilesGeneratorHttpApi:
82 | Type: AWS::Serverless::HttpApi
83 | Properties:
84 | DefinitionBody:
85 | 'Fn::Transform':
86 | Name: 'AWS::Include'
87 | Parameters:
88 | Location: 'templates/api.yaml'
89 |
90 | #function that will generate files and put into S3
91 | FilesGeneratorFunction:
92 | Type: AWS::Serverless::Function
93 | Properties:
94 | CodeUri: lambdas/files_generator/
95 | MemorySize: 256
96 | Timeout: 900
97 | Events:
98 | Trigger:
99 | Type: EventBridgeRule
100 | Properties:
101 | Pattern:
102 | {
103 | "source": ["NFProcessor.api"],
104 | "detail-type": ["file_generator_request"]
105 | }
106 | Policies:
107 | - S3CrudPolicy:
108 | BucketName: !Ref FileReceiverBucket
109 | Environment:
110 | Variables:
111 | DESTINATION_BUCKET: !Ref FileReceiverBucket
112 |
113 | #receive file resources(performs file conversion)
114 | FileReceiverBucket:
115 | Type: AWS::S3::Bucket
116 | Properties:
117 | NotificationConfiguration:
118 | EventBridgeConfiguration:
119 | EventBridgeEnabled: true
120 |
121 | OnFileReceiveFunction:
122 | Type: AWS::Serverless::Function
123 | Properties:
124 | CodeUri: lambdas/on_file_receive/
125 | Events:
126 | Trigger:
127 | Type: EventBridgeRule
128 | Properties:
129 | Pattern: !Sub |
130 | {
131 | "source": ["aws.s3"],
132 | "detail-type": ["Object Created"],
133 | "detail": {
134 | "bucket": {
135 | "name": ["${FileReceiverBucket}"]
136 | }
137 | }
138 | }
139 | Policies:
140 | - S3ReadPolicy:
141 | BucketName: !Ref FileReceiverBucket
142 | - Version: "2012-10-17"
143 | Statement:
144 | - Effect: Allow
145 | Action:
146 | - events:PutEvents
147 | Resource: !Sub arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:event-bus/default
148 |
149 | #In case of conversion errors(eg files with missing tags),
150 | #we need to send the payload to a DLQ. Lambda will send an event to EB
151 | #that will send the payload to SQS
152 | OnFileconvertedErrorQueue:
153 | Type: AWS::SQS::Queue
154 | Properties:
155 | SqsManagedSseEnabled: true
156 |
157 | #rule to receive events from conversion lambda
158 | OnFileConvertedErrorEBRule:
159 | Type: AWS::Events::Rule
160 | Properties:
161 | EventPattern: !Sub |
162 | {
163 | "source": ["NFProcessor.file_receiver"],
164 | "detail-type": ["file-converted-error"]
165 | }
166 | Targets:
167 | - Arn: !GetAtt OnFileconvertedErrorQueue.Arn
168 | Id: "FileConvertedErrorQueue"
169 |
170 | # Allow EventBridge to invoke SQS
171 | EventBridgeToToSqsPolicyRole:
172 | Type: AWS::SQS::QueuePolicy
173 | Properties:
174 | PolicyDocument:
175 | Statement:
176 | - Effect: Allow
177 | Principal:
178 | Service: events.amazonaws.com
179 | Action: SQS:SendMessage
180 | Resource: !GetAtt OnFileconvertedErrorQueue.Arn
181 | Queues:
182 | - Ref: OnFileconvertedErrorQueue
183 |
184 | #file validation resources(performs business validation)
185 | OnFileConvertedFunction:
186 | Type: AWS::Serverless::Function
187 | Properties:
188 | CodeUri: lambdas/on_file_converted/
189 | Events:
190 | Trigger:
191 | Type: EventBridgeRule
192 | Properties:
193 | Pattern:
194 | {
195 | "source": ["NFProcessor.file_receiver"],
196 | "detail-type": ["file-converted"]
197 | }
198 | Policies:
199 | - Version: "2012-10-17"
200 | Statement:
201 | - Effect: Allow
202 | Action:
203 | - events:PutEvents
204 | Resource: !Sub arn:${AWS::Partition}:events:${AWS::Region}:${AWS::AccountId}:event-bus/default
205 |
206 | #database persistence(final) step resources
207 | FileStatusTable:
208 | Type: AWS::DynamoDB::Table
209 | Properties:
210 | AttributeDefinitions:
211 | - AttributeName: filename
212 | AttributeType: S
213 | BillingMode: PAY_PER_REQUEST
214 | KeySchema:
215 | - AttributeName: filename
216 | KeyType: HASH
217 | Tags:
218 | - Key: "project"
219 | Value: "EventDrivenFileProcessing"
220 |
221 | OnFileValidatedFunction:
222 | Type: AWS::Serverless::Function
223 | Properties:
224 | CodeUri: lambdas/on_file_validated/
225 | Events:
226 | Trigger:
227 | Type: EventBridgeRule
228 | Properties:
229 | Pattern:
230 | {
231 | "source": ["NFProcessor.file_validator"],
232 | "detail-type": ["file-validated"]
233 | }
234 | Policies:
235 | - DynamoDBCrudPolicy:
236 | TableName: !Ref FileStatusTable
237 | Environment:
238 | Variables:
239 | DDB_TABLE_NAME: !Ref FileStatusTable
240 |
241 | #resources that cannot be created by sam will be handled by bellow stack
242 | NonSamResources:
243 | Type: AWS::CloudFormation::Stack
244 | Properties:
245 | TemplateURL: templates/non-sam-resources.yaml
246 | Parameters:
247 | CloudTrailBucketName: !Ref CloudTrailBucket
248 |
249 | Outputs:
250 | CloudTrailBucket:
251 | Description: "S3 bucket that will store the cloudtrail events for event bridge"
252 | Value: !Ref CloudTrailBucket
253 |
254 | FileReceiverBucket:
255 | Description: S3 bucket containing all generated files
256 | Value: !Ref FileReceiverBucket
257 |
258 | FileGenerationCommandLinuxMacOS:
259 | Description: "Command to generate the files for testing"
260 | Value: !Sub |
261 | curl --location --request POST 'https://${FilesGeneratorHttpApi}.execute-api.${AWS::Region}.amazonaws.com/filesgenerator' --header 'Content-Type: application/json' --data-raw '{"numberOfFiles": 100}'
262 |
263 | FileGenerationCommandWindows:
264 | Description: "Command to generate the files for testing"
265 | Value: !Sub |
266 | $body = '{"numberOfFiles": 100}'
267 | Invoke-RestMethod -uri 'https://${FilesGeneratorHttpApi}.execute-api.${AWS::Region}.amazonaws.com/filesgenerator' -method POST -body $body -ContentType "application/json"
268 |
--------------------------------------------------------------------------------
/images/flow.drawio:
--------------------------------------------------------------------------------
1 | 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
--------------------------------------------------------------------------------