diff --git a/infra/app/ecs/main.tf b/infra/app/ecs/main.tf new file mode 100644 index 0000000..d8e433c --- /dev/null +++ b/infra/app/ecs/main.tf @@ -0,0 +1,201 @@ +# ALB +resource "aws_security_group" "alb" { + name = "${var.app_name}-alb-sg" + vpc_id = var.vpc_id + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + ingress { + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } +} +resource "aws_lb" "this" { + name = "${var.app_name}-alb" + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = var.public_subnet_ids +} +resource "aws_lb_target_group" "this" { + name = "${var.app_name}-lb-tg" + vpc_id = var.vpc_id + port = 80 + protocol = "HTTP" + target_type = "ip" + health_check { + port = 80 + path = "/docs" + interval = 30 + protocol = "HTTP" + timeout = 5 + unhealthy_threshold = 2 + matcher = 200 + } +} +resource "aws_lb_listener" "http" { + port = "80" + protocol = "HTTP" + load_balancer_arn = aws_lb.this.arn + default_action { + target_group_arn = aws_lb_target_group.this.arn + type = "forward" + } + depends_on = [aws_lb_target_group.this] +} +resource "aws_lb_listener_rule" "this" { + listener_arn = aws_lb_listener.http.arn + action { + type = "forward" + target_group_arn = aws_lb_target_group.this.arn + } + condition { + path_pattern { + values = ["*"] + } + } +} + +# IAM +data "aws_iam_policy_document" "ecs_assume_policy" { + statement { + actions = ["sts:AssumeRole"] + principals { + type = "Service" + identifiers = ["ecs-tasks.amazonaws.com"] + } + } +} +resource "aws_iam_role" "ecs_execution_role" { + name = "${var.app_name}-execution-role" + assume_role_policy = data.aws_iam_policy_document.ecs_assume_policy.json +} +resource "aws_iam_policy" "ecs_execution_policy" { + name = "${var.app_name}-ecs-execution-role-policy" + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect : "Allow", + Action : [ + "ecr:*", + "ecs:*", + "elasticloadbalancing:*", + "cloudwatch:*", + "logs:*" + ], + Resource : "*" + } + ] + }) +} +resource "aws_iam_role_policy_attachment" "ecs_execution_role_policy_attach" { + role = aws_iam_role.ecs_execution_role.name + policy_arn = aws_iam_policy.ecs_execution_policy.arn +} + +# ECS +resource "aws_cloudwatch_log_group" "ecs" { + name = "/aws/ecs/${var.app_name}/cluster" +} +resource "aws_ecs_task_definition" "api" { + family = "${var.app_name}-api-task" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + execution_role_arn = aws_iam_role.ecs_execution_role.arn + task_role_arn = aws_iam_role.ecs_execution_role.arn + cpu = 256 + memory = 512 + container_definitions = jsonencode([ + { + name = "${var.app_name}-api-container" + image = "${var.image}" + command = ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "80"] + portMappings = [ + { + hostPort = 80 + containerPort = 80 + protocol = "tcp" + } + ], + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.ecs.name + awslogs-stream-prefix = "ecs" + awslogs-region = var.region + } + } + environment = [ + { + name = "SUPABASE_URL", + value = var.supabase_url + }, + { + name = "SUPABASE_KEY", + value = var.supabase_key + } + ] + } + ]) +} + +# Cluster +resource "aws_ecs_cluster" "this" { + name = "${var.app_name}-cluster" + setting { + name = "containerInsights" + value = "enabled" + } +} + +# Security Group and Service +resource "aws_security_group" "ecs" { + name = "${var.app_name}-ecs-sg" + vpc_id = var.vpc_id + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + ingress { + from_port = 80 + to_port = 80 + protocol = "tcp" + security_groups = [aws_security_group.alb.id] + } +} +resource "aws_ecs_service" "api" { + name = "${var.app_name}-ecs-service" + cluster = aws_ecs_cluster.this.name + launch_type = "FARGATE" + desired_count = length(var.private_subnet_ids) + task_definition = aws_ecs_task_definition.api.arn + network_configuration { + subnets = var.private_subnet_ids + security_groups = [aws_security_group.ecs.id] + } + load_balancer { + target_group_arn = aws_lb_target_group.this.arn + container_name = "${var.app_name}-api-container" + container_port = "80" + } + lifecycle { + ignore_changes = [ + desired_count, + ] + } + depends_on = [aws_lb_listener_rule.this] +} + diff --git a/infra/app/ecs/output.tf b/infra/app/ecs/output.tf new file mode 100644 index 0000000..3365194 --- /dev/null +++ b/infra/app/ecs/output.tf @@ -0,0 +1,3 @@ +output "alb_dns_name" { + value = aws_lb.this.dns_name +} diff --git a/infra/app/ecs/variable.tf b/infra/app/ecs/variable.tf new file mode 100644 index 0000000..cb7d17a --- /dev/null +++ b/infra/app/ecs/variable.tf @@ -0,0 +1,34 @@ +variable "app_name" { + description = "Name of the app." + type = string +} +variable "region" { + description = "AWS region to deploy the network to." + type = string +} +variable "image" { + description = "Image used to start the container. Should be in repository-url/image:tag format." + type = string +} +variable "vpc_id" { + description = "ID of the VPC where the ECS will be hosted." + type = string +} +variable "public_subnet_ids" { + description = "IDs of public subnets where the ALB will be attached to." + type = list(string) +} +variable "private_subnet_ids" { + description = "IDs of private subnets where the ECS service will be deployed to." + type = list(string) +} +variable "supabase_url" { + type = string + description = "Supabase URL for the application" + sensitive = true +} +variable "supabase_key" { + type = string + description = "Supabase API key" + sensitive = true +} \ No newline at end of file diff --git a/infra/app/main.tf b/infra/app/main.tf new file mode 100644 index 0000000..a04fa78 --- /dev/null +++ b/infra/app/main.tf @@ -0,0 +1,33 @@ +provider "aws" { + region = var.region + default_tags { + tags = { + app = var.app_name + } + } +} + +module "network" { + source = "./network" + app_name = var.app_name + region = var.region +} + +module "ecs" { + source = "./ecs" + app_name = var.app_name + region = var.region + image = var.image + supabase_url = var.supabase_url + supabase_key = var.supabase_key + vpc_id = module.network.vpc.id + public_subnet_ids = [for s in module.network.public_subnets : s.id] + private_subnet_ids = [for s in module.network.private_subnets : s.id] + depends_on = [module.network] +} + + +# Outputs +output "alb_dns_name" { + value = module.ecs.alb_dns_name +} diff --git a/infra/app/network/main.tf b/infra/app/network/main.tf new file mode 100644 index 0000000..1ff014f --- /dev/null +++ b/infra/app/network/main.tf @@ -0,0 +1,75 @@ +# Define provider +provider "aws" { + region = var.region + default_tags { + tags = { + app = var.app_name + } + } +} + +# Create VPC and IGW +resource "aws_vpc" "this" { + cidr_block = var.vpc_cidr_block +} +resource "aws_internet_gateway" "this" { + vpc_id = aws_vpc.this.id +} + +# Create public subnets +resource "aws_subnet" "public_subnets" { + count = length(var.availability_zones) + vpc_id = aws_vpc.this.id + cidr_block = var.public_cidr_blocks[count.index] + availability_zone = var.availability_zones[count.index] +} + +# Create routing tables for public subnets +resource "aws_route_table" "public" { + vpc_id = aws_vpc.this.id + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.this.id + } +} +resource "aws_route_table_association" "publics" { + count = length(var.availability_zones) + subnet_id = element(aws_subnet.public_subnets.*.id, count.index) + route_table_id = aws_route_table.public.id +} + + +# Create Elastic IPs and NAT Gateways +resource "aws_eip" "eips" { + count = length(var.availability_zones) + domain = "vpc" +} +resource "aws_nat_gateway" "this" { + count = length(var.availability_zones) + subnet_id = element(aws_subnet.public_subnets.*.id, count.index) + allocation_id = element(aws_eip.eips.*.id, count.index) +} + +# Create private subnets +resource "aws_subnet" "private_subnets" { + count = length(var.availability_zones) + vpc_id = aws_vpc.this.id + cidr_block = var.private_cidr_blocks[count.index] + availability_zone = var.availability_zones[count.index] +} + +# Create routing tables for private subnets +resource "aws_route_table" "private" { + count = length(var.availability_zones) + vpc_id = aws_vpc.this.id + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = element(aws_nat_gateway.this.*.id, count.index) + } +} +resource "aws_route_table_association" "privates" { + count = length(var.availability_zones) + subnet_id = element(aws_subnet.private_subnets.*.id, count.index) + route_table_id = element(aws_route_table.private.*.id, count.index) +} + diff --git a/infra/app/network/outputs.tf b/infra/app/network/outputs.tf new file mode 100644 index 0000000..0e04949 --- /dev/null +++ b/infra/app/network/outputs.tf @@ -0,0 +1,9 @@ +output "vpc" { + value = aws_vpc.this +} +output "public_subnets" { + value = aws_subnet.public_subnets +} +output "private_subnets" { + value = aws_subnet.private_subnets +} \ No newline at end of file diff --git a/infra/app/network/variable.tf b/infra/app/network/variable.tf new file mode 100644 index 0000000..0c3f342 --- /dev/null +++ b/infra/app/network/variable.tf @@ -0,0 +1,22 @@ +variable "app_name" { + type = string +} +variable "region" { + type = string +} +variable "vpc_cidr_block" { + type = string + default = "10.0.0.0/16" +} +variable "availability_zones" { + type = list(string) + default = ["us-east-1a", "us-east-1f"] +} +variable "public_cidr_blocks" { + type = list(string) + default = ["10.0.1.0/24", "10.0.2.0/24"] +} +variable "private_cidr_blocks" { + type = list(string) + default = ["10.0.11.0/24", "10.0.12.0/24"] +} \ No newline at end of file diff --git a/infra/app/variable.tf b/infra/app/variable.tf new file mode 100644 index 0000000..4a0f067 --- /dev/null +++ b/infra/app/variable.tf @@ -0,0 +1,22 @@ +variable "app_name" { + description = "Name of the app." + type = string +} +variable "region" { + description = "AWS region to deploy the network to." + type = string +} +variable "image" { + description = "Image used to start the container. Should be in repository-url/image:tag format." + type = string +} +variable "supabase_url" { + type = string + description = "Supabase URL for the application" + sensitive = true +} +variable "supabase_key" { + type = string + description = "Supabase API key" + sensitive = true +} \ No newline at end of file diff --git a/infra/setup/main.tf b/infra/setup/main.tf new file mode 100644 index 0000000..e0c6d3a --- /dev/null +++ b/infra/setup/main.tf @@ -0,0 +1,3 @@ +resource "aws_ecr_repository" "this" { + name = "${var.app_name}" +} \ No newline at end of file diff --git a/infra/setup/output.tf b/infra/setup/output.tf new file mode 100644 index 0000000..7b23807 --- /dev/null +++ b/infra/setup/output.tf @@ -0,0 +1,3 @@ +output "ecr_repo_url" { + value = aws_ecr_repository.this.repository_url +} \ No newline at end of file diff --git a/infra/setup/variable.tf b/infra/setup/variable.tf new file mode 100644 index 0000000..62afa2b --- /dev/null +++ b/infra/setup/variable.tf @@ -0,0 +1,4 @@ +variable "app_name" { + description = "Name of the app." + type = string +}