├── 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 | ![Architecture and it's main components](images/architecture.png "High-level architecture") 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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 --------------------------------------------------------------------------------