├── .bootstrap └── tfstate.tf ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ ├── python.yml │ └── terraform.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── api ├── Makefile ├── main.py ├── requirements-dev.txt ├── requirements.txt └── tests │ └── test_main.py └── infra ├── env ├── common │ ├── common_variables.tf │ └── provider.tf ├── dev │ ├── api-gateway │ │ └── terragrunt.hcl │ ├── env_vars.hcl │ ├── network │ │ └── terragrunt.hcl │ └── sqs │ │ └── terragrunt.hcl └── terragrunt.hcl └── modules ├── api-gateway ├── api-gateway.tf ├── cloudwatch.tf ├── lambda.tf ├── outputs.tf └── variables.tf ├── network ├── cloudwatch.tf ├── outputs.tf ├── variables.tf └── vpc.tf └── sqs ├── data.tf ├── output.tf ├── sqs.tf └── variables.tf /.bootstrap/tfstate.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "~> 3.0" 6 | } 7 | } 8 | } 9 | 10 | provider "aws" { 11 | region = "ca-central-1" 12 | } 13 | 14 | variable "tfstate_bucket_name" { 15 | description = "Name of the S3 bucket to use to store the tfstate" 16 | type = string 17 | } 18 | 19 | resource "aws_s3_bucket" "tfstate" { 20 | bucket = var.tfstate_bucket_name 21 | 22 | acl = "private" 23 | versioning { 24 | enabled = true 25 | } 26 | 27 | server_side_encryption_configuration { 28 | rule { 29 | apply_server_side_encryption_by_default { 30 | sse_algorithm = "AES256" 31 | } 32 | } 33 | } 34 | } 35 | 36 | resource "aws_s3_bucket_public_access_block" "tfstate_public_access_block" { 37 | bucket = aws_s3_bucket.tfstate.id 38 | 39 | block_public_acls = true 40 | block_public_policy = true 41 | ignore_public_acls = true 42 | restrict_public_buckets = true 43 | } 44 | 45 | resource "aws_dynamodb_table" "tfstate_lock" { 46 | name = "terraform-lock" 47 | read_capacity = 5 48 | write_capacity = 5 49 | hash_key = "LockID" 50 | attribute { 51 | name = "LockID" 52 | type = "S" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG PYTHON_VERSION 2 | FROM mcr.microsoft.com/vscode/devcontainers/python:${PYTHON_VERSION} 3 | 4 | ARG USERNAME=vscode 5 | 6 | # Set these in devcontainer.json 7 | ARG TERRAFORM_VERSION 8 | ARG TERRAFORM_CHECKSUM 9 | ARG TERRAGRUNT_VERSION 10 | ARG TERRAGRUNT_CHECKSUM 11 | 12 | # Install packages 13 | RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 14 | && apt-get -y install --no-install-recommends awscli ca-certificates curl git gnupg2 jq make openssh-client vim zsh \ 15 | && apt-get autoremove -y && apt-get clean -y 16 | 17 | # Install Terraform 18 | RUN curl -Lo terraform.zip https://releases.hashicorp.com/terraform/"${TERRAFORM_VERSION}"/terraform_"${TERRAFORM_VERSION}"_linux_"$(dpkg --print-architecture)".zip \ 19 | && echo "${TERRAFORM_CHECKSUM} terraform.zip" | sha256sum --check \ 20 | && unzip terraform.zip \ 21 | && mv terraform /usr/local/bin/ \ 22 | && rm terraform.zip 23 | 24 | # Install Terragrunt 25 | RUN curl -Lo terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/v"${TERRAGRUNT_VERSION}"/terragrunt_linux_"$(dpkg --print-architecture)" \ 26 | && echo "${TERRAGRUNT_CHECKSUM} terragrunt" | sha256sum --check \ 27 | && chmod +x terragrunt \ 28 | && mv terragrunt /usr/local/bin/ 29 | 30 | # Install Checkov 31 | RUN pip3 install --upgrade requests setuptools \ 32 | && pip3 install --upgrade botocore checkov 33 | 34 | # Setup aliases and autocomplete 35 | RUN echo "\n\ 36 | complete -C /usr/bin/aws_completer aws\n\ 37 | complete -C /usr/local/bin/terraform terraform\n\ 38 | complete -C /usr/local/bin/terraform terragrunt\n\ 39 | alias tf='terraform'\n\ 40 | alias tg='terragrunt'\n\ 41 | alias ll='la -la'" >> /home/"${USERNAME}"/.zshrc -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python + Terraform", 3 | "build": { 4 | "dockerfile": "Dockerfile", 5 | "context": "..", 6 | "args": { 7 | "PYTHON_VERSION": "3.8", 8 | "TERRAFORM_VERSION": "1.0.2", 9 | "TERRAFORM_CHECKSUM": "7329f887cc5a5bda4bedaec59c439a4af7ea0465f83e3c1b0f4d04951e1181f4", 10 | "TERRAGRUNT_VERSION": "0.31.0", 11 | "TERRAGRUNT_CHECKSUM": "b2d32b6c5a7d5fb22ad3f07267b4b90ff82ebcc5f92111550fd43f4ce94716a0" 12 | } 13 | }, 14 | "containerEnv": { 15 | "SHELL": "/bin/zsh" 16 | }, 17 | "settings": { 18 | "python.pythonPath": "/usr/local/bin/python", 19 | "python.linting.enabled": true, 20 | "python.linting.pylintEnabled": true, 21 | "python.formatting.provider": "black", 22 | "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", 23 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 24 | "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", 25 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 26 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 27 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 28 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 29 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 30 | "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", 31 | "editor.formatOnSave": true, 32 | }, 33 | "extensions": [ 34 | "github.copilot", 35 | "hashicorp.terraform", 36 | "ms-python.python", 37 | "redhat.vscode-yaml", 38 | ], 39 | "postCreateCommand": "cd api && make install && make install-dev", 40 | "remoteUser": "vscode" 41 | } -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python lint, format and test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "api/**" 9 | - ".github/workflows/python.yml" 10 | pull_request: 11 | paths: 12 | - "api/**" 13 | - ".github/workflows/python.yml" 14 | 15 | defaults: 16 | run: 17 | working-directory: api 18 | 19 | jobs: 20 | python-test: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v2 25 | 26 | - name: Setup python 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: "3.8" 30 | 31 | - name: Install dependencies 32 | run: | 33 | make install 34 | make install-dev 35 | 36 | - name: Lint 37 | run: make lint 38 | 39 | - name: Format 40 | run: make ARGS=--check fmt 41 | 42 | - name: Test 43 | run: make test 44 | -------------------------------------------------------------------------------- /.github/workflows/terraform.yml: -------------------------------------------------------------------------------- 1 | name: Terraform security 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "infra/**" 9 | - ".github/workflows/terraform.yml" 10 | pull_request: 11 | paths: 12 | - "infra/**" 13 | - ".github/workflows/terraform.yml" 14 | 15 | jobs: 16 | terraform-security: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | 22 | - name: Checkov security scan 23 | id: checkov 24 | uses: bridgecrewio/checkov-action@v12.641.0 25 | with: 26 | directory: infra 27 | framework: terraform 28 | output_format: cli 29 | download_external_modules: true 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__ 3 | .pytest_cache 4 | 5 | # Terraform 6 | .terraform* 7 | .terragrunt* 8 | *.tfstate 9 | 10 | # API 11 | infra/modules/api-gateway/lambda.zip -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | ## Copyright (c) 2021 Pat Heard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS FastAPI Lambda 2 | Creates an API using an AWS API Gateway and Lambda function, based on [this walkthrough](https://towardsdatascience.com/fastapi-aws-robust-api-part-1-f67ae47390f9). The setup uses: 3 | 4 | * **API:** [FastAPI](https://fastapi.tiangolo.com/) `+` [Mangum](https://mangum.io/) 5 | * **Infrastructure:** [Terraform](https://www.terraform.io/) `+` [Terragrunt](https://terragrunt.gruntwork.io/) 6 | 7 | Requests are sent to the API Gateway, which has one `/{proxy+}` resource. This resource handles all requests using a [proxy integration with the Lambda function](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html). Mangum acts as a wrapper, which allows FastAPI to handle the requests and create responses the API gateway can serve. All logs are sent to CloudWatch log groups. 8 | 9 | ```js 10 | ┌─── AWS region ─────────────────────────────┐ 11 | │ ┌─── VPC: three AZs ────┐ │ 12 | │ │ │ │ 13 | Request ───► API Gateway ───► Lambda (FastAPI) │ │ 14 | │ │ │ │ 15 | │ │ └───────────│───────────┘ │ 16 | │ ┌───────│────────────────────│───────────┐ │ 17 | │ │ ▼ CloudWatch ▼ │ │ 18 | │ └────────────────────────────────────────┘ │ 19 | └────────────────────────────────────────────┘ 20 | ``` 21 | 22 | If performance becomes an issue, a CloudFront distribution could be added for API responses that are cacheable. 23 | 24 | # Dev 25 | ```sh 26 | cd api 27 | make install 28 | make install-dev 29 | make serve 30 | ``` 31 | View the [API endpoints](http://localhost:8000/docs). 32 | 33 | # Deploy 34 | ```sh 35 | # Create the zip that will be used for the Lambda function 36 | cd api 37 | make zip 38 | 39 | # Create the AWS infrastructure 40 | cd ../infra/env/dev 41 | terragrunt run-all plan # to see all the goodness that will get created 42 | terragrunt run-all apply # create all the goodness 43 | ``` 44 | 45 | # Notes 46 | ## Root path 47 | The API gateway proxy integration with the Lambda function does not include a `/` root path. If you need a root path, you'll need to add this method integration: 48 | ```terraform 49 | resource "aws_api_gateway_method" "api_gateway_root_method" { 50 | rest_api_id = aws_api_gateway_rest_api.api_gateway.id 51 | resource_id = aws_api_gateway_rest_api.api_gateway.root_resource_id 52 | http_method = "ANY" 53 | authorization = "NONE" 54 | } 55 | 56 | resource "aws_api_gateway_integration" "api_proxy_integration" { 57 | rest_api_id = aws_api_gateway_rest_api.api_gateway.id 58 | resource_id = aws_api_gateway_rest_api.api_gateway.root_resource_id 59 | http_method = aws_api_gateway_method.api_gateway_root_method.http_method 60 | integration_http_method = "POST" 61 | type = "AWS_PROXY" 62 | uri = aws_lambda_function.api_lambda.invoke_arn 63 | } 64 | ``` 65 | ## API key 66 | An API key is created and required by the API gateway. You can retrieve the key from the API's usage plan in the AWS console. To use the key: 67 | ```sh 68 | curl --header "x-api-key: ${API_KEY_VALUE}" \ 69 | https://${API_ID}.execute-api.${API_REGION}.amazonaws.com/dev/hello 70 | ``` 71 | -------------------------------------------------------------------------------- /api/Makefile: -------------------------------------------------------------------------------- 1 | fmt: 2 | black . $(ARGS) 3 | 4 | install: 5 | pip3 install --user --requirement requirements.txt 6 | 7 | install-dev: 8 | pip3 install --user --requirement requirements-dev.txt 9 | 10 | lint: 11 | pylint *.py 12 | 13 | serve: 14 | uvicorn main:app --reload 15 | 16 | test: 17 | python -m pytest -s -vv tests 18 | 19 | zip: 20 | pip3 install --target ./libs --requirement requirements.txt 21 | cd ./libs && zip -rq ../lambda.zip . 22 | zip -gq lambda.zip main.py 23 | mv lambda.zip ../infra/modules/api-gateway 24 | rm -r ./libs 25 | 26 | .PHONY: \ 27 | fmt \ 28 | install \ 29 | install-dev \ 30 | serve \ 31 | tests \ 32 | zip -------------------------------------------------------------------------------- /api/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main API handler that defines all routes. 3 | """ 4 | 5 | import json 6 | import os 7 | from datetime import datetime 8 | 9 | import boto3 10 | from fastapi import FastAPI 11 | from mangum import Mangum 12 | 13 | MESSAGE_QUEUE_NAME = os.environ.get("MESSAGE_QUEUE_NAME", None) 14 | REGION = os.environ.get("AWS_REGION", "ca-central-1") 15 | 16 | app = FastAPI( 17 | title="AWS + FastAPI", 18 | description="AWS API Gateway, Lambdas and FastAPI (oh my)", 19 | ) 20 | 21 | 22 | @app.get("/hello") 23 | def hello(): 24 | "Hello path request" 25 | return {"Hello": "World"} 26 | 27 | 28 | @app.get("/produce") 29 | def produce(): 30 | "Produce an SQS message" 31 | 32 | if MESSAGE_QUEUE_NAME is None: 33 | return {"error": "Message queue name not set"} 34 | 35 | message = {"date": datetime.now().isoformat(), "flavour": "delicious"} 36 | 37 | sqs = boto3.resource("sqs", region_name=REGION) 38 | queue = sqs.get_queue_by_name(QueueName=MESSAGE_QUEUE_NAME) 39 | response = queue.send_message(MessageBody=json.dumps(message)) 40 | 41 | return {"message_id": response.get("MessageId")} 42 | 43 | 44 | # Mangum allows us to use Lambdas to process requests 45 | handler = Mangum(app=app) 46 | -------------------------------------------------------------------------------- /api/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black==21.7b0 2 | pylint==2.9.5 3 | pytest==6.2.4 4 | uvicorn==0.14.0 5 | -------------------------------------------------------------------------------- /api/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.17.* 2 | fastapi==0.67.0 3 | mangum==0.12.1 4 | -------------------------------------------------------------------------------- /api/tests/test_main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import main 3 | from unittest.mock import call, MagicMock, patch 4 | 5 | 6 | def test_hello(): 7 | assert main.hello() == {"Hello": "World"} 8 | 9 | 10 | @patch("main.boto3") 11 | @patch("main.datetime") 12 | @patch("main.MESSAGE_QUEUE_NAME", "Muffins") 13 | @patch("main.REGION", "us-east-1") 14 | def test_produce(MockDateTime, MockBoto): 15 | response = MagicMock() 16 | response.get.return_value = "42" 17 | queue = MagicMock() 18 | queue.send_message.return_value = response 19 | sqs = MagicMock() 20 | sqs.get_queue_by_name.return_value = queue 21 | MockBoto.resource.return_value = sqs 22 | MockDateTime.now.return_value.isoformat.return_value = "1970-01-01" 23 | 24 | assert main.produce() == {"message_id": "42"} 25 | 26 | MockBoto.resource.assert_called_with("sqs", region_name="us-east-1") 27 | sqs.get_queue_by_name.assert_called_with(QueueName="Muffins") 28 | queue.send_message.assert_called_with( 29 | MessageBody=json.dumps({"date": "1970-01-01", "flavour": "delicious"}) 30 | ) 31 | 32 | 33 | @patch("main.MESSAGE_QUEUE_NAME", None) 34 | def test_produce_no_queue(): 35 | assert main.produce() == {"error": "Message queue name not set"} 36 | -------------------------------------------------------------------------------- /infra/env/common/common_variables.tf: -------------------------------------------------------------------------------- 1 | variable "env" { 2 | description = "(Required) The current running environment" 3 | type = string 4 | } 5 | 6 | variable "project_name" { 7 | description = "(Required) Name of the project, used for top level resources and tagging" 8 | type = string 9 | } 10 | 11 | variable "project_team" { 12 | description = "(Required) Name of the project team, used for tagging" 13 | type = string 14 | } 15 | 16 | variable "region" { 17 | description = "(Required) The region to build infra in" 18 | type = string 19 | } 20 | -------------------------------------------------------------------------------- /infra/env/common/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.0.2" 3 | required_providers { 4 | aws = { 5 | source = "hashicorp/aws" 6 | version = "~> 3.0" 7 | } 8 | } 9 | } 10 | 11 | provider "aws" { 12 | region = var.region 13 | } 14 | -------------------------------------------------------------------------------- /infra/env/dev/api-gateway/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | include { 2 | path = find_in_parent_folders() 3 | } 4 | 5 | dependencies { 6 | paths = ["../network", "../sqs"] 7 | } 8 | 9 | dependency "network" { 10 | config_path = "../network" 11 | 12 | mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] 13 | mock_outputs = { 14 | lambda_subnet_ids = [""] 15 | lambda_security_group_id = "" 16 | } 17 | } 18 | 19 | dependency "sqs" { 20 | config_path = "../sqs" 21 | 22 | mock_outputs_allowed_terraform_commands = ["init", "fmt", "validate", "plan", "show"] 23 | mock_outputs = { 24 | message_queue_arn = "" 25 | message_queue_name = "" 26 | } 27 | } 28 | 29 | inputs = { 30 | lambda_subnet_ids = dependency.network.outputs.lambda_subnet_ids 31 | lambda_security_group_id = dependency.network.outputs.lambda_security_group_id 32 | message_queue_arn = dependency.sqs.outputs.message_queue_arn 33 | message_queue_name = dependency.sqs.outputs.message_queue_name 34 | } 35 | 36 | terraform { 37 | source = "../../../modules//api-gateway" 38 | } 39 | -------------------------------------------------------------------------------- /infra/env/dev/env_vars.hcl: -------------------------------------------------------------------------------- 1 | inputs = { 2 | env = "dev" 3 | project_name = "aws-fastapi-lambda" 4 | project_team = "Operations" 5 | } -------------------------------------------------------------------------------- /infra/env/dev/network/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | include { 2 | path = find_in_parent_folders() 3 | } 4 | 5 | terraform { 6 | source = "../../../modules//network" 7 | } 8 | -------------------------------------------------------------------------------- /infra/env/dev/sqs/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | include { 2 | path = find_in_parent_folders() 3 | } 4 | 5 | inputs = { 6 | lambda_producer_function_name = "FastAPI" 7 | max_receive_count = 5 8 | } 9 | 10 | terraform { 11 | source = "../../../modules//sqs" 12 | } 13 | -------------------------------------------------------------------------------- /infra/env/terragrunt.hcl: -------------------------------------------------------------------------------- 1 | locals { 2 | vars = read_terragrunt_config("../env_vars.hcl") 3 | region = "ca-central-1" 4 | } 5 | 6 | inputs = { 7 | env = local.vars.inputs.env 8 | project_name = local.vars.inputs.project_name 9 | project_team = local.vars.inputs.project_team 10 | region = local.region 11 | } 12 | 13 | remote_state { 14 | backend = "s3" 15 | generate = { 16 | path = "backend.tf" 17 | if_exists = "overwrite_terragrunt" 18 | } 19 | config = { 20 | encrypt = true 21 | bucket = "tfstate-aws-fastapi-lambda-${local.vars.inputs.env}" 22 | dynamodb_table = "terraform-lock" 23 | region = local.region 24 | key = "${path_relative_to_include()}/terraform.tfstate" 25 | } 26 | } 27 | 28 | generate "provider" { 29 | path = "provider.tf" 30 | if_exists = "overwrite" 31 | contents = file("./common/provider.tf") 32 | } 33 | 34 | generate "common_variables" { 35 | path = "common_variables.tf" 36 | if_exists = "overwrite" 37 | contents = file("./common/common_variables.tf") 38 | } 39 | -------------------------------------------------------------------------------- /infra/modules/api-gateway/api-gateway.tf: -------------------------------------------------------------------------------- 1 | resource "aws_api_gateway_rest_api" "api_gateway" { 2 | name = var.project_name 3 | description = "API Gateway that proxies all requests to the FastAPI Lambda function" 4 | 5 | endpoint_configuration { 6 | types = ["REGIONAL"] 7 | } 8 | 9 | tags = { 10 | Name = "${var.project_name}-api-gateway" 11 | Project = var.project_name 12 | Billing = var.project_team 13 | } 14 | } 15 | 16 | resource "aws_api_gateway_deployment" "api_deployment" { 17 | rest_api_id = aws_api_gateway_rest_api.api_gateway.id 18 | stage_description = md5(file("api-gateway.tf")) # Force a new deployment when this file changes 19 | 20 | lifecycle { 21 | create_before_destroy = true 22 | } 23 | 24 | depends_on = [ 25 | aws_api_gateway_integration.api_proxy_integration, 26 | ] 27 | } 28 | 29 | resource "aws_api_gateway_stage" "api_stage" { 30 | # checkov:skip=CKV2_AWS_29:WAF not needed for non-prod use 31 | deployment_id = aws_api_gateway_deployment.api_deployment.id 32 | rest_api_id = aws_api_gateway_rest_api.api_gateway.id 33 | stage_name = "dev" 34 | 35 | cache_cluster_enabled = true 36 | cache_cluster_size = "0.5" 37 | 38 | xray_tracing_enabled = true 39 | 40 | access_log_settings { 41 | destination_arn = aws_cloudwatch_log_group.api_gateway_log_group.arn 42 | format = "{\"requestId\":\"$context.requestId\", \"ip\": \"$context.identity.sourceIp\", \"caller\":\"$context.identity.caller\", \"requestTime\":\"$context.requestTime\", \"httpMethod\":\"$context.httpMethod\", \"resourcePath\":\"$context.resourcePath\", \"status\":\"$context.status\", \"responseLength\":\"$context.responseLength\"}" 43 | } 44 | } 45 | 46 | resource "aws_api_gateway_resource" "api_gateway_resource" { 47 | rest_api_id = aws_api_gateway_rest_api.api_gateway.id 48 | parent_id = aws_api_gateway_rest_api.api_gateway.root_resource_id 49 | path_part = "{proxy+}" 50 | } 51 | 52 | resource "aws_api_gateway_method" "api_gateway_proxy_method" { 53 | rest_api_id = aws_api_gateway_rest_api.api_gateway.id 54 | resource_id = aws_api_gateway_resource.api_gateway_resource.id 55 | http_method = "ANY" 56 | authorization = "NONE" 57 | api_key_required = true 58 | 59 | request_parameters = { 60 | "method.request.path.proxy" = true 61 | } 62 | } 63 | 64 | resource "aws_api_gateway_method_settings" "api_gateway_method_settings" { 65 | rest_api_id = aws_api_gateway_rest_api.api_gateway.id 66 | stage_name = aws_api_gateway_stage.api_stage.stage_name 67 | method_path = "*/*" 68 | 69 | settings { 70 | caching_enabled = false 71 | metrics_enabled = true 72 | logging_level = "ERROR" 73 | } 74 | } 75 | 76 | resource "aws_api_gateway_integration" "api_proxy_integration" { 77 | rest_api_id = aws_api_gateway_rest_api.api_gateway.id 78 | resource_id = aws_api_gateway_resource.api_gateway_resource.id 79 | http_method = aws_api_gateway_method.api_gateway_proxy_method.http_method 80 | integration_http_method = "POST" 81 | type = "AWS_PROXY" 82 | uri = aws_lambda_function.api_lambda.invoke_arn 83 | } 84 | 85 | # 86 | # API gateway usage plan and key 87 | # 88 | resource "aws_api_gateway_usage_plan" "api_gateway_usage_plan" { 89 | name = "FastAPIUsagePlan" 90 | 91 | api_stages { 92 | api_id = aws_api_gateway_rest_api.api_gateway.id 93 | stage = aws_api_gateway_stage.api_stage.stage_name 94 | } 95 | } 96 | 97 | resource "aws_api_gateway_api_key" "api_key" { 98 | name = "FastAPI" 99 | } 100 | 101 | resource "aws_api_gateway_usage_plan_key" "api_gateway_usage_plan_key" { 102 | key_id = aws_api_gateway_api_key.api_key.id 103 | key_type = "API_KEY" 104 | usage_plan_id = aws_api_gateway_usage_plan.api_gateway_usage_plan.id 105 | } 106 | -------------------------------------------------------------------------------- /infra/modules/api-gateway/cloudwatch.tf: -------------------------------------------------------------------------------- 1 | # 2 | # Lambda CloudWatch logging 3 | # 4 | resource "aws_cloudwatch_log_group" "lambda_log_group" { 5 | # checkov:skip=CKV_AWS_158:Default service key encryption is acceptable 6 | name = "/aws/lambda/${aws_lambda_function.api_lambda.function_name}" 7 | retention_in_days = 14 8 | 9 | tags = { 10 | Project = var.project_name 11 | Billing = var.project_team 12 | } 13 | } 14 | 15 | resource "aws_iam_policy" "lambda_logging" { 16 | name = "LambdaCloudWatchLogging" 17 | description = "IAM policy for logging from a lambda" 18 | path = "/" 19 | policy = data.aws_iam_policy_document.lambda_cloudwatch_log_policy.json 20 | } 21 | 22 | resource "aws_iam_role_policy_attachment" "lambda_logging_policy_attachment" { 23 | role = aws_iam_role.api_lambda_role.name 24 | policy_arn = aws_iam_policy.lambda_logging.arn 25 | } 26 | 27 | data "aws_iam_policy_document" "lambda_cloudwatch_log_policy" { 28 | statement { 29 | effect = "Allow" 30 | actions = [ 31 | "logs:CreateLogGroup", 32 | "logs:CreateLogStream", 33 | "logs:PutLogEvents", 34 | ] 35 | resources = [ 36 | aws_cloudwatch_log_group.lambda_log_group.arn, 37 | "${aws_cloudwatch_log_group.lambda_log_group.arn}:log-stream:*" 38 | ] 39 | } 40 | } 41 | 42 | # 43 | # API Gateway CloudWatch logging 44 | # 45 | resource "aws_cloudwatch_log_group" "api_gateway_log_group" { 46 | # checkov:skip=CKV_AWS_158:Default service key encryption is acceptable 47 | name = "/aws/api-gateway/${aws_api_gateway_rest_api.api_gateway.name}" 48 | retention_in_days = 14 49 | 50 | tags = { 51 | Project = var.project_name 52 | Billing = var.project_team 53 | } 54 | } 55 | 56 | # This account is used by all API Gateway resources in a region 57 | resource "aws_api_gateway_account" "api_gateway_account" { 58 | cloudwatch_role_arn = aws_iam_role.api_gateway_cloudwatch_role.arn 59 | } 60 | 61 | resource "aws_iam_role" "api_gateway_cloudwatch_role" { 62 | name = "ApiGatewayCloudwatchRole" 63 | assume_role_policy = data.aws_iam_policy_document.api_gateway_assume_policy.json 64 | } 65 | 66 | resource "aws_iam_role_policy_attachment" "api_gateway_logging_policy_attachment" { 67 | role = aws_iam_role.api_gateway_cloudwatch_role.name 68 | policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" 69 | } 70 | 71 | data "aws_iam_policy_document" "api_gateway_assume_policy" { 72 | statement { 73 | effect = "Allow" 74 | actions = [ 75 | "sts:AssumeRole", 76 | ] 77 | principals { 78 | type = "Service" 79 | identifiers = ["apigateway.amazonaws.com"] 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /infra/modules/api-gateway/lambda.tf: -------------------------------------------------------------------------------- 1 | resource "aws_lambda_function" "api_lambda" { 2 | # checkov:skip=CKV_AWS_115:No function-level concurrent execution limit required 3 | # checkov:skip=CKV_AWS_116:No Dead Letter Queue required 4 | filename = "lambda.zip" 5 | function_name = "FastAPI" 6 | role = aws_iam_role.api_lambda_role.arn 7 | handler = "main.handler" 8 | runtime = "python3.8" 9 | 10 | source_code_hash = filebase64sha256("lambda.zip") 11 | 12 | vpc_config { 13 | subnet_ids = var.lambda_subnet_ids 14 | security_group_ids = [var.lambda_security_group_id] 15 | } 16 | 17 | tracing_config { 18 | mode = "PassThrough" 19 | } 20 | 21 | environment { 22 | variables = { 23 | MESSAGE_QUEUE_NAME = var.message_queue_name 24 | REGION = var.region 25 | } 26 | } 27 | 28 | tags = { 29 | Name = "${var.project_name}-function" 30 | Project = var.project_name 31 | Billing = var.project_team 32 | } 33 | } 34 | 35 | resource "aws_iam_role" "api_lambda_role" { 36 | name = "ApiLambdaRole" 37 | assume_role_policy = data.aws_iam_policy_document.lambda_assume_policy.json 38 | } 39 | 40 | resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { 41 | role = aws_iam_role.api_lambda_role.name 42 | policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" 43 | } 44 | 45 | resource "aws_iam_role_policy_attachment" "lambda_sqs_producer_policy_attachment" { 46 | role = aws_iam_role.api_lambda_role.name 47 | policy_arn = aws_iam_policy.lambda_sqs_producer_policy.arn 48 | } 49 | 50 | resource "aws_iam_policy" "lambda_sqs_producer_policy" { 51 | name = "LambdaSqsProducer" 52 | description = "IAM policy for creating messages in an SQS queue" 53 | path = "/" 54 | policy = data.aws_iam_policy_document.lambda_sqs_producer_policy.json 55 | } 56 | 57 | data "aws_iam_policy_document" "lambda_sqs_producer_policy" { 58 | statement { 59 | effect = "Allow" 60 | actions = [ 61 | "sqs:GetQueueAttributes", 62 | "sqs:GetQueueUrl", 63 | "sqs:SendMessage*" 64 | ] 65 | resources = [ 66 | var.message_queue_arn 67 | ] 68 | } 69 | } 70 | 71 | data "aws_iam_policy_document" "lambda_assume_policy" { 72 | statement { 73 | effect = "Allow" 74 | actions = [ 75 | "sts:AssumeRole", 76 | ] 77 | principals { 78 | type = "Service" 79 | identifiers = ["lambda.amazonaws.com"] 80 | } 81 | } 82 | } 83 | 84 | # Allow the API gateway to invoke this lambda function 85 | resource "aws_lambda_permission" "api_lambda_permission" { 86 | statement_id = "AllowExecutionFromAPIGateway" 87 | action = "lambda:InvokeFunction" 88 | function_name = aws_lambda_function.api_lambda.function_name 89 | principal = "apigateway.amazonaws.com" 90 | source_arn = "${aws_api_gateway_rest_api.api_gateway.execution_arn}/*/*/*" 91 | } 92 | -------------------------------------------------------------------------------- /infra/modules/api-gateway/outputs.tf: -------------------------------------------------------------------------------- 1 | output "api_gateway_stage_url" { 2 | description = "API Gateway stage invocation URL" 3 | value = aws_api_gateway_stage.api_stage.invoke_url 4 | } 5 | -------------------------------------------------------------------------------- /infra/modules/api-gateway/variables.tf: -------------------------------------------------------------------------------- 1 | variable "lambda_subnet_ids" { 2 | description = "Lambda's subnet IDs" 3 | type = list(string) 4 | } 5 | 6 | variable "lambda_security_group_id" { 7 | description = "Lambda's security group ID" 8 | type = string 9 | } 10 | 11 | variable "message_queue_arn" { 12 | description = "ARN of the SQS to send messages to" 13 | type = string 14 | } 15 | 16 | variable "message_queue_name" { 17 | description = "Name of the SQS to send messages to" 18 | type = string 19 | } 20 | -------------------------------------------------------------------------------- /infra/modules/network/cloudwatch.tf: -------------------------------------------------------------------------------- 1 | resource "aws_flow_log" "api_flow_logs" { 2 | iam_role_arn = aws_iam_role.vpc_flow_logs_role.arn 3 | log_destination = aws_cloudwatch_log_group.vpc_flow_logs_group.arn 4 | traffic_type = "ALL" 5 | vpc_id = aws_vpc.api_vpc.id 6 | } 7 | 8 | resource "aws_cloudwatch_log_group" "vpc_flow_logs_group" { 9 | # checkov:skip=CKV_AWS_158:Default service key encryption is acceptable 10 | name = "/aws/vpc-flow-logs/${var.project_name}-vpc" 11 | retention_in_days = 14 12 | 13 | tags = { 14 | Project = var.project_name 15 | Billing = var.project_team 16 | } 17 | } 18 | 19 | resource "aws_iam_role" "vpc_flow_logs_role" { 20 | name = "VpcFlowLogsRole" 21 | assume_role_policy = data.aws_iam_policy_document.vpc_flow_logs_assume_policy.json 22 | } 23 | 24 | resource "aws_iam_policy" "vpc_flow_logs_policy" { 25 | name = "VpcFlowLogsCloudWatchLogging" 26 | description = "IAM policy for VPC flow logs" 27 | path = "/" 28 | policy = data.aws_iam_policy_document.vpc_flow_logs_cloudwatch_log_policy.json 29 | } 30 | 31 | resource "aws_iam_role_policy_attachment" "vpc_flow_logs_policy_attachment" { 32 | role = aws_iam_role.vpc_flow_logs_role.name 33 | policy_arn = aws_iam_policy.vpc_flow_logs_policy.arn 34 | } 35 | 36 | data "aws_iam_policy_document" "vpc_flow_logs_assume_policy" { 37 | statement { 38 | effect = "Allow" 39 | actions = [ 40 | "sts:AssumeRole", 41 | ] 42 | principals { 43 | type = "Service" 44 | identifiers = ["vpc-flow-logs.amazonaws.com"] 45 | } 46 | } 47 | } 48 | 49 | data "aws_iam_policy_document" "vpc_flow_logs_cloudwatch_log_policy" { 50 | statement { 51 | effect = "Allow" 52 | actions = [ 53 | "logs:CreateLogGroup", 54 | "logs:CreateLogStream", 55 | "logs:PutLogEvents", 56 | ] 57 | resources = [ 58 | aws_cloudwatch_log_group.vpc_flow_logs_group.arn, 59 | "${aws_cloudwatch_log_group.vpc_flow_logs_group.arn}:log-stream:*" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /infra/modules/network/outputs.tf: -------------------------------------------------------------------------------- 1 | output "lambda_subnet_ids" { 2 | description = "Lambda's subnet IDs" 3 | value = aws_subnet.api_subnet.*.id 4 | } 5 | 6 | output "lambda_security_group_id" { 7 | description = "Lambda's security group ID" 8 | value = aws_security_group.lambda_security_group.id 9 | } 10 | -------------------------------------------------------------------------------- /infra/modules/network/variables.tf: -------------------------------------------------------------------------------- 1 | variable "vpc_cidr_block" { 2 | description = "(Optional) The VPC's CIDR block" 3 | default = "10.16.0.0/16" 4 | type = string 5 | } 6 | -------------------------------------------------------------------------------- /infra/modules/network/vpc.tf: -------------------------------------------------------------------------------- 1 | data "aws_availability_zones" "available" { 2 | state = "available" 3 | } 4 | 5 | # 6 | # VPC and subnet 7 | # 8 | 9 | resource "aws_vpc" "api_vpc" { 10 | # checkov:skip=CKV2_AWS_1:False positive - NACL is attached to all subnets 11 | cidr_block = var.vpc_cidr_block 12 | enable_dns_support = true 13 | enable_dns_hostnames = true 14 | assign_generated_ipv6_cidr_block = true 15 | 16 | tags = { 17 | Name = "${var.project_name}-vpc" 18 | Project = var.project_name 19 | Billing = var.project_team 20 | } 21 | } 22 | 23 | resource "aws_subnet" "api_subnet" { 24 | count = 3 25 | 26 | vpc_id = aws_vpc.api_vpc.id 27 | cidr_block = cidrsubnet(var.vpc_cidr_block, 4, count.index) 28 | availability_zone = element(data.aws_availability_zones.available.names, count.index) 29 | 30 | tags = { 31 | Name = "${var.project_name}-subnet-${count.index + 1}" 32 | Project = var.project_name 33 | Billing = var.project_team 34 | } 35 | } 36 | 37 | # 38 | # Security groups 39 | # 40 | 41 | resource "aws_default_security_group" "vpc_default" { 42 | vpc_id = aws_vpc.api_vpc.id 43 | } 44 | 45 | resource "aws_security_group" "vpc_endpoints" { 46 | name = "VpcEndpoints" 47 | description = "VPC Endpoint security group" 48 | vpc_id = aws_vpc.api_vpc.id 49 | } 50 | 51 | resource "aws_security_group" "lambda_security_group" { 52 | # checkov:skip=CKV2_AWS_5:False positive - SG is attached to Lambda in ./infra/modules/api-gateway/lambda.tf 53 | name = "Lambda" 54 | description = "Allow TLS outbound traffic to CloudWatch from the Lambda" 55 | vpc_id = aws_vpc.api_vpc.id 56 | } 57 | 58 | resource "aws_security_group_rule" "vpc_endpoints_from_lambda" { 59 | description = "Security group rule for ingress to VPC endpoints" 60 | type = "ingress" 61 | from_port = 443 62 | to_port = 443 63 | protocol = "tcp" 64 | security_group_id = aws_security_group.vpc_endpoints.id 65 | source_security_group_id = aws_security_group.lambda_security_group.id 66 | } 67 | 68 | resource "aws_security_group_rule" "lambda_to_vpc_endpoints" { 69 | description = "Security group rule for egress to VPC endpoints" 70 | type = "egress" 71 | from_port = 443 72 | to_port = 443 73 | protocol = "tcp" 74 | security_group_id = aws_security_group.lambda_security_group.id 75 | source_security_group_id = aws_security_group.vpc_endpoints.id 76 | } 77 | 78 | # 79 | # Network Access Control List (NACL) 80 | # 81 | 82 | resource "aws_default_network_acl" "vpc_default" { 83 | default_network_acl_id = aws_vpc.api_vpc.default_network_acl_id 84 | subnet_ids = aws_subnet.api_subnet.*.id 85 | 86 | ingress { 87 | rule_no = 100 88 | protocol = "tcp" 89 | action = "allow" 90 | cidr_block = "0.0.0.0/0" 91 | from_port = 443 92 | to_port = 443 93 | } 94 | 95 | egress { 96 | rule_no = 200 97 | protocol = "tcp" 98 | action = "allow" 99 | cidr_block = "0.0.0.0/0" 100 | from_port = 443 101 | to_port = 443 102 | } 103 | } 104 | 105 | # 106 | # PrivateLink endpoints to CloudWatch logs/monitoring 107 | # 108 | 109 | resource "aws_vpc_endpoint" "logs" { 110 | vpc_id = aws_vpc.api_vpc.id 111 | vpc_endpoint_type = "Interface" 112 | service_name = "com.amazonaws.${var.region}.logs" 113 | private_dns_enabled = true 114 | security_group_ids = [ 115 | aws_security_group.vpc_endpoints.id, 116 | ] 117 | subnet_ids = aws_subnet.api_subnet.*.id 118 | } 119 | 120 | resource "aws_vpc_endpoint" "monitoring" { 121 | vpc_id = aws_vpc.api_vpc.id 122 | vpc_endpoint_type = "Interface" 123 | service_name = "com.amazonaws.${var.region}.monitoring" 124 | private_dns_enabled = true 125 | security_group_ids = [ 126 | aws_security_group.vpc_endpoints.id, 127 | ] 128 | subnet_ids = aws_subnet.api_subnet.*.id 129 | } 130 | 131 | # 132 | # PrivateLink endpoint to SQS 133 | # 134 | 135 | resource "aws_vpc_endpoint" "sqs" { 136 | vpc_id = aws_vpc.api_vpc.id 137 | vpc_endpoint_type = "Interface" 138 | service_name = "com.amazonaws.${var.region}.sqs" 139 | private_dns_enabled = true 140 | security_group_ids = [ 141 | aws_security_group.vpc_endpoints.id, 142 | ] 143 | subnet_ids = aws_subnet.api_subnet.*.id 144 | } 145 | -------------------------------------------------------------------------------- /infra/modules/sqs/data.tf: -------------------------------------------------------------------------------- 1 | data "aws_caller_identity" "current" {} 2 | -------------------------------------------------------------------------------- /infra/modules/sqs/output.tf: -------------------------------------------------------------------------------- 1 | output "message_queue_arn" { 2 | value = aws_sqs_queue.message_queue.arn 3 | } 4 | 5 | output "message_queue_name" { 6 | value = aws_sqs_queue.message_queue.name 7 | } 8 | 9 | output "message_queue_url" { 10 | value = aws_sqs_queue.message_queue.url 11 | } 12 | -------------------------------------------------------------------------------- /infra/modules/sqs/sqs.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | account_id = data.aws_caller_identity.current.account_id 3 | } 4 | 5 | resource "aws_sqs_queue" "message_queue" { 6 | name = "message-queue" 7 | max_message_size = 1024 8 | message_retention_seconds = 86400 # 1 day 9 | kms_master_key_id = "alias/aws/sqs" 10 | 11 | redrive_policy = jsonencode({ 12 | deadLetterTargetArn = aws_sqs_queue.message_deadletter_queue.arn 13 | maxReceiveCount = var.max_receive_count 14 | }) 15 | 16 | tags = { 17 | Name = "${var.project_name}-message-queue" 18 | Project = var.project_name 19 | Billing = var.project_team 20 | } 21 | } 22 | 23 | # Only allow this account to send messages to the queue 24 | resource "aws_sqs_queue_policy" "message_queue_policy" { 25 | queue_url = aws_sqs_queue.message_queue.id 26 | policy = data.aws_iam_policy_document.message_queue_policy.json 27 | } 28 | 29 | data "aws_iam_policy_document" "message_queue_policy" { 30 | statement { 31 | effect = "Allow" 32 | actions = [ 33 | "sqs:GetQueueAttributes", 34 | "sqs:GetQueueUrl", 35 | "sqs:SendMessage*" 36 | ] 37 | resources = [ 38 | aws_sqs_queue.message_queue.arn 39 | ] 40 | principals { 41 | type = "Service" 42 | identifiers = ["lambda.amazonaws.com"] 43 | } 44 | condition { 45 | test = "ArnLike" 46 | variable = "aws:SourceArn" 47 | 48 | values = [ 49 | "arn:aws:lambda:${var.region}:${local.account_id}:function:${var.lambda_producer_function_name}*" 50 | ] 51 | } 52 | } 53 | } 54 | 55 | resource "aws_sqs_queue" "message_deadletter_queue" { 56 | name = "message-deadletter-queue" 57 | max_message_size = 1024 58 | message_retention_seconds = 604800 # 1 week 59 | kms_master_key_id = "alias/aws/sqs" 60 | 61 | tags = { 62 | Name = "${var.project_name}-message-deadletter-queue" 63 | Project = var.project_name 64 | Billing = var.project_team 65 | } 66 | } 67 | 68 | # Only allow the message queue to send messages to the deadletter queue 69 | resource "aws_sqs_queue_policy" "message_deadletter_queue_policy" { 70 | queue_url = aws_sqs_queue.message_deadletter_queue.id 71 | policy = data.aws_iam_policy_document.message_deadletter_queue_policy.json 72 | } 73 | 74 | data "aws_iam_policy_document" "message_deadletter_queue_policy" { 75 | statement { 76 | effect = "Allow" 77 | actions = [ 78 | "sqs:GetQueueAttributes", 79 | "sqs:GetQueueUrl", 80 | "sqs:SendMessage*" 81 | ] 82 | resources = [ 83 | aws_sqs_queue.message_deadletter_queue.arn 84 | ] 85 | principals { 86 | type = "Service" 87 | identifiers = ["sqs.amazonaws.com"] 88 | } 89 | condition { 90 | test = "ArnEquals" 91 | variable = "aws:SourceArn" 92 | values = [ 93 | aws_sqs_queue.message_queue.arn 94 | ] 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /infra/modules/sqs/variables.tf: -------------------------------------------------------------------------------- 1 | variable "lambda_producer_function_name" { 2 | description = "Name of the Lambda function that produces messages for the SQS queue" 3 | type = string 4 | } 5 | 6 | variable "max_receive_count" { 7 | description = "Max number of times a message can be processed without deletion before sent to the deadletter queue" 8 | type = number 9 | } 10 | --------------------------------------------------------------------------------