├── .circleci └── config.yml ├── .eslintrc.json ├── .gitignore ├── Makefile ├── README.md ├── demo ├── lambdas │ ├── app.js │ ├── processing.py │ └── worker.rb └── web │ ├── architecture.png │ └── index.html ├── package-lock.json ├── package.json └── serverless.yml /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | localstack: localstack/platform@2.0.0 5 | 6 | jobs: 7 | localstack-test: 8 | executor: localstack/default 9 | environment: 10 | CI_PROJECT: ci-demo-1 11 | DNS_ADDRESS: "0" 12 | steps: 13 | - checkout 14 | - run: docker pull localstack/localstack 15 | - localstack/startup 16 | - run: 17 | name: Deploy app and run test request 18 | command: | 19 | docker logs localstack-main 20 | awslocal s3 mb s3://test123 21 | awslocal s3 ls 22 | make install 23 | make deploy 24 | make web & 25 | make send-request 26 | code=$? 27 | docker logs localstack-main 28 | exit $code 29 | 30 | workflows: 31 | localstack-test: 32 | jobs: 33 | - localstack-test 34 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 2018 10 | }, 11 | "rules": { 12 | "arrow-parens": [ 13 | "error", 14 | "always" 15 | ], 16 | "max-len": [ 17 | "error", 18 | { 19 | "code": 120 20 | } 21 | ], 22 | "indent": [ 23 | 2, 24 | 4, 25 | { 26 | "SwitchCase": 1 27 | } 28 | ], 29 | "brace-style": "error", 30 | "quotes": [ 31 | "error", 32 | "single" 33 | ], 34 | "semi": [ 35 | "error", 36 | "always" 37 | ], 38 | "no-var": "error" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .serverless/ 3 | node_modules 4 | .idea/ 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | export AWS_ACCESS_KEY_ID ?= test 2 | export AWS_SECRET_ACCESS_KEY ?= test 3 | export AWS_DEFAULT_REGION = us-east-1 4 | 5 | usage: ## Show this help 6 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' 7 | 8 | install: ## Install dependencies 9 | npm install 10 | which serverless || npm install -g serverless 11 | which localstack || pip install localstack 12 | 13 | deploy: ## Deploy the app 14 | @make install; \ 15 | echo "Deploying Serverless app to local environment"; \ 16 | SLS_DEBUG=1 serverless deploy --stage local 17 | 18 | send-request: ## Send a test request to the deployed application 19 | @which jq || (echo "jq was not found. Please install it (https://jqlang.github.io/jq/download/) and try again." && exit 1) 20 | @echo Looking up API ID from deployed API Gateway REST APIs ...; \ 21 | apiId=$$(awslocal apigateway get-rest-apis --output json | jq -r '.items[] | select(.name="local-localstack-demo") | .id'); \ 22 | echo Sending request to API Gateway REST APIs ID "$$apiId" ...; \ 23 | requestID=$$(curl -s -d '{}' http://$$apiId.execute-api.localhost.localstack.cloud:4566/local/requests | jq -r .requestID); \ 24 | echo "Received request ID '$$requestID'"; \ 25 | for i in 1 2 3 4 5 6 7 8 9 10; do echo "Polling for processing result to appear in s3://archive-bucket/..."; awslocal s3 ls s3://archive-bucket/ | grep $$requestID && exit; sleep 3; done 26 | 27 | lint: ## Run code linter 28 | @npm run lint 29 | @flake8 demo 30 | 31 | .PHONY: usage install deploy send-request lint 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/localstack/localstack-demo.svg?branch=master)](https://travis-ci.org/whummer/localstack-demo) 2 | 3 | # LocalStack Demo 4 | 5 | Simple demo application deployed using LocalStack, developed using the Serverless framework. 6 | 7 | The sample app illustrates a typical Web application scenario with asynchronous request processing happening in the background, all running locally inside LocalStack. The figure below outlines the application architecture with the different components and services involved in processing the requests. 8 | 9 | 10 | 11 | ## Prerequisites 12 | 13 | * LocalStack 14 | * Docker 15 | * Node.js / `yarn` 16 | * `make` 17 | * (optional) jq 18 | 19 | Note: Please make sure to pull and start the `latest` LocalStack Docker image. At the time of writing (2023-02-01), the demo requires some features that were only recently added to LocalStack and are not part of a tagged release version yet. 20 | 21 | ## Running LocalStack 22 | 23 | Use the `localstack` CLI command to get started: 24 | ``` 25 | localstack start 26 | ``` 27 | 28 | ## Installing dependencies & running the application 29 | 30 | To install the dependencies, deploy and start the application locally in LocalStack: 31 | ``` 32 | make deploy 33 | ``` 34 | 35 | ## Testing 36 | 37 | After starting the app, open this URL in your browser: http://localhost:4566/archive-bucket/index.html 38 | 39 | * Enable the option "Auto-Refresh" to continuously poll for new results 40 | * Click the button "Create new request" to send a new request to the backend API 41 | * The new request will go through the phases `QUEUED->PROCESSING->FINISHED` as the request is being handled by the backend services (Lambda functions, Step Functions state machine) 42 | 43 | If you have the [`awslocal`](https://github.com/localstack/awscli-local) command line installed, you can browse the contents of the local S3 bucket via: 44 | ``` 45 | awslocal s3 ls s3://archive-bucket/ 46 | ``` 47 | 48 | ## License 49 | 50 | This code is available under the Apache 2.0 license. 51 | -------------------------------------------------------------------------------- /demo/lambdas/app.js: -------------------------------------------------------------------------------- 1 | const uuidv4 = require('uuid/v4'); 2 | const AWS = require('aws-sdk'); 3 | 4 | const AWS_ENDPOINT_URL = process.env.AWS_ENDPOINT_URL; 5 | if (AWS_ENDPOINT_URL) { 6 | process.env.AWS_SECRET_ACCESS_KEY = 'test'; 7 | process.env.AWS_ACCESS_KEY_ID = 'test'; 8 | } 9 | 10 | const DYNAMODB_TABLE = 'appRequests'; 11 | const QUEUE_NAME = 'requestQueue'; 12 | const CLIENT_CONFIG = AWS_ENDPOINT_URL ? {endpoint: AWS_ENDPOINT_URL} : {}; 13 | 14 | const connectSQS = () => new AWS.SQS(CLIENT_CONFIG); 15 | const connectDynamoDB = () => new AWS.DynamoDB(CLIENT_CONFIG); 16 | 17 | const shortUid = () => uuidv4().substring(0, 8); 18 | 19 | const headers = { 20 | 'content-type': 'application/json', 21 | 'Access-Control-Allow-Headers' : 'Content-Type', 22 | 'Access-Control-Allow-Origin': '*', 23 | 'Access-Control-Allow-Methods': 'OPTIONS,POST,GET' 24 | }; 25 | 26 | const handleRequest = async (event) => { 27 | if (event.path === '/requests' && event.httpMethod === 'POST') { 28 | return startNewRequest(event); 29 | } else if (event.path === '/requests' && event.httpMethod === 'GET') { 30 | return listRequests(event); 31 | } else { 32 | return {statusCode: 404, headers, body: {}}; 33 | } 34 | }; 35 | 36 | const startNewRequest = async () => { 37 | // put message onto SQS queue 38 | const sqs = connectSQS(); 39 | const requestID = shortUid(); 40 | const message = {'requestID': requestID}; 41 | const queueUrl = (await sqs.getQueueUrl({QueueName: QUEUE_NAME}).promise()).QueueUrl; 42 | let params = { 43 | MessageBody: JSON.stringify(message), 44 | QueueUrl: queueUrl 45 | }; 46 | await sqs.sendMessage(params).promise(); 47 | 48 | // set status in DynamoDB to QUEUED 49 | const dynamodb = connectDynamoDB(); 50 | const status = 'QUEUED'; 51 | params = { 52 | TableName: DYNAMODB_TABLE, 53 | Item: { 54 | id: { 55 | S: shortUid() 56 | }, 57 | requestID: { 58 | S: requestID 59 | }, 60 | timestamp: { 61 | N: '' + Date.now() 62 | }, 63 | status: { 64 | S: status 65 | } 66 | } 67 | }; 68 | await dynamodb.putItem(params).promise(); 69 | 70 | const body = JSON.stringify({ 71 | requestID, 72 | status 73 | }); 74 | return { 75 | statusCode: 200, 76 | headers, 77 | body 78 | }; 79 | }; 80 | 81 | const listRequests = async () => { 82 | const dynamodb = connectDynamoDB(); 83 | const params = { 84 | TableName: DYNAMODB_TABLE, 85 | }; 86 | const scanResult = await dynamodb.scan(params).promise(); 87 | const items = scanResult['Items'].map((x) => { 88 | Object.keys(x).forEach((attr) => { 89 | if ('N' in x[attr]) x[attr] = parseFloat(x[attr].N); 90 | else if ('S' in x[attr]) x[attr] = x[attr].S; 91 | else x[attr] = x[attr][Object.keys(x[attr])[0]]; 92 | }); 93 | return x; 94 | }); 95 | const result = { 96 | statusCode: 200, 97 | headers, 98 | body: JSON.stringify({result: items}) 99 | }; 100 | return result; 101 | }; 102 | 103 | module.exports = { 104 | handleRequest 105 | }; 106 | -------------------------------------------------------------------------------- /demo/lambdas/processing.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import time 4 | import uuid 5 | 6 | import boto3 7 | 8 | AWS_ENDPOINT_URL = os.environ.get("AWS_ENDPOINT_URL") 9 | 10 | DYNAMODB_TABLE = 'appRequests' 11 | S3_BUCKET = os.environ.get('ARCHIVE_BUCKET') or 'archive-bucket' 12 | 13 | 14 | def handle_request(event, context=None): 15 | # simulate queueing delay 16 | time.sleep(5) 17 | print('handle_request', event) 18 | # set request status to PROCESSING 19 | status = 'PROCESSING' 20 | set_status(event['requestID'], status) 21 | # simulate processing delay 22 | time.sleep(4) 23 | return { 24 | 'requestID': event['requestID'], 25 | 'status': status 26 | } 27 | 28 | 29 | def archive_result(event, context=None): 30 | print('archive_result', event) 31 | requestID = event['requestID'] 32 | # put result onto S3 33 | s3 = get_client('s3') 34 | s3.put_object( 35 | Bucket=S3_BUCKET, 36 | Key=f'{requestID}/result.txt', 37 | Body=f'Archive result for request {requestID}' 38 | ) 39 | # simulate processing delay 40 | time.sleep(3) 41 | # set request status to FINISHED 42 | set_status(requestID, 'FINISHED') 43 | 44 | 45 | def get_client(resource): 46 | kwargs = {"endpoint_url": AWS_ENDPOINT_URL} if AWS_ENDPOINT_URL else {} 47 | return boto3.client(resource, **kwargs) 48 | 49 | 50 | def set_status(requestID, status): 51 | dynamodb = get_client('dynamodb') 52 | item = { 53 | 'id': {'S': short_uid()}, 54 | 'requestID': {'S': requestID}, 55 | 'timestamp': {'N': str(now_utc())}, 56 | 'status': {'S': status} 57 | } 58 | dynamodb.put_item(TableName=DYNAMODB_TABLE, Item=item) 59 | 60 | 61 | def now_utc(): 62 | diff = datetime.datetime.utcnow() - datetime.datetime(1970, 1, 1) 63 | return int(diff.total_seconds() * 1000.0) 64 | 65 | 66 | def short_uid(): 67 | return str(uuid.uuid4())[0:8] 68 | -------------------------------------------------------------------------------- /demo/lambdas/worker.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk-states' 2 | 3 | $aws_endpoint_url = ENV['AWS_ENDPOINT_URL'] 4 | if $aws_endpoint_url 5 | ENV['AWS_SECRET_ACCESS_KEY'] = 'test' 6 | ENV['AWS_ACCESS_KEY_ID'] = 'test' 7 | end 8 | $state_machine_arn = ENV['STATE_MACHINE_ARN'] 9 | 10 | def triggerProcessing(event:, context:) 11 | if $aws_endpoint_url 12 | client = Aws::States::Client.new(:endpoint => $aws_endpoint_url) 13 | else 14 | client = Aws::States::Client.new() 15 | end 16 | 17 | records = event['Records'] 18 | for rec in records do 19 | result = client.start_execution({ 20 | state_machine_arn: $state_machine_arn, 21 | input: rec['body'] 22 | }) 23 | end 24 | {} 25 | end 26 | -------------------------------------------------------------------------------- /demo/web/architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/localstack/localstack-demo/e58fed5e09085a6e2f92d3c028cbd923f2a4bde5/demo/web/architecture.png -------------------------------------------------------------------------------- /demo/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 14 | 15 | 16 |
17 | 122 |
123 |
124 |
125 |

Request Worker Sample App

126 |

127 | This sample app illustrates a typical Web application scenario with asynchronous request processing happening in the background, all running locally inside LocalStack. 128 |

129 | The end-to-end process is illustrated in the figure on the right. 130 |

131 | Here's how the app works: 132 |

133 | 142 |

143 | (Note that all resources will be deployed to the local "us-east-1" region.) 144 |

145 | The full source code of this sample is available in this Github repo: https://github.com/localstack/localstack-demo 146 |

147 |
148 |
149 | 150 |
151 |
152 | 153 | 154 | 155 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "localstack-demo", 3 | "version": "1.0.1", 4 | "description": "LocalStack demo application", 5 | "dependencies": { 6 | "uuid": "^3.4.0" 7 | }, 8 | "devDependencies": { 9 | "aws-sdk": "^2.814.0", 10 | "axios": "^0.21.2", 11 | "eslint": "^6.8.0", 12 | "lodash": "^4.17.21", 13 | "mixin-deep": ">=1.3.2", 14 | "serverless": "^3.0.0", 15 | "serverless-deployment-bucket": "^1.5.1", 16 | "serverless-localstack": "^1.1.1", 17 | "serverless-sync-s3": "^0.3.0", 18 | "set-value": "^4.0.1" 19 | }, 20 | "scripts": { 21 | "start": "make start", 22 | "lint": "eslint .", 23 | "test": "jest" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/whummer/localstack-demo.git" 28 | }, 29 | "author": "Waldemar Hummer", 30 | "license": "Apache 2.0", 31 | "homepage": "https://github.com/whummer/localstack-demo#readme" 32 | } 33 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: localstack-demo 2 | 3 | plugins: 4 | - serverless-deployment-bucket 5 | - serverless-localstack 6 | # Note: Although there's some more popular S3 sync plugins out there, most of them failed on LocalStack with: 7 | # "Error: Non-file stream objects are not supported with SigV4" 8 | # at Object.computeSha256 (node_modules/aws-sdk/lib/util.js:754:23) 9 | - serverless-sync-s3 10 | 11 | provider: 12 | name: aws 13 | stage: ${opt:stage,'local'} 14 | region: us-east-1 15 | stackName: demo1 16 | timeout: 15 17 | deploymentBucket: 18 | name: ${self:custom.deploymentBucket.${self:provider.stage}} 19 | iam: 20 | role: 21 | statements: 22 | - Effect: Allow 23 | Action: 24 | - dynamodb:* 25 | Resource: 'arn:aws:dynamodb:us-east-1:*:table/appRequests' 26 | - Effect: Allow 27 | Action: 28 | - sqs:* 29 | Resource: 'arn:aws:sqs:us-east-1:*:requestQueue' 30 | - Effect: Allow 31 | Action: 32 | - states:* 33 | Resource: 'arn:aws:states:us-east-1:*:stateMachine:*' 34 | - Effect: Allow 35 | Action: 36 | - s3:* 37 | Resource: !Sub 'arn:aws:s3:::${self:custom.archiveBucket.${self:provider.stage}}/*' 38 | 39 | package: 40 | excludeDevDependencies: true 41 | exclude: 42 | - ./** 43 | - "!demo/**" 44 | - "!node_modules/uuid/**" 45 | 46 | custom: 47 | region: us-east-1 48 | accountID: '000000000000' 49 | localstack: 50 | stages: [local] 51 | host: http://127.0.0.1 52 | debug: true 53 | # Note: enable this configuration to automatically start up a LocalStack container in the background 54 | # autostart: true 55 | # lambda: 56 | # mountCode: true 57 | deploymentBucket: 58 | local: localstack-test-bucket 59 | aws: localstack-test-bucket-53194 60 | archiveBucket: 61 | local: archive-bucket 62 | aws: localstack-demo-archive-bucket-53194 63 | syncS3: 64 | - bucketName: ${self:custom.archiveBucket.${self:provider.stage}} 65 | localDir: demo/web 66 | 67 | functions: 68 | httpHandleRequest: 69 | handler: demo/lambdas/app.handleRequest 70 | runtime: nodejs14.x 71 | events: 72 | - http: 73 | path: /requests 74 | method: post 75 | cors: true 76 | - http: 77 | path: /requests 78 | method: get 79 | cors: true 80 | sqsHandleItem: 81 | handler: demo/lambdas/worker.triggerProcessing 82 | runtime: ruby2.7 83 | environment: 84 | STATE_MACHINE_ARN: !Sub '${processingStateMachine.Arn}' 85 | events: 86 | - sqs: 87 | arn: 88 | Fn::GetAtt: [requestQueue, Arn] 89 | backendProcessRequest: 90 | handler: demo/lambdas/processing.handle_request 91 | runtime: python3.7 92 | backendArchiveResult: 93 | handler: demo/lambdas/processing.archive_result 94 | runtime: python3.7 95 | environment: 96 | ARCHIVE_BUCKET: ${self:custom.archiveBucket.${self:provider.stage}} 97 | 98 | resources: 99 | Resources: 100 | appDatabase: 101 | Type: AWS::DynamoDB::Table 102 | Properties: 103 | TableName: appRequests 104 | BillingMode: PAY_PER_REQUEST 105 | AttributeDefinitions: 106 | - AttributeName: id 107 | AttributeType: S 108 | - AttributeName: requestID 109 | AttributeType: S 110 | KeySchema: 111 | - AttributeName: id 112 | KeyType: HASH 113 | - AttributeName: requestID 114 | KeyType: RANGE 115 | archiveBucket: 116 | Type: AWS::S3::Bucket 117 | Properties: 118 | BucketName: ${self:custom.archiveBucket.${self:provider.stage}} 119 | requestQueue: 120 | Type: AWS::SQS::Queue 121 | Properties: 122 | QueueName: requestQueue 123 | processingStateMachine: 124 | Type: AWS::StepFunctions::StateMachine 125 | Properties: 126 | StateMachineName: processingStateMachine 127 | RoleArn: !Sub '${processingStateMachineRole.Arn}' 128 | DefinitionString: !Sub | 129 | { 130 | "StartAt": "ProcessRequest", 131 | "States": { 132 | "ProcessRequest": { 133 | "Type": "Task", 134 | "Resource": "${BackendProcessRequestLambdaFunction.Arn}", 135 | "Next": "ArchiveResult" 136 | }, 137 | "ArchiveResult": { 138 | "Type": "Task", 139 | "Resource": "${BackendArchiveResultLambdaFunction.Arn}", 140 | "End": true 141 | } 142 | } 143 | } 144 | processingStateMachineRole: 145 | Type: AWS::IAM::Role 146 | Properties: 147 | AssumeRolePolicyDocument: 148 | Version: '2012-10-17' 149 | Statement: 150 | - Effect: Allow 151 | Principal: 152 | Service: !Sub 'states.${AWS::Region}.amazonaws.com' 153 | Action: 'sts:AssumeRole' 154 | Policies: 155 | - PolicyName: lambda 156 | PolicyDocument: 157 | Statement: 158 | - Effect: Allow 159 | Action: 'lambda:InvokeFunction' 160 | Resource: 161 | - !Sub '${BackendProcessRequestLambdaFunction.Arn}' 162 | - !Sub '${BackendArchiveResultLambdaFunction.Arn}' 163 | --------------------------------------------------------------------------------