diff --git a/README.md b/README.md index 460ce38..ff92dfb 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,4 @@ This is an example repository to showcase some IaC usage with different cloud pr - [aws-ec2+rds](html-db-website/aws-ec2+rds) - [aws-ecs+rds](html-db-website/aws-ecs+rds) - [aws-eks](html-db-website/aws-eks) + - [aws-s3+lambda+rds](html-db-website/aws-s3+lambda+rds) diff --git a/html-db-website/aws-s3+lambda+rds/README.md b/html-db-website/aws-s3+lambda+rds/README.md new file mode 100644 index 0000000..7232331 --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/README.md @@ -0,0 +1,89 @@ +# AWS-S3+Lambda+RDS + +This is an example repository containing Terraform code. It contains the code to deploy a basic application (html web page + relational database) with S3, Lambda and RDS. +We are using Api gateway and Cloudfront (mostly to avoid CORS issues) to expose the application. + +## Tree +``` +. +├── app +│   ├── app.py +│   ├── lambda.zip +│   ├── requirements.txt +│   └── zip.sh # Helper script to run to generate lambda.zip +├── misc +│   └── architecture.dot.png # Generated with https://github.com/patrickchugh/terravision. +├── README.md +└── terraform + ├── iam.tf + ├── main.tf + ├── network.tf + ├── outputs.tf + ├── provider.tf + ├── s3.tf + ├── security_group.tf + ├── templates + │   └── index.html.tftpl + └── variables.tf +``` + +## Architecture diagram + + + +## Infracost + +```shell + Name Monthly Qty Unit Monthly Cost + + aws_db_instance.postgres + ├─ Database instance (on-demand, Single-AZ, db.t3.micro) 730 hours $13.14 + └─ Storage (general purpose SSD, gp2) 20 GB $2.30 + + aws_apigatewayv2_api.http + └─ Requests (first 300M) Monthly cost depends on usage: $1.00 per 1M requests + + aws_cloudfront_distribution.s3_distribution + ├─ Invalidation requests (first 1k) Monthly cost depends on usage: $0.00 per paths + └─ US, Mexico, Canada + ├─ Data transfer out to internet (first 10TB) Monthly cost depends on usage: $0.085 per GB + ├─ Data transfer out to origin Monthly cost depends on usage: $0.02 per GB + ├─ HTTP requests Monthly cost depends on usage: $0.0075 per 10k requests + └─ HTTPS requests Monthly cost depends on usage: $0.01 per 10k requests + + aws_lambda_function.this + ├─ Requests Monthly cost depends on usage: $0.20 per 1M requests + ├─ Ephemeral storage Monthly cost depends on usage: $0.0000000309 per GB-seconds + └─ Duration (first 6B) Monthly cost depends on usage: $0.0000166667 per GB-seconds + + aws_s3_bucket.frontend + └─ Standard + ├─ Storage Monthly cost depends on usage: $0.023 per GB + ├─ PUT, COPY, POST, LIST requests Monthly cost depends on usage: $0.005 per 1k requests + ├─ GET, SELECT, and all other requests Monthly cost depends on usage: $0.0004 per 1k requests + ├─ Select data scanned Monthly cost depends on usage: $0.002 per GB + └─ Select data returned Monthly cost depends on usage: $0.0007 per GB + + OVERALL TOTAL $15.44 + +*Usage costs can be estimated by updating Infracost Cloud settings, see docs for other options. + +────────────────────────────────── +22 cloud resources were detected: +∙ 5 were estimated +∙ 17 were free + +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━┓ +┃ Project ┃ Baseline cost ┃ Usage cost* ┃ Total cost ┃ +┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━━━━━━━━┫ +┃ terraform ┃ $15 ┃ - ┃ $15 ┃ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━━━━━━━━┛ +``` + +## Helpful informations + +Must export database username and password before usage. +```shell +export TF_VAR_db_username= +export TF_VAR_db_password= +``` diff --git a/html-db-website/aws-s3+lambda+rds/app/app.py b/html-db-website/aws-s3+lambda+rds/app/app.py new file mode 100644 index 0000000..cf34b65 --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/app/app.py @@ -0,0 +1,83 @@ +import os +import ssl +from flask import Flask, request, jsonify +from flask_sqlalchemy import SQLAlchemy +from io import BytesIO + +app = Flask(__name__) + +ssl_context = ssl.create_default_context() +ssl_context.check_hostname = False +ssl_context.verify_mode = ssl.CERT_NONE + +db_url = os.environ.get('DATABASE_URL') +app.config['SQLALCHEMY_DATABASE_URI'] = db_url +app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { + 'connect_args': {'ssl_context': ssl_context} +} +db = SQLAlchemy(app) + +class Task(db.Model): + id = db.Column(db.Integer, primary_key=True) + content = db.Column(db.String(200), nullable=False) + +@app.route('/tasks', methods=['GET', 'POST']) +def tasks(): + with app.app_context(): + db.create_all() + if request.method == 'POST': + data = request.get_json() + new_task = Task(content=data['content']) + db.session.add(new_task) + db.session.commit() + return jsonify({"message": "Task added"}), 201 + all_tasks = Task.query.all() + return jsonify([{"id": t.id, "content": t.content} for t in all_tasks]) + +def handler(event, context): + method = event.get('requestContext', {}).get('http', {}).get('method', 'GET') + path = event.get('rawPath', '/') + query = event.get('rawQueryString', '') + headers = event.get('headers', {}) + body = event.get('body', '') or '' + if event.get('isBase64Encoded'): + import base64 + body = base64.b64decode(body) + else: + body = body.encode('utf-8') + + environ = { + 'REQUEST_METHOD': method, + 'PATH_INFO': path, + 'QUERY_STRING': query, + 'CONTENT_LENGTH': str(len(body)), + 'CONTENT_TYPE': headers.get('content-type', ''), + 'SERVER_NAME': 'lambda', + 'SERVER_PORT': '443', + 'wsgi.input': BytesIO(body), + 'wsgi.errors': BytesIO(), + 'wsgi.url_scheme': 'https', + 'wsgi.multithread': False, + 'wsgi.multiprocess': False, + 'wsgi.run_once': False, + } + for k, v in headers.items(): + key = 'HTTP_' + k.upper().replace('-', '_') + environ[key] = v + + response_started = {} + response_body = [] + + def start_response(status, response_headers, exc_info=None): + response_started['status'] = int(status.split(' ', 1)[0]) + response_started['headers'] = dict(response_headers) + + result = app(environ, start_response) + for chunk in result: + response_body.append(chunk) + + return { + 'statusCode': response_started['status'], + 'headers': response_started['headers'], + 'body': b''.join(response_body).decode('utf-8'), + } diff --git a/html-db-website/aws-s3+lambda+rds/app/lambda.zip b/html-db-website/aws-s3+lambda+rds/app/lambda.zip new file mode 100644 index 0000000..02f8290 Binary files /dev/null and b/html-db-website/aws-s3+lambda+rds/app/lambda.zip differ diff --git a/html-db-website/aws-s3+lambda+rds/app/requirements.txt b/html-db-website/aws-s3+lambda+rds/app/requirements.txt new file mode 100644 index 0000000..275cb72 --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/app/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 +pg8000==1.30.1 diff --git a/html-db-website/aws-s3+lambda+rds/app/zip.sh b/html-db-website/aws-s3+lambda+rds/app/zip.sh new file mode 100755 index 0000000..e26b8aa --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/app/zip.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -euo pipefail + +rm -rf build lambda.zip +mkdir build + +cp app.py build/ + +# Build dependencies for Lambda using Docker +docker run --rm \ + -v "$(pwd)/build":/var/task \ + -v "$(pwd)/requirements.txt":/var/requirements.txt \ + --entrypoint "" \ + public.ecr.aws/lambda/python:3.11 \ + python3.11 -m pip install -r /var/requirements.txt -t /var/task + +cd build +zip -r ../lambda.zip . +cd .. diff --git a/html-db-website/aws-s3+lambda+rds/misc/architecture.dot.png b/html-db-website/aws-s3+lambda+rds/misc/architecture.dot.png new file mode 100644 index 0000000..2850889 Binary files /dev/null and b/html-db-website/aws-s3+lambda+rds/misc/architecture.dot.png differ diff --git a/html-db-website/aws-s3+lambda+rds/terraform/iam.tf b/html-db-website/aws-s3+lambda+rds/terraform/iam.tf new file mode 100644 index 0000000..27df334 --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/terraform/iam.tf @@ -0,0 +1,47 @@ +resource "aws_iam_role" "lambda" { + name = "${var.function_name}-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "vpc_access" { + role = aws_iam_role.lambda.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole" +} + +resource "aws_lambda_permission" "api" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.this.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_apigatewayv2_api.http.execution_arn}/*/*" +} + +resource "aws_s3_bucket_public_access_block" "frontend" { + bucket = aws_s3_bucket.frontend.id + + block_public_acls = false + block_public_policy = false + ignore_public_acls = false + restrict_public_buckets = false +} + +resource "aws_s3_bucket_policy" "public_read" { + depends_on = [aws_s3_bucket_public_access_block.frontend] + + bucket = aws_s3_bucket.frontend.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow", Principal = "*", Action = "s3:GetObject" + Resource = "${aws_s3_bucket.frontend.arn}/*" + }] + }) +} diff --git a/html-db-website/aws-s3+lambda+rds/terraform/main.tf b/html-db-website/aws-s3+lambda+rds/terraform/main.tf new file mode 100644 index 0000000..c8b3391 --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/terraform/main.tf @@ -0,0 +1,79 @@ +locals { + zip_path = "${path.module}/../app/lambda.zip" +} + +resource "aws_lambda_function" "this" { + function_name = var.function_name + role = aws_iam_role.lambda.arn + runtime = "python3.11" + handler = "app.handler" + + filename = local.zip_path + source_code_hash = filebase64sha256(local.zip_path) + + timeout = 15 + + vpc_config { + subnet_ids = aws_subnet.private[*].id + security_group_ids = [aws_security_group.lambda_sg.id] + } + + environment { + variables = { + DATABASE_URL = "postgresql+pg8000://${var.db_username}:${var.db_password}@${aws_db_instance.postgres.address}/${var.db_name}" + } + } +} + +resource "aws_apigatewayv2_api" "http" { + name = "${var.function_name}-api" + protocol_type = "HTTP" + + cors_configuration { + allow_origins = ["https://${aws_cloudfront_distribution.s3_distribution.domain_name}"] + allow_methods = ["GET", "POST", "OPTIONS"] + allow_headers = ["content-type"] + } +} + +resource "aws_apigatewayv2_integration" "lambda" { + api_id = aws_apigatewayv2_api.http.id + integration_type = "AWS_PROXY" + integration_uri = aws_lambda_function.this.invoke_arn + payload_format_version = "2.0" +} + +resource "aws_apigatewayv2_route" "get_tasks" { + api_id = aws_apigatewayv2_api.http.id + route_key = "GET /tasks" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" +} + +resource "aws_apigatewayv2_route" "post_tasks" { + api_id = aws_apigatewayv2_api.http.id + route_key = "POST /tasks" + target = "integrations/${aws_apigatewayv2_integration.lambda.id}" +} + +resource "aws_apigatewayv2_stage" "default" { + api_id = aws_apigatewayv2_api.http.id + name = "$default" + auto_deploy = true +} + +resource "aws_db_subnet_group" "db" { + name = "main" + subnet_ids = aws_subnet.private[*].id +} + +resource "aws_db_instance" "postgres" { + allocated_storage = 20 + engine = "postgres" + instance_class = "db.t3.micro" + db_name = var.db_name + username = var.db_username + password = var.db_password + db_subnet_group_name = aws_db_subnet_group.db.name + vpc_security_group_ids = [aws_security_group.rds_sg.id] + skip_final_snapshot = true +} diff --git a/html-db-website/aws-s3+lambda+rds/terraform/network.tf b/html-db-website/aws-s3+lambda+rds/terraform/network.tf new file mode 100644 index 0000000..320893b --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/terraform/network.tf @@ -0,0 +1,13 @@ +resource "aws_vpc" "main" { + cidr_block = "10.0.0.0/16" + enable_dns_hostnames = true +} + +resource "aws_subnet" "private" { + count = 2 + vpc_id = aws_vpc.main.id + cidr_block = "10.0.${count.index}.0/24" + availability_zone = data.aws_availability_zones.available.names[count.index] +} + +data "aws_availability_zones" "available" {} diff --git a/html-db-website/aws-s3+lambda+rds/terraform/outputs.tf b/html-db-website/aws-s3+lambda+rds/terraform/outputs.tf new file mode 100644 index 0000000..21982ee --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/terraform/outputs.tf @@ -0,0 +1,7 @@ +output "website_url" { + value = "https://${aws_cloudfront_distribution.s3_distribution.domain_name}" +} + +output "api_url" { + value = aws_apigatewayv2_api.http.api_endpoint +} diff --git a/html-db-website/aws-s3+lambda+rds/terraform/provider.tf b/html-db-website/aws-s3+lambda+rds/terraform/provider.tf new file mode 100644 index 0000000..9403d13 --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/terraform/provider.tf @@ -0,0 +1,17 @@ +terraform { + required_version = ">= 1.6" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.aws_region + s3_use_path_style = true + skip_credentials_validation = false + skip_metadata_api_check = false + skip_requesting_account_id = true +} diff --git a/html-db-website/aws-s3+lambda+rds/terraform/s3.tf b/html-db-website/aws-s3+lambda+rds/terraform/s3.tf new file mode 100644 index 0000000..34dd495 --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/terraform/s3.tf @@ -0,0 +1,50 @@ +resource "aws_s3_bucket" "frontend" { + bucket = "html-db-public-s3-bucket" +} + +resource "aws_s3_object" "index" { + bucket = aws_s3_bucket.frontend.id + key = "index.html" + content = templatefile("${path.module}/templates/index.html.tftpl", { + api_url = aws_apigatewayv2_stage.default.invoke_url + }) + content_type = "text/html" + etag = md5(templatefile("${path.module}/templates/index.html.tftpl", { + api_url = aws_apigatewayv2_stage.default.invoke_url + })) +} + +resource "aws_s3_bucket_website_configuration" "hosting" { + bucket = aws_s3_bucket.frontend.id + index_document { suffix = "index.html" } +} + +resource "aws_cloudfront_distribution" "s3_distribution" { + origin { + domain_name = aws_s3_bucket.frontend.bucket_regional_domain_name + origin_id = "S3-Origin" + } + enabled = true + default_root_object = "index.html" + default_cache_behavior { + allowed_methods = ["GET", "HEAD"] + cached_methods = ["GET", "HEAD"] + target_origin_id = "S3-Origin" + viewer_protocol_policy = "redirect-to-https" + forwarded_values { + query_string = false + cookies { + forward = "none" + } + } + } + viewer_certificate { + cloudfront_default_certificate = true + } + restrictions { + geo_restriction { + restriction_type = "none" + locations = [] + } + } +} diff --git a/html-db-website/aws-s3+lambda+rds/terraform/security_group.tf b/html-db-website/aws-s3+lambda+rds/terraform/security_group.tf new file mode 100644 index 0000000..260b43e --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/terraform/security_group.tf @@ -0,0 +1,22 @@ +resource "aws_security_group" "lambda_sg" { + name = "lambda-sg" + vpc_id = aws_vpc.main.id + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_security_group" "rds_sg" { + name = "rds-sg" + vpc_id = aws_vpc.main.id + ingress { + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.lambda_sg.id] + } +} + diff --git a/html-db-website/aws-s3+lambda+rds/terraform/templates/index.html.tftpl b/html-db-website/aws-s3+lambda+rds/terraform/templates/index.html.tftpl new file mode 100644 index 0000000..365f292 --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/terraform/templates/index.html.tftpl @@ -0,0 +1,41 @@ + + + + Task Manager + + + +

My Serverless Tasks

+ + +
Loading tasks...
+ + + + diff --git a/html-db-website/aws-s3+lambda+rds/terraform/variables.tf b/html-db-website/aws-s3+lambda+rds/terraform/variables.tf new file mode 100644 index 0000000..0d9c914 --- /dev/null +++ b/html-db-website/aws-s3+lambda+rds/terraform/variables.tf @@ -0,0 +1,29 @@ +variable "aws_region" { + description = "the AWS region" + type = string + default = "us-east-1" +} + +variable "function_name" { + description = "the name of the function" + type = string + default = "flask-lambda" +} + +variable "db_username" { + description = "RDS Root username" + type = string + sensitive = true +} + +variable "db_password" { + description = "RDS Root Password" + type = string + sensitive = true +} + +variable "db_name" { + description = "RDS Root username" + type = string + default = "tasklist_db" +}