From ca2d541d92e20b85c95048f779ad79fb42f47c77 Mon Sep 17 00:00:00 2001 From: AdaomaB Date: Fri, 24 Apr 2026 09:31:14 +0100 Subject: [PATCH 1/2] feat: add Terraform AWS infrastructure for voting app - VPC with public/private subnets, IGW, NAT gateway - Security groups for ALB, ECS tasks, RDS, ElastiCache - Application Load Balancer with listeners for vote (80) and result (8080) - ECS Fargate cluster with task definitions and services for vote, result, worker - RDS Postgres replacing local db container - ElastiCache Redis replacing local redis container - IAM roles for ECS task execution and task permissions - CloudWatch log groups for container logging - Updated app source to read connection hostnames from env vars --- result/server.js | 2 +- terraform/.terraform.lock.hcl | 25 +++++ terraform/alb.tf | 76 ++++++++++++++ terraform/ecs.tf | 188 ++++++++++++++++++++++++++++++++++ terraform/elasticache.tf | 24 +++++ terraform/iam.tf | 60 +++++++++++ terraform/main.tf | 14 +++ terraform/outputs.tf | 29 ++++++ terraform/rds.tf | 28 +++++ terraform/security_groups.tf | 111 ++++++++++++++++++++ terraform/variables.tf | 87 ++++++++++++++++ terraform/vpc.tf | 89 ++++++++++++++++ vote/app.py | 3 +- worker/Program.cs | 16 ++- 14 files changed, 747 insertions(+), 5 deletions(-) create mode 100644 terraform/.terraform.lock.hcl create mode 100644 terraform/alb.tf create mode 100644 terraform/ecs.tf create mode 100644 terraform/elasticache.tf create mode 100644 terraform/iam.tf create mode 100644 terraform/main.tf create mode 100644 terraform/outputs.tf create mode 100644 terraform/rds.tf create mode 100644 terraform/security_groups.tf create mode 100644 terraform/variables.tf create mode 100644 terraform/vpc.tf diff --git a/result/server.js b/result/server.js index 1c8593e7ee..4ef2af10e8 100644 --- a/result/server.js +++ b/result/server.js @@ -18,7 +18,7 @@ io.on('connection', function (socket) { }); var pool = new Pool({ - connectionString: 'postgres://postgres:postgres@db/postgres' + connectionString: process.env.DATABASE_URL || 'postgres://postgres:postgres@db/postgres' }); async.retry( diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000000..92a2bcccd8 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/terraform/alb.tf b/terraform/alb.tf new file mode 100644 index 0000000000..038c7ca32c --- /dev/null +++ b/terraform/alb.tf @@ -0,0 +1,76 @@ +# ------------------------------------------------------- +# Application Load Balancer +# ------------------------------------------------------- +resource "aws_lb" "main" { + name = "${var.project_name}-alb" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = aws_subnet.public[*].id + + tags = { Name = "${var.project_name}-alb" } +} + +# ------------------------------------------------------- +# Target Groups +# ------------------------------------------------------- +resource "aws_lb_target_group" "vote" { + name = "${var.project_name}-vote-tg" + port = 80 + protocol = "HTTP" + vpc_id = aws_vpc.main.id + target_type = "ip" + + health_check { + path = "/" + healthy_threshold = 2 + unhealthy_threshold = 3 + interval = 30 + } + + tags = { Name = "${var.project_name}-vote-tg" } +} + +resource "aws_lb_target_group" "result" { + name = "${var.project_name}-result-tg" + port = 80 + protocol = "HTTP" + vpc_id = aws_vpc.main.id + target_type = "ip" + + health_check { + path = "/" + healthy_threshold = 2 + unhealthy_threshold = 3 + interval = 30 + } + + tags = { Name = "${var.project_name}-result-tg" } +} + +# ------------------------------------------------------- +# Listeners +# vote app → port 80 (default) +# result app → port 8080 (separate listener) +# ------------------------------------------------------- +resource "aws_lb_listener" "vote" { + load_balancer_arn = aws_lb.main.arn + port = 80 + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.vote.arn + } +} + +resource "aws_lb_listener" "result" { + load_balancer_arn = aws_lb.main.arn + port = 8080 + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.result.arn + } +} diff --git a/terraform/ecs.tf b/terraform/ecs.tf new file mode 100644 index 0000000000..fbc1fe6ea5 --- /dev/null +++ b/terraform/ecs.tf @@ -0,0 +1,188 @@ +# ------------------------------------------------------- +# ECS Cluster +# ------------------------------------------------------- +resource "aws_ecs_cluster" "main" { + name = "${var.project_name}-cluster" + tags = { Name = "${var.project_name}-cluster" } +} + +# ------------------------------------------------------- +# CloudWatch Log Group +# ------------------------------------------------------- +resource "aws_cloudwatch_log_group" "voting_app" { + name = "/ecs/${var.project_name}" + retention_in_days = 7 +} + +# ------------------------------------------------------- +# Task Definitions +# ------------------------------------------------------- + +# --- Vote --- +resource "aws_ecs_task_definition" "vote" { + family = "${var.project_name}-vote" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = "256" + memory = "512" + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([{ + name = "vote" + image = var.vote_image + essential = true + + portMappings = [{ + containerPort = 80 + protocol = "tcp" + }] + + environment = [ + { name = "REDIS_HOST", value = aws_elasticache_cluster.redis.cache_nodes[0].address } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.voting_app.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "vote" + } + } + }]) +} + +# --- Result --- +resource "aws_ecs_task_definition" "result" { + family = "${var.project_name}-result" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = "256" + memory = "512" + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([{ + name = "result" + image = var.result_image + essential = true + + portMappings = [{ + containerPort = 80 + protocol = "tcp" + }] + + environment = [ + { name = "DATABASE_URL", value = "postgres://${var.db_username}:${var.db_password}@${aws_db_instance.postgres.address}/${var.db_name}" } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.voting_app.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "result" + } + } + }]) +} + +# --- Worker --- +resource "aws_ecs_task_definition" "worker" { + family = "${var.project_name}-worker" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = "256" + memory = "512" + execution_role_arn = aws_iam_role.ecs_task_execution.arn + task_role_arn = aws_iam_role.ecs_task.arn + + container_definitions = jsonencode([{ + name = "worker" + image = var.worker_image + essential = true + + environment = [ + { name = "REDIS_HOST", value = aws_elasticache_cluster.redis.cache_nodes[0].address }, + { name = "DATABASE_HOST", value = aws_db_instance.postgres.address }, + { name = "DATABASE_USER", value = var.db_username }, + { name = "DATABASE_PASSWORD", value = var.db_password }, + { name = "DATABASE_NAME", value = var.db_name } + ] + + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-group" = aws_cloudwatch_log_group.voting_app.name + "awslogs-region" = var.aws_region + "awslogs-stream-prefix" = "worker" + } + } + }]) +} + +# ------------------------------------------------------- +# ECS Services +# ------------------------------------------------------- + +# --- Vote Service --- +resource "aws_ecs_service" "vote" { + name = "${var.project_name}-vote" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.vote.arn + desired_count = 2 + launch_type = "FARGATE" + + network_configuration { + subnets = aws_subnet.private[*].id + security_groups = [aws_security_group.ecs_tasks.id] + assign_public_ip = false + } + + load_balancer { + target_group_arn = aws_lb_target_group.vote.arn + container_name = "vote" + container_port = 80 + } + + depends_on = [aws_lb_listener.vote] +} + +# --- Result Service --- +resource "aws_ecs_service" "result" { + name = "${var.project_name}-result" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.result.arn + desired_count = 2 + launch_type = "FARGATE" + + network_configuration { + subnets = aws_subnet.private[*].id + security_groups = [aws_security_group.ecs_tasks.id] + assign_public_ip = false + } + + load_balancer { + target_group_arn = aws_lb_target_group.result.arn + container_name = "result" + container_port = 80 + } + + depends_on = [aws_lb_listener.result] +} + +# --- Worker Service (no ALB needed, background processor) --- +resource "aws_ecs_service" "worker" { + name = "${var.project_name}-worker" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.worker.arn + desired_count = 1 + launch_type = "FARGATE" + + network_configuration { + subnets = aws_subnet.private[*].id + security_groups = [aws_security_group.ecs_tasks.id] + assign_public_ip = false + } +} diff --git a/terraform/elasticache.tf b/terraform/elasticache.tf new file mode 100644 index 0000000000..03b3ade8e0 --- /dev/null +++ b/terraform/elasticache.tf @@ -0,0 +1,24 @@ +# ------------------------------------------------------- +# ElastiCache — replaces local Redis container +# ------------------------------------------------------- + +resource "aws_elasticache_subnet_group" "redis" { + name = "${var.project_name}-redis-subnet-group" + subnet_ids = aws_subnet.private[*].id + + tags = { Name = "${var.project_name}-redis-subnet-group" } +} + +resource "aws_elasticache_cluster" "redis" { + cluster_id = "${var.project_name}-redis" + engine = "redis" + node_type = var.redis_node_type + num_cache_nodes = 1 + parameter_group_name = "default.redis7" + engine_version = "7.0" + port = 6379 + subnet_group_name = aws_elasticache_subnet_group.redis.name + security_group_ids = [aws_security_group.redis.id] + + tags = { Name = "${var.project_name}-redis" } +} diff --git a/terraform/iam.tf b/terraform/iam.tf new file mode 100644 index 0000000000..382c95f1a1 --- /dev/null +++ b/terraform/iam.tf @@ -0,0 +1,60 @@ +# ------------------------------------------------------- +# ECS Task Execution Role +# Allows ECS to pull images and write logs to CloudWatch +# ------------------------------------------------------- +resource "aws_iam_role" "ecs_task_execution" { + name = "${var.project_name}-ecs-execution-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + + tags = { Name = "${var.project_name}-ecs-execution-role" } +} + +resource "aws_iam_role_policy_attachment" "ecs_execution_policy" { + role = aws_iam_role.ecs_task_execution.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +# ------------------------------------------------------- +# ECS Task Role +# Permissions the running container itself has +# ------------------------------------------------------- +resource "aws_iam_role" "ecs_task" { + name = "${var.project_name}-ecs-task-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "ecs-tasks.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) + + tags = { Name = "${var.project_name}-ecs-task-role" } +} + +# Allow tasks to write logs +resource "aws_iam_role_policy" "ecs_task_logs" { + name = "${var.project_name}-ecs-task-logs" + role = aws_iam_role.ecs_task.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "*" + }] + }) +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000000..b467ea11a0 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.3.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = var.aws_region +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000000..6d10e7c523 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,29 @@ +output "alb_dns_name" { + description = "DNS name of the load balancer" + value = aws_lb.main.dns_name +} + +output "vote_url" { + description = "URL to access the vote app" + value = "http://${aws_lb.main.dns_name}" +} + +output "result_url" { + description = "URL to access the result app" + value = "http://${aws_lb.main.dns_name}:8080" +} + +output "rds_endpoint" { + description = "RDS Postgres endpoint" + value = aws_db_instance.postgres.address +} + +output "redis_endpoint" { + description = "ElastiCache Redis endpoint" + value = aws_elasticache_cluster.redis.cache_nodes[0].address +} + +output "ecs_cluster_name" { + description = "ECS cluster name" + value = aws_ecs_cluster.main.name +} diff --git a/terraform/rds.tf b/terraform/rds.tf new file mode 100644 index 0000000000..6fdc2ba218 --- /dev/null +++ b/terraform/rds.tf @@ -0,0 +1,28 @@ +# ------------------------------------------------------- +# RDS — replaces local Postgres container +# ------------------------------------------------------- + +resource "aws_db_subnet_group" "postgres" { + name = "${var.project_name}-db-subnet-group" + subnet_ids = aws_subnet.private[*].id + + tags = { Name = "${var.project_name}-db-subnet-group" } +} + +resource "aws_db_instance" "postgres" { + identifier = "${var.project_name}-postgres" + engine = "postgres" + engine_version = "15" + instance_class = var.db_instance_class + allocated_storage = 20 + db_name = var.db_name + username = var.db_username + password = var.db_password + db_subnet_group_name = aws_db_subnet_group.postgres.name + vpc_security_group_ids = [aws_security_group.rds.id] + skip_final_snapshot = true + publicly_accessible = false + multi_az = false + + tags = { Name = "${var.project_name}-postgres" } +} diff --git a/terraform/security_groups.tf b/terraform/security_groups.tf new file mode 100644 index 0000000000..2c4322559c --- /dev/null +++ b/terraform/security_groups.tf @@ -0,0 +1,111 @@ +# ------------------------------------------------------- +# ALB Security Group — accepts HTTP from the internet +# ------------------------------------------------------- +resource "aws_security_group" "alb" { + name = "${var.project_name}-alb-sg" + description = "Allow HTTP inbound to ALB" + vpc_id = aws_vpc.main.id + + ingress { + description = "HTTP from internet" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "Result app port from internet" + from_port = 8080 + to_port = 8080 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project_name}-alb-sg" } +} + +# ------------------------------------------------------- +# ECS Tasks Security Group — accepts traffic from ALB only +# ------------------------------------------------------- +resource "aws_security_group" "ecs_tasks" { + name = "${var.project_name}-ecs-tasks-sg" + description = "Allow inbound from ALB to ECS tasks" + vpc_id = aws_vpc.main.id + + ingress { + description = "From ALB on port 80" + from_port = 80 + to_port = 80 + protocol = "tcp" + security_groups = [aws_security_group.alb.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project_name}-ecs-tasks-sg" } +} + +# ------------------------------------------------------- +# RDS Security Group — accepts Postgres from ECS tasks only +# ------------------------------------------------------- +resource "aws_security_group" "rds" { + name = "${var.project_name}-rds-sg" + description = "Allow Postgres from ECS tasks" + vpc_id = aws_vpc.main.id + + ingress { + description = "Postgres from ECS" + from_port = 5432 + to_port = 5432 + protocol = "tcp" + security_groups = [aws_security_group.ecs_tasks.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project_name}-rds-sg" } +} + +# ------------------------------------------------------- +# ElastiCache Security Group — accepts Redis from ECS tasks only +# ------------------------------------------------------- +resource "aws_security_group" "redis" { + name = "${var.project_name}-redis-sg" + description = "Allow Redis from ECS tasks" + vpc_id = aws_vpc.main.id + + ingress { + description = "Redis from ECS" + from_port = 6379 + to_port = 6379 + protocol = "tcp" + security_groups = [aws_security_group.ecs_tasks.id] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { Name = "${var.project_name}-redis-sg" } +} diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000000..692db2b430 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,87 @@ +variable "aws_region" { + description = "AWS region to deploy into" + type = string + default = "us-east-1" +} + +variable "project_name" { + description = "Project name used for naming resources" + type = string + default = "voting-app" +} + +variable "vpc_cidr" { + description = "CIDR block for the VPC" + type = string + default = "10.0.0.0/16" +} + +variable "public_subnet_cidrs" { + description = "CIDR blocks for public subnets" + type = list(string) + default = ["10.0.1.0/24", "10.0.2.0/24"] +} + +variable "private_subnet_cidrs" { + description = "CIDR blocks for private subnets" + type = list(string) + default = ["10.0.3.0/24", "10.0.4.0/24"] +} + +variable "availability_zones" { + description = "Availability zones to use" + type = list(string) + default = ["us-east-1a", "us-east-1b"] +} + +# --- Container image URIs (push to ECR and update these) --- +variable "vote_image" { + description = "Docker image URI for the vote service" + type = string + default = "dockersamples/examplevotingapp_vote:latest" +} + +variable "result_image" { + description = "Docker image URI for the result service" + type = string + default = "dockersamples/examplevotingapp_result:latest" +} + +variable "worker_image" { + description = "Docker image URI for the worker service" + type = string + default = "dockersamples/examplevotingapp_worker:latest" +} + +# --- Database --- +variable "db_name" { + description = "Postgres database name" + type = string + default = "postgres" +} + +variable "db_username" { + description = "Postgres master username" + type = string + default = "postgres" +} + +variable "db_password" { + description = "Postgres master password" + type = string + sensitive = true + default = "changeme123!" +} + +variable "db_instance_class" { + description = "RDS instance class" + type = string + default = "db.t3.micro" +} + +# --- ElastiCache --- +variable "redis_node_type" { + description = "ElastiCache node type" + type = string + default = "cache.t3.micro" +} diff --git a/terraform/vpc.tf b/terraform/vpc.tf new file mode 100644 index 0000000000..d570a2cf4e --- /dev/null +++ b/terraform/vpc.tf @@ -0,0 +1,89 @@ +# ------------------------------------------------------- +# VPC +# ------------------------------------------------------- +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { Name = "${var.project_name}-vpc" } +} + +# ------------------------------------------------------- +# Subnets +# ------------------------------------------------------- +resource "aws_subnet" "public" { + count = length(var.public_subnet_cidrs) + vpc_id = aws_vpc.main.id + cidr_block = var.public_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + map_public_ip_on_launch = true + + tags = { Name = "${var.project_name}-public-${count.index + 1}" } +} + +resource "aws_subnet" "private" { + count = length(var.private_subnet_cidrs) + vpc_id = aws_vpc.main.id + cidr_block = var.private_subnet_cidrs[count.index] + availability_zone = var.availability_zones[count.index] + + tags = { Name = "${var.project_name}-private-${count.index + 1}" } +} + +# ------------------------------------------------------- +# Internet Gateway + NAT Gateway +# ------------------------------------------------------- +resource "aws_internet_gateway" "igw" { + vpc_id = aws_vpc.main.id + tags = { Name = "${var.project_name}-igw" } +} + +resource "aws_eip" "nat" { + domain = "vpc" + tags = { Name = "${var.project_name}-nat-eip" } +} + +resource "aws_nat_gateway" "nat" { + allocation_id = aws_eip.nat.id + subnet_id = aws_subnet.public[0].id + tags = { Name = "${var.project_name}-nat" } + depends_on = [aws_internet_gateway.igw] +} + +# ------------------------------------------------------- +# Route Tables +# ------------------------------------------------------- +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.igw.id + } + + tags = { Name = "${var.project_name}-public-rt" } +} + +resource "aws_route_table_association" "public" { + count = length(aws_subnet.public) + subnet_id = aws_subnet.public[count.index].id + route_table_id = aws_route_table.public.id +} + +resource "aws_route_table" "private" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.nat.id + } + + tags = { Name = "${var.project_name}-private-rt" } +} + +resource "aws_route_table_association" "private" { + count = length(aws_subnet.private) + subnet_id = aws_subnet.private[count.index].id + route_table_id = aws_route_table.private.id +} diff --git a/vote/app.py b/vote/app.py index 596546612a..e44868fc5c 100644 --- a/vote/app.py +++ b/vote/app.py @@ -18,7 +18,8 @@ def get_redis(): if not hasattr(g, 'redis'): - g.redis = Redis(host="redis", db=0, socket_timeout=5) + redis_host = os.getenv('REDIS_HOST', 'redis') + g.redis = Redis(host=redis_host, db=0, socket_timeout=5) return g.redis @app.route("/", methods=['POST','GET']) diff --git a/worker/Program.cs b/worker/Program.cs index 9b5fb74d1a..93c94a96d2 100644 --- a/worker/Program.cs +++ b/worker/Program.cs @@ -16,8 +16,14 @@ public static int Main(string[] args) { try { - var pgsql = OpenDbConnection("Server=db;Username=postgres;Password=postgres;"); - var redisConn = OpenRedisConnection("redis"); + var pgsql = OpenDbConnection( + $"Server={Environment.GetEnvironmentVariable("DATABASE_HOST") ?? "db"};" + + $"Username={Environment.GetEnvironmentVariable("DATABASE_USER") ?? "postgres"};" + + $"Password={Environment.GetEnvironmentVariable("DATABASE_PASSWORD") ?? "postgres"};" + ); + var redisConn = OpenRedisConnection( + Environment.GetEnvironmentVariable("REDIS_HOST") ?? "redis" + ); var redis = redisConn.GetDatabase(); // Keep alive is not implemented in Npgsql yet. This workaround was recommended: @@ -46,7 +52,11 @@ public static int Main(string[] args) if (!pgsql.State.Equals(System.Data.ConnectionState.Open)) { Console.WriteLine("Reconnecting DB"); - pgsql = OpenDbConnection("Server=db;Username=postgres;Password=postgres;"); + pgsql = OpenDbConnection( + $"Server={Environment.GetEnvironmentVariable("DATABASE_HOST") ?? "db"};" + + $"Username={Environment.GetEnvironmentVariable("DATABASE_USER") ?? "postgres"};" + + $"Password={Environment.GetEnvironmentVariable("DATABASE_PASSWORD") ?? "postgres"};" + ); } else { // Normal +1 vote requested From 3b197bee21ea8d65329e1b904b6d16b91df9af9f Mon Sep 17 00:00:00 2001 From: AdaomaB Date: Sat, 25 Apr 2026 12:05:32 +0100 Subject: [PATCH 2/2] feat: add CloudWatch alarms for infrastructure monitoring - CPU utilization alarms for vote and result ECS services - Database connection count alarm for RDS - CPU utilization alarm for ElastiCache Redis - All alarms configured with 2-minute evaluation period and appropriate thresholds --- terraform/cloudwatch.tf | 81 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 terraform/cloudwatch.tf diff --git a/terraform/cloudwatch.tf b/terraform/cloudwatch.tf new file mode 100644 index 0000000000..e41547705e --- /dev/null +++ b/terraform/cloudwatch.tf @@ -0,0 +1,81 @@ +# ------------------------------------------------------- +# CloudWatch Alarms for monitoring +# ------------------------------------------------------- + +# Alert when vote service CPU goes above 80% +resource "aws_cloudwatch_metric_alarm" "vote_cpu_high" { + alarm_name = "${var.project_name}-vote-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + period = 60 + statistic = "Average" + threshold = 80 + alarm_description = "Vote service CPU utilization is too high" + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.vote.name + } + + tags = { Name = "${var.project_name}-vote-cpu-alarm" } +} + +# Alert when result service CPU goes above 80% +resource "aws_cloudwatch_metric_alarm" "result_cpu_high" { + alarm_name = "${var.project_name}-result-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + period = 60 + statistic = "Average" + threshold = 80 + alarm_description = "Result service CPU utilization is too high" + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.result.name + } + + tags = { Name = "${var.project_name}-result-cpu-alarm" } +} + +# Alert when RDS has too many connections +resource "aws_cloudwatch_metric_alarm" "rds_connections_high" { + alarm_name = "${var.project_name}-rds-connections-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "DatabaseConnections" + namespace = "AWS/RDS" + period = 60 + statistic = "Average" + threshold = 50 + alarm_description = "RDS connection count is too high" + + dimensions = { + DBInstanceIdentifier = aws_db_instance.postgres.identifier + } + + tags = { Name = "${var.project_name}-rds-connections-alarm" } +} + +# Alert when ElastiCache CPU goes above 75% +resource "aws_cloudwatch_metric_alarm" "redis_cpu_high" { + alarm_name = "${var.project_name}-redis-cpu-high" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + metric_name = "CPUUtilization" + namespace = "AWS/ElastiCache" + period = 60 + statistic = "Average" + threshold = 75 + alarm_description = "Redis CPU utilization is too high" + + dimensions = { + CacheClusterId = aws_elasticache_cluster.redis.cluster_id + } + + tags = { Name = "${var.project_name}-redis-cpu-alarm" } +}