diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..7e0ace5 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,70 @@ +name: Terraform Deploy + +on: + push: + branches: + - staging + - main + workflow_dispatch: + +env: + TF_WORKING_DIR: terraform/ + +jobs: + terraform: + name: Terraform (plan & apply) + runs-on: ubuntu-latest + environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'staging' }} + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v2 + with: + terraform_version: 1.5.6 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Terraform Init + working-directory: ${{ env.TF_WORKING_DIR }} + run: terraform init -input=false + + - name: Terraform Validate & Format + working-directory: ${{ env.TF_WORKING_DIR }} + run: | + terraform fmt -check + terraform validate + + - name: Terraform Plan + id: plan + working-directory: ${{ env.TF_WORKING_DIR }} + run: | + if [ "${{ github.ref }}" = "refs/heads/staging" ]; then + terraform plan -var-file="staging.tfvars" -out=tfplan + else + terraform plan -var-file="prod.tfvars" -out=tfplan + fi + + - name: Terraform Apply + if: github.ref == 'refs/heads/staging' + working-directory: ${{ env.TF_WORKING_DIR }} + run: terraform apply -input=false -auto-approve tfplan + + - name: Terraform Apply (prod) - requires env approval + if: github.ref == 'refs/heads/main' + working-directory: ${{ env.TF_WORKING_DIR }} + run: terraform apply -input=false -auto-approve tfplan + + - name: Show outputs + if: success() + working-directory: ${{ env.TF_WORKING_DIR }} + run: terraform output -json diff --git a/.github/workflows/destroy.yaml b/.github/workflows/destroy.yaml new file mode 100644 index 0000000..6836253 --- /dev/null +++ b/.github/workflows/destroy.yaml @@ -0,0 +1,48 @@ +name: Terraform Destroy + +on: + workflow_dispatch: + inputs: + environment: + description: "Environment to destroy" + required: true + type: choice + options: + - staging + - prod + +env: + TF_WORKING_DIR: terraform/ + +jobs: + destroy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v2 + with: + terraform_version: 1.5.6 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Terraform Init + working-directory: ${{ env.TF_WORKING_DIR }} + run: terraform init -input=false + + - name: Terraform Destroy + working-directory: ${{ env.TF_WORKING_DIR }} + run: | + if [ "${{ github.event.inputs.environment }}" = "staging" ]; then + terraform destroy -var-file="staging.tfvars" -auto-approve + else + terraform destroy -var-file="prod.tfvars" -auto-approve + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d9015b --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.terraform +*.tfstate +*.tfstate.backup +.terraform.tfstate.lock.info +crash.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terragrunt-cache +.terragrunt-tfstate-backups +.terraform.lock.hcl +.terraformrc +.terraform.* \ No newline at end of file diff --git a/README.md b/README.md index e323310..34aae76 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,34 @@ -# Serverless-Health-Check-API-with-CI-CD -Serverless Health Check API with CI/CD +# Serverless Health Check API with CI/CD + +create hello lambda funtion using Python + +``` +# Hello lambda function +def lambda_handler(event, context): + name = event.get("name", "World") + message = f"Hello, {name}!" + + return { + "statusCode": 200, + "body": message + } + +# Run the funtion locally for testing +if __name__ == "__main__": + test_event = {"name": "Emmanuel Ogah"} + result = lambda_handler(test_event, None) + print(result) +``` + +Run the python funtion locally using VS Code Run Button +- Click the “Run Python File” button in the top right corner. + +``` +# Output + +{'statusCode': 200, 'body': 'Hello, Emmanuel Ogah!'}" +``` + +- Create the terraform folder structure + +- Deploy with: terraform init then terraform apply -var-file="staging.tfvars" (or prod.tfvars) \ No newline at end of file diff --git a/lambda/lambda_function.py b/lambda/lambda_function.py new file mode 100644 index 0000000..43a7b84 --- /dev/null +++ b/lambda/lambda_function.py @@ -0,0 +1,46 @@ +import uuid +import json +import boto3 +import os +import logging +from datetime import datetime + + +TABLE_NAME = os.environ.get("REQUESTS_TABLE", "unknown-table") + +dynamodb = boto3.resource("dynamodb") +table = dynamodb.Table(TABLE_NAME) + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +def lambda_handler(event, context): + # Incoming event logging + logger.info("Received event: %s", json.dumps(event)) + + # Create request id + request_id = str(uuid.uuid4()) + item = { + "id": request_id, + "timestamp": datetime.now().isoformat() + "Z", + "event": event + } + + # Save request to DynamoDB + try: + table.put_item(Item=item) + logger.info("Saved item %s to %s", request_id, TABLE_NAME) + except Exception as e: + logger.exception("Failed to write to DynamoDB: %s", e) + return { + "statusCode": 500, + "body": json.dumps({"status": "error", "message": "Failed saving request."}), + "headers": {"Content-Type": "application/json"} + } + + # Return success + return { + "statusCode": 200, + "body": json.dumps({"status": "healthy", "message": "Request processed and saved.", "id": request_id}), + "headers": {"Content-Type": "application/json"} + } diff --git a/terraform/backend.tfvars b/terraform/backend.tfvars new file mode 100644 index 0000000..71aaf90 --- /dev/null +++ b/terraform/backend.tfvars @@ -0,0 +1,5 @@ +# bucket = "serverlesshealthcheckapi" +# key = "health-check-app/terraform.tfstate" +# region = "us-east-1" +# encrypt = true +# dynamodb_table = "terraform-locks" \ No newline at end of file diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..60f09a3 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,64 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region + + default_tags { + tags = local.common_tags + } +} + +locals { + common_tags = { + Environment = var.environment + Project = var.project_name + ManagedBy = "Terraform" + CreatedAt = timestamp() + } +} + +# DynamoDB Module +module "dynamodb" { + source = "./modules/dynamodb" + environment = var.environment + common_tags = local.common_tags +} + +# IAM Module +module "iam" { + source = "./modules/iam" + + environment = var.environment + dynamodb_table_arn = module.dynamodb.table_arn + common_tags = local.common_tags +} + +# API Gateway Module +module "api_gateway" { + source = "./modules/api-gateway" + environment = var.environment + lambda_invoke_arn = module.lambda.function_invoke_arn + common_tags = local.common_tags +} + +# Lambda Module +module "lambda" { + source = "./modules/lambda" + environment = var.environment + lambda_role_arn = module.iam.lambda_role_arn + dynamodb_table_name = module.dynamodb.table_name + api_gateway_execution_arn = module.api_gateway.execution_arn + lambda_funtion_dir = var.lambda_funtion_dir + common_tags = local.common_tags + # depends_on = [module.api_gateway] +} + diff --git a/terraform/modules/api-gateway/main.tf b/terraform/modules/api-gateway/main.tf new file mode 100644 index 0000000..8152db3 --- /dev/null +++ b/terraform/modules/api-gateway/main.tf @@ -0,0 +1,62 @@ +resource "aws_apigatewayv2_api" "health_api" { + name = "${var.environment}-serverless-health-check-api" + protocol_type = "HTTP" + + cors_configuration { + allow_origins = ["*"] + allow_methods = ["GET", "POST", "OPTIONS"] + allow_headers = ["*"] + } + + tags = var.common_tags +} + +resource "aws_apigatewayv2_integration" "lambda_integration" { + api_id = aws_apigatewayv2_api.health_api.id + integration_type = "AWS_PROXY" + integration_method = "POST" + payload_format_version = "2.0" + integration_uri = var.lambda_invoke_arn + depends_on = [] +} + +resource "aws_apigatewayv2_route" "health_route_get" { + api_id = aws_apigatewayv2_api.health_api.id + route_key = "GET /health" + target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}" +} + +resource "aws_apigatewayv2_route" "health_route_post" { + api_id = aws_apigatewayv2_api.health_api.id + route_key = "POST /health" + target = "integrations/${aws_apigatewayv2_integration.lambda_integration.id}" +} + +resource "aws_apigatewayv2_stage" "default" { + api_id = aws_apigatewayv2_api.health_api.id + name = "$default" + auto_deploy = true + + access_log_settings { + destination_arn = aws_cloudwatch_log_group.api_gateway_logs.arn + format = jsonencode({ + requestId = "$context.requestId" + ip = "$context.identity.sourceIp" + requestTime = "$context.requestTime" + httpMethod = "$context.httpMethod" + resourcePath = "$context.resourcePath" + status = "$context.status" + protocol = "$context.protocol" + responseLength = "$context.responseLength" + }) + } + + tags = var.common_tags +} + +resource "aws_cloudwatch_log_group" "api_gateway_logs" { + name = "/aws/apigateway/${var.environment}-serverless-health-check-api" + retention_in_days = 7 + + tags = var.common_tags +} diff --git a/terraform/modules/api-gateway/outputs.tf b/terraform/modules/api-gateway/outputs.tf new file mode 100644 index 0000000..fc6da1c --- /dev/null +++ b/terraform/modules/api-gateway/outputs.tf @@ -0,0 +1,14 @@ +output "api_endpoint" { + description = "The endpoint URL of the API Gateway" + value = "${aws_apigatewayv2_api.health_api.api_endpoint}/" +} + +output "api_id" { + description = "The ID of the API Gateway" + value = aws_apigatewayv2_api.health_api.id +} + +output "execution_arn" { + description = "The execution ARN of the API Gateway" + value = aws_apigatewayv2_api.health_api.execution_arn +} diff --git a/terraform/modules/api-gateway/variables.tf b/terraform/modules/api-gateway/variables.tf new file mode 100644 index 0000000..af84d72 --- /dev/null +++ b/terraform/modules/api-gateway/variables.tf @@ -0,0 +1,14 @@ +variable "environment" { + description = "Environment name" + type = string +} + +variable "lambda_invoke_arn" { + description = "Invoke ARN of the Lambda function" + type = string +} + +variable "common_tags" { + description = "Common tags for all resources" + type = map(string) +} diff --git a/terraform/modules/dynamodb/main.tf b/terraform/modules/dynamodb/main.tf new file mode 100644 index 0000000..b606585 --- /dev/null +++ b/terraform/modules/dynamodb/main.tf @@ -0,0 +1,17 @@ +resource "aws_dynamodb_table" "requests" { + name = "${var.environment}-requests-db" + billing_mode = "PAY_PER_REQUEST" + hash_key = "request_id" + + attribute { + name = "request_id" + type = "S" + } + + ttl { + attribute_name = "expiration_time" + enabled = true + } + + tags = var.common_tags +} diff --git a/terraform/modules/dynamodb/outputs.tf b/terraform/modules/dynamodb/outputs.tf new file mode 100644 index 0000000..0d5bc73 --- /dev/null +++ b/terraform/modules/dynamodb/outputs.tf @@ -0,0 +1,9 @@ +output "table_name" { + description = "Name of the DynamoDB" + value = aws_dynamodb_table.requests.name +} + +output "table_arn" { + description = "ARN of the DynamoDB" + value = aws_dynamodb_table.requests.arn +} \ No newline at end of file diff --git a/terraform/modules/dynamodb/variables.tf b/terraform/modules/dynamodb/variables.tf new file mode 100644 index 0000000..705b670 --- /dev/null +++ b/terraform/modules/dynamodb/variables.tf @@ -0,0 +1,9 @@ +variable "environment" { + description = "Environment name" + type = string +} + +variable "common_tags" { + description = "Common tags for all resources" + type = map(string) +} diff --git a/terraform/modules/iam/main.tf b/terraform/modules/iam/main.tf new file mode 100644 index 0000000..f837f7a --- /dev/null +++ b/terraform/modules/iam/main.tf @@ -0,0 +1,58 @@ +# IAM Role for Lambda function +resource "aws_iam_role" "lambda_role" { + name = "${var.environment}-serverless-health-check-api-lambda-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + } + ] + }) + + tags = var.common_tags +} + +# IAM Policy for CloudWatch Logs +resource "aws_iam_role_policy" "lambda_cloudwatch_policy" { + name = "${var.environment}-serverless-health-check-api-lambda-cloudwatch-policy" + role = aws_iam_role.lambda_role.id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "arn:aws:logs:*:*:*" + } + ] + }) +} + +# IAM Policy for DynamoDB +resource "aws_iam_role_policy" "lambda_dynamodb_policy" { + name = "${var.environment}-serverless-health-check-api-lambda-dynamodb-policy" + role = aws_iam_role.lambda_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "dynamodb:PutItem" + ] + Resource = var.dynamodb_table_arn + } + ] + }) +} diff --git a/terraform/modules/iam/outputs.tf b/terraform/modules/iam/outputs.tf new file mode 100644 index 0000000..b63be17 --- /dev/null +++ b/terraform/modules/iam/outputs.tf @@ -0,0 +1,9 @@ +output "lambda_role_arn" { + description = "ARN of the Lambda execution role" + value = aws_iam_role.lambda_role.arn +} + +output "lambda_role_name" { + description = "Name of the Lambda execution role" + value = aws_iam_role.lambda_role.name +} diff --git a/terraform/modules/iam/variables.tf b/terraform/modules/iam/variables.tf new file mode 100644 index 0000000..a688f79 --- /dev/null +++ b/terraform/modules/iam/variables.tf @@ -0,0 +1,14 @@ +variable "environment" { + description = "Environment name" + type = string +} + +variable "dynamodb_table_arn" { + description = "ARN of the DynamoDB" + type = string +} + +variable "common_tags" { + description = "Common tags for all resources" + type = map(string) +} diff --git a/terraform/modules/lambda/main.tf b/terraform/modules/lambda/main.tf new file mode 100644 index 0000000..054e2aa --- /dev/null +++ b/terraform/modules/lambda/main.tf @@ -0,0 +1,33 @@ +data "archive_file" "lambda_zip" { + type = "zip" + source_dir = var.lambda_funtion_dir + output_path = "${path.module}/${var.environment}_lambda_function.zip" +} + +resource "aws_lambda_function" "health_check" { + function_name = "${var.environment}-serverless-health-check-api" + role = var.lambda_role_arn + handler = "lambda_function.lambda_handler" + source_code_hash = data.archive_file.lambda_zip.output_base64sha256 + runtime = "python3.11" + filename = data.archive_file.lambda_zip.output_path + + environment { + variables = { + DYNAMODB_TABLE_NAME = var.dynamodb_table_name + ENVIRONMENT = var.environment + } + } + + tags = var.common_tags + + depends_on = [data.archive_file.lambda_zip] +} + +resource "aws_lambda_permission" "api_gateway" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.health_check.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${var.api_gateway_execution_arn}/*/*" +} diff --git a/terraform/modules/lambda/outputs.tf b/terraform/modules/lambda/outputs.tf new file mode 100644 index 0000000..c8b0bb7 --- /dev/null +++ b/terraform/modules/lambda/outputs.tf @@ -0,0 +1,14 @@ +output "function_arn" { + description = "ARN of the Lambda function" + value = aws_lambda_function.health_check.arn +} + +output "function_name" { + description = "Name of the Lambda function" + value = aws_lambda_function.health_check.function_name +} + +output "function_invoke_arn" { + description = "Invoke ARN of the Lambda function" + value = aws_lambda_function.health_check.invoke_arn +} diff --git a/terraform/modules/lambda/variables.tf b/terraform/modules/lambda/variables.tf new file mode 100644 index 0000000..b595507 --- /dev/null +++ b/terraform/modules/lambda/variables.tf @@ -0,0 +1,29 @@ +variable "environment" { + description = "Environment name" + type = string +} + +variable "lambda_role_arn" { + description = "ARN of the IAM role for Lambda" + type = string +} + +variable "dynamodb_table_name" { + description = "Name of the DynamoDB table" + type = string +} + +variable "api_gateway_execution_arn" { + description = "Execution ARN of the API Gateway" + type = string +} + +variable "lambda_funtion_dir" { + description = "Path to the Lambda function source code directory" + type = string +} + +variable "common_tags" { + description = "Common tags for all resources" + type = map(string) +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..8b3a508 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,19 @@ +output "api_endpoint" { + description = "The endpoint URL of the API Gateway" + value = module.api_gateway.api_endpoint +} + +output "dynamodb_table_name" { + description = "The name of the DynamoDB table" + value = module.dynamodb.table_name +} + +output "lambda_function_name" { + description = "The name of the Lambda function" + value = module.lambda.function_name +} + +output "environment" { + description = "The environment name" + value = var.environment +} diff --git a/terraform/prod.tfvars b/terraform/prod.tfvars new file mode 100644 index 0000000..26f4f3d --- /dev/null +++ b/terraform/prod.tfvars @@ -0,0 +1,3 @@ +environment = "prod" +aws_region = "us-east-1" +project_name = "serverless-health-check-api" \ No newline at end of file diff --git a/terraform/staging.tfvars b/terraform/staging.tfvars new file mode 100644 index 0000000..9b0a55c --- /dev/null +++ b/terraform/staging.tfvars @@ -0,0 +1,3 @@ +environment = "staging" +aws_region = "us-east-1" +project_name = "serverless-health-check-api" \ No newline at end of file diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..0754d5d --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,31 @@ +variable "aws_region" { + default = "us-east-1" + description = "AWS region" + type = string +} + +variable "terraform_backend_bucket" { + default = "serverlesshealthcheckapi" + description = "AWS S3 Bucket for Terraform backend" + type = string +} + +variable "environment" { + validation { + condition = contains(["staging", "prod"], var.environment) + error_message = "Environment must be either staging or prod" + } + description = "Deployment environment name (staging or prod)" + type = string +} + +variable "lambda_funtion_dir" { + default = "../lambda" + description = "Directory containing Lambda function source code" + type = string +} +variable "project_name" { + default = "serverless-health-check-api" + description = "Project name for tagging and resource naming" + type = string +} \ No newline at end of file