diff --git a/.gitignore b/.gitignore index 4bc03e175d..515393c341 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,23 @@ docs/instructions/Roadmap.md .cursorrules *.md .qodo + + +# Local .terraform directories +**/.terraform/* + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data +*.tfvars +*.tfvars.json + +# Ignore CLI configuration files +.terraformrc +terraform.rc \ No newline at end of file diff --git a/.terraform.lock.hcl b/.terraform.lock.hcl new file mode 100644 index 0000000000..d92454473e --- /dev/null +++ b/.terraform.lock.hcl @@ -0,0 +1,44 @@ +# 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:hd45qFU5cFuJMpFGdUniU9mVIr5LYVWP1uMeunBpYYs=", + "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", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.7.2" + hashes = [ + "h1:hkKSY5xI4R1H4Yrg10HHbtOoxZif2dXa9HFPSbaVg5o=", + "zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f", + "zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc", + "zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab", + "zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3", + "zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212", + "zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34", + "zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967", + "zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d", + "zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62", + "zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0", + ] +} diff --git a/Dockerfile b/Dockerfile index 1cd3f0bfca..841ba4eea9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,10 @@ EXPOSE 5173 # Production image FROM base AS bolt-ai-production + +# Build prefix argument - defaults to empty string, can be set to 'code:' for CodeBuild +ARG BUILD_PREFIX="" + # Define environment variables with default values or let them be overridden ARG GROQ_API_KEY ARG HuggingFace_API_KEY @@ -55,7 +59,7 @@ ENV WRANGLER_SEND_METRICS=false \ RUN mkdir -p /root/.config/.wrangler && \ echo '{"enabled":false}' > /root/.config/.wrangler/metrics.json -RUN pnpm run build +RUN pnpm run ${BUILD_PREFIX}build CMD [ "pnpm", "run", "dockerstart"] @@ -64,7 +68,7 @@ FROM base AS bolt-ai-development # Define the same environment variables for development ARG GROQ_API_KEY -ARG HuggingFace +ARG HuggingFace ARG OPENAI_API_KEY ARG ANTHROPIC_API_KEY ARG OPEN_ROUTER_API_KEY @@ -92,4 +96,4 @@ ENV GROQ_API_KEY=${GROQ_API_KEY} \ RUNNING_IN_DOCKER=true RUN mkdir -p ${WORKDIR}/run -CMD pnpm run dev --host +CMD pnpm run dev --host \ No newline at end of file diff --git a/buildspec.yml b/buildspec.yml new file mode 100644 index 0000000000..4cf90f1266 --- /dev/null +++ b/buildspec.yml @@ -0,0 +1,32 @@ +version: 0.2 + +env: + variables: + NODE_OPTIONS: "--max_old_space_size=4096" + NPM_CONFIG_PROGRESS: "false" + NPM_CONFIG_AUDIT: "false" + NPM_CONFIG_FUND: "false" + +phases: + install: + runtime-versions: + nodejs: latest + pre_build: + commands: + - echo Logging in to Docker Hub... + - echo "$DOCKER_HUB_ACCESS_TOKEN" | docker login --username "$DOCKER_HUB_USERNAME" --password-stdin + - echo Logging in to Amazon ECR... + - export AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) + - aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com + build: + commands: + - echo Building the Docker image... + - export DOCKER_BUILDKIT=1 + - docker build --build-arg BUILD_PREFIX="code:" --target bolt-ai-$IMAGE_TAG -t $IMAGE_REPO_NAME:$IMAGE_TAG . + - docker tag $IMAGE_REPO_NAME:$IMAGE_TAG $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG + post_build: + commands: + - echo Pushing the Docker image... + - docker push $AWS_ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/$IMAGE_REPO_NAME:$IMAGE_TAG + - echo Update ECS service + - aws ecs update-service --cluster $ECS_CLUSTER_NAME --service $ECS_SERVICE_NAME --force-new-deployment \ No newline at end of file diff --git a/package.json b/package.json index cbf558f6fd..5a5388aa3a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "scripts": { "deploy": "npm run build && wrangler pages deploy", "build": "remix vite:build", + "code:build": "NODE_OPTIONS=--max-old-space-size=4096 remix vite:build", "dev": "node pre-start.cjs && remix vite:dev", "test": "vitest --run", "test:watch": "vitest", diff --git a/terraform.tf b/terraform.tf new file mode 100644 index 0000000000..8524443c32 --- /dev/null +++ b/terraform.tf @@ -0,0 +1,1110 @@ +# terraform.tf - Optimized Version with Merged Common Tags + +# ======================== +# 1. VARIABLES (Better organized) +# ======================== +variable "aws_access_key_id" { + description = "AWS access key ID" + type = string + sensitive = true +} + +variable "aws_secret_access_key" { + description = "AWS secret access key" + type = string + sensitive = true +} + +variable "github_token" { + description = "GitHub personal access token for OAuth" + type = string + sensitive = true +} + +variable "docker_access_token" { + description = "Docker Access Token" + type = string + sensitive = true +} + +variable "bedrock_config" { + description = "Bedrock configuration" + type = string + sensitive = true +} + +# Non-sensitive variables +variable "aws_region" { + description = "AWS region" + type = string + default = "us-east-1" + + validation { + condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]$", var.aws_region)) + error_message = "AWS region must be valid (e.g., us-east-1)." + } +} + +variable "project_name" { + description = "Project name for resource naming" + type = string + default = "bolt" + + validation { + condition = can(regex("^[a-z0-9-]+$", var.project_name)) + error_message = "Project name must contain only lowercase letters, numbers, and hyphens." + } +} + +variable "github_owner" { + description = "GitHub repository owner" + type = string +} + +variable "github_repo" { + description = "GitHub repository name" + type = string + default = "bolt" +} + +variable "github_branch" { + description = "Github branch that the build will use" + type = string + default = "main" +} + +variable "docker_username" { + description = "Docker username" + type = string +} + +variable "image_tag" { + description = "Docker image tag" + type = string + default = "development" +} + +variable "environment" { + description = "Environment name" + type = string + default = "dev" + + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "Environment must be one of: dev, staging, prod." + } +} + +variable "common_tags" { + description = "Common tags to be applied to all resources" + type = map(string) + default = { + Project = "Bolt AI" + Owner = "John Doe" + } +} + +# Container configuration +variable "container_cpu" { + description = "CPU units for the container (1024 = 1 vCPU)" + type = number + default = 2048 + + validation { + condition = contains([256, 512, 1024, 2048, 4096], var.container_cpu) + error_message = "CPU must be one of: 256, 512, 1024, 2048, 4096." + } +} + +variable "container_memory" { + description = "Memory in MiB for the container" + type = number + default = 4096 +} + +variable "desired_count" { + description = "Number of ECS tasks to run" + type = number + default = 2 + + validation { + condition = var.desired_count > 0 && var.desired_count <= 10 + error_message = "Desired count must be between 1 and 10." + } +} + +# ======================== +# 2. LOCALS (Enhanced) +# ======================== +locals { + bolt_port = 5173 + + # Use variable for common tags + common_tags = merge(var.common_tags, { + Environment = var.environment + ManagedBy = "Terraform" + LastModified = timestamp() + }) + + # Naming convention + name_prefix = "${var.project_name}-${var.environment}" + + # Network configuration + vpc_cidr = "10.0.0.0/16" + azs = slice(data.aws_availability_zones.available.names, 0, 2) + + public_subnets = [ + for i, az in local.azs : cidrsubnet(local.vpc_cidr, 8, i + 1) + ] +} + +# ======================== +# 3. TERRAFORM & PROVIDER CONFIG +# ======================== +terraform { + required_version = ">= 1.5" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + random = { + source = "hashicorp/random" + version = "~> 3.5" + } + } + +} + +provider "aws" { + region = var.aws_region + access_key = var.aws_access_key_id + secret_key = var.aws_secret_access_key +} + +# ======================== +# 4. DATA SOURCES +# ======================== +data "aws_caller_identity" "current" {} + +data "aws_availability_zones" "available" { + state = "available" +} + +# ======================== +# 5. RANDOM RESOURCES +# ======================== +resource "random_id" "bucket_suffix" { + byte_length = 4 +} + +# ======================== +# 6. PARAMETER STORE +# ======================== +resource "aws_ssm_parameter" "secrets" { + for_each = { + github-token = var.github_token + docker-username = var.docker_username + docker-access-token = var.docker_access_token + } + + name = "/${local.name_prefix}/${each.key}" + type = "SecureString" + value = each.value + description = "${each.key} for ${var.project_name}" + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-high-cpu-alarm" + }) +} + +# ======================== +# 7. VPC RESOURCES +# ======================== +resource "aws_vpc" "main" { + cidr_block = local.vpc_cidr + enable_dns_hostnames = true + enable_dns_support = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-vpc" + }) +} + +resource "aws_internet_gateway" "main" { + vpc_id = aws_vpc.main.id + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-igw" + }) +} + +resource "aws_subnet" "public" { + count = length(local.azs) + + vpc_id = aws_vpc.main.id + cidr_block = local.public_subnets[count.index] + availability_zone = local.azs[count.index] + map_public_ip_on_launch = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-public-subnet-${count.index + 1}" + Type = "Public" + }) +} + +resource "aws_route_table" "public" { + vpc_id = aws_vpc.main.id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.main.id + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-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 +} + +# ======================== +# 8. SECURITY GROUPS +# ======================== +resource "aws_security_group" "alb" { + name_prefix = "${local.name_prefix}-alb-" + description = "Security group for ALB" + vpc_id = aws_vpc.main.id + + ingress { + description = "HTTP" + from_port = 80 + to_port = 80 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + ingress { + description = "HTTPS" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "All outbound" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alb-sg" + }) + + lifecycle { + create_before_destroy = true + } +} + +resource "aws_security_group" "ecs_tasks" { + name_prefix = "${local.name_prefix}-ecs-tasks-" + description = "Allow inbound access from the ALB only" + vpc_id = aws_vpc.main.id + + ingress { + description = "Access from ALB" + from_port = local.bolt_port + to_port = local.bolt_port + protocol = "tcp" + security_groups = [aws_security_group.alb.id] + } + + egress { + description = "All outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecs-tasks-sg" + }) + + lifecycle { + create_before_destroy = true + } +} + +# ======================== +# 9. LOAD BALANCER +# ======================== +resource "aws_lb" "main" { + name = "${local.name_prefix}-alb" + internal = false + load_balancer_type = "application" + security_groups = [aws_security_group.alb.id] + subnets = aws_subnet.public[*].id + + enable_deletion_protection = false + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alb" + }) +} + +resource "aws_lb_target_group" "app" { + name = "${local.name_prefix}-tg" + port = local.bolt_port + protocol = "HTTP" + vpc_id = aws_vpc.main.id + target_type = "ip" + + health_check { + enabled = true + healthy_threshold = 2 + interval = 30 + matcher = "200" + path = "/" + port = "traffic-port" + protocol = "HTTP" + timeout = 10 + unhealthy_threshold = 3 + } + + # Improved stickiness configuration + stickiness { + type = "lb_cookie" + cookie_duration = 86400 + enabled = true + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-tg" + }) +} + +resource "aws_lb_listener" "front_end" { + load_balancer_arn = aws_lb.main.arn + port = "80" + protocol = "HTTP" + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.app.arn + } +} + +# ======================== +# 10. ECR +# ======================== +resource "aws_ecr_repository" "app_repo" { + name = "${var.github_repo}-${var.environment}" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + encryption_configuration { + encryption_type = "AES256" + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecr" + }) +} + +# ECR Lifecycle Policy +resource "aws_ecr_lifecycle_policy" "app_repo_policy" { + repository = aws_ecr_repository.app_repo.name + + policy = jsonencode({ + rules = [ + { + rulePriority = 1 + description = "Keep last 5 production images" + selection = { + tagStatus = "tagged" + tagPrefixList = ["production"] + countType = "imageCountMoreThan" + countNumber = 5 + } + action = { + type = "expire" + } + }, + { + rulePriority = 2 + description = "Keep last 3 development images" + selection = { + tagStatus = "tagged" + tagPrefixList = ["development"] + countType = "imageCountMoreThan" + countNumber = 3 + } + action = { + type = "expire" + } + } + ] + }) +} + +# ======================== +# 11. S3 BUCKET +# ======================== +resource "aws_s3_bucket" "codepipeline_artifacts" { + bucket = "${local.name_prefix}-artifacts-${random_id.bucket_suffix.hex}" + force_destroy = true + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-artifacts" + }) +} + +resource "aws_s3_bucket_versioning" "codepipeline_artifacts_versioning" { + bucket = aws_s3_bucket.codepipeline_artifacts.id + versioning_configuration { + status = "Enabled" + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "codepipeline_artifacts_encryption" { + bucket = aws_s3_bucket.codepipeline_artifacts.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_public_access_block" "codepipeline_artifacts_pab" { + bucket = aws_s3_bucket.codepipeline_artifacts.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# Add lifecycle configuration +resource "aws_s3_bucket_lifecycle_configuration" "codepipeline_artifacts_lifecycle" { + bucket = aws_s3_bucket.codepipeline_artifacts.id + + rule { + id = "cleanup_old_artifacts" + status = "Enabled" + + filter { + prefix = "" # applies to all objects + } + + + expiration { + days = 30 + } + + noncurrent_version_expiration { + noncurrent_days = 7 + } + } +} + +# ======================== +# 12. ECS CLUSTER +# ======================== +resource "aws_ecs_cluster" "main" { + name = "${local.name_prefix}-cluster" + + setting { + name = "containerInsights" + value = "enabled" + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-cluster" + }) +} + +resource "aws_ecs_cluster_capacity_providers" "main" { + cluster_name = aws_ecs_cluster.main.name + + capacity_providers = ["FARGATE", "FARGATE_SPOT"] + + default_capacity_provider_strategy { + base = 1 + weight = 100 + capacity_provider = "FARGATE" + } +} + +# ======================== +# 13. CLOUDWATCH LOG GROUP +# ======================== +resource "aws_cloudwatch_log_group" "app" { + name = "/ecs/${local.name_prefix}" + retention_in_days = var.environment == "production" ? 30 : 7 + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-log-group" + }) +} + +# ======================== +# 14. ECS TASK DEFINITION +# ======================== +resource "aws_ecs_task_definition" "app" { + family = "${local.name_prefix}-task" + execution_role_arn = aws_iam_role.ecs_task_execution_role.arn + task_role_arn = aws_iam_role.ecs_task_role.arn + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + cpu = var.container_cpu + memory = var.container_memory + + container_definitions = jsonencode([ + { + name = var.project_name + image = "${aws_ecr_repository.app_repo.repository_url}:${var.image_tag}" + + portMappings = [ + { + containerPort = local.bolt_port + protocol = "tcp" + } + ] + + # can add ENV variables like Bedrock config and so on + + environment = [ + { + name = "NODE_ENV" + value = var.environment + } + ] + + essential = true + + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.app.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "ecs" + } + } + } + ]) + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-task" + }) +} + +# ======================== +# 15. ECS SERVICE +# ======================== +resource "aws_ecs_service" "main" { + name = "${local.name_prefix}-service" + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.app.arn + desired_count = var.desired_count + launch_type = "FARGATE" + + network_configuration { + security_groups = [aws_security_group.ecs_tasks.id] + subnets = aws_subnet.public[*].id + assign_public_ip = true + } + + load_balancer { + target_group_arn = aws_lb_target_group.app.arn + container_name = var.project_name + container_port = local.bolt_port + } + + depends_on = [aws_lb_listener.front_end] + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-service" + }) +} + +# Auto Scaling +resource "aws_appautoscaling_target" "ecs_target" { + max_capacity = 10 + min_capacity = var.desired_count + resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.main.name}" + scalable_dimension = "ecs:service:DesiredCount" + service_namespace = "ecs" +} + +resource "aws_appautoscaling_policy" "ecs_policy_cpu" { + name = "${local.name_prefix}-cpu-scaling" + policy_type = "TargetTrackingScaling" + resource_id = aws_appautoscaling_target.ecs_target.resource_id + scalable_dimension = aws_appautoscaling_target.ecs_target.scalable_dimension + service_namespace = aws_appautoscaling_target.ecs_target.service_namespace + + target_tracking_scaling_policy_configuration { + predefined_metric_specification { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + target_value = 80.0 + } +} + +# ======================== +# 16. IAM ROLES +# ======================== + +# ECS Task Execution Role +resource "aws_iam_role" "ecs_task_execution_role" { + name = "${local.name_prefix}-ecs-execution-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecs-execution-role" + }) +} + +# ECS Task Role (for application permissions) +resource "aws_iam_role" "ecs_task_role" { + name = "${local.name_prefix}-ecs-task-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + } + ] + }) + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-ecs-task-role" + }) +} + +# Attach managed policies +resource "aws_iam_role_policy_attachment" "ecs_task_execution_role_policy" { + role = aws_iam_role.ecs_task_execution_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +# Custom execution role policy +resource "aws_iam_role_policy" "ecs_task_execution_role_custom" { + name = "${local.name_prefix}-ecs-execution-custom" + role = aws_iam_role.ecs_task_execution_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "ssm:GetParameters", + "ssm:GetParameter" + ] + Resource = [ + for key in keys(aws_ssm_parameter.secrets) : aws_ssm_parameter.secrets[key].arn + ] + } + ] + }) +} + +# ======================== +# 17. CODEBUILD & CODEPIPELINE +# ======================== +resource "aws_iam_role" "codepipeline_role" { + name = "${local.name_prefix}-codepipeline-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "codepipeline.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy" "codepipeline_policy" { + name = "${local.name_prefix}-codepipeline-policy" + role = aws_iam_role.codepipeline_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "s3:GetBucketVersioning", + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject" + ] + Resource = [ + aws_s3_bucket.codepipeline_artifacts.arn, + "${aws_s3_bucket.codepipeline_artifacts.arn}/*" + ] + }, + { + Effect = "Allow" + Action = [ + "codebuild:BatchGetBuilds", + "codebuild:StartBuild" + ] + Resource = aws_codebuild_project.app_build.arn + }, + { + Effect = "Allow" + Action = [ + "ecs:UpdateService" + ] + Resource = aws_ecs_service.main.id + } + ] + }) +} + +resource "aws_iam_role" "codebuild_role" { + name = "${local.name_prefix}-codebuild-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "codebuild.amazonaws.com" + } + } + ] + }) +} + +resource "aws_iam_role_policy" "codebuild_policy" { + name = "${local.name_prefix}-codebuild-policy" + role = aws_iam_role.codebuild_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:*" + }, + { + Effect = "Allow" + Action = [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject" + ] + Resource = [ + aws_s3_bucket.codepipeline_artifacts.arn, + "${aws_s3_bucket.codepipeline_artifacts.arn}/*" + ] + }, + { + Effect = "Allow" + Action = [ + "ecr:*" + ] + Resource = "*" + }, + { + Effect = "Allow" + Action = [ + "ecs:UpdateService" + ] + Resource = aws_ecs_service.main.id + }, + { + Effect = "Allow" + Action = [ + "ssm:GetParameter", + "ssm:GetParameters" + ] + Resource = [ + for key in keys(aws_ssm_parameter.secrets) : aws_ssm_parameter.secrets[key].arn + ] + } + ] + }) +} + +# ======================== +# 18. CODEBUILD PROJECT +# ======================== +resource "aws_codebuild_project" "app_build" { + name = "${local.name_prefix}-build" + description = "Build project for ${var.github_repo}" + service_role = aws_iam_role.codebuild_role.arn + + artifacts { + type = "CODEPIPELINE" + } + + cache { + type = "LOCAL" + modes = ["LOCAL_DOCKER_LAYER_CACHE"] + } + + environment { + compute_type = "BUILD_GENERAL1_MEDIUM" + image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0" + type = "LINUX_CONTAINER" + image_pull_credentials_type = "CODEBUILD" + privileged_mode = true + + dynamic "environment_variable" { + for_each = { + AWS_DEFAULT_REGION = var.aws_region + AWS_ACCOUNT_ID = data.aws_caller_identity.current.account_id + IMAGE_REPO_NAME = aws_ecr_repository.app_repo.name + ECS_CLUSTER_NAME = aws_ecs_cluster.main.name + ECS_SERVICE_NAME = aws_ecs_service.main.name + IMAGE_TAG = var.image_tag + ENVIRONMENT = var.environment + } + + content { + name = environment_variable.key + type = "PLAINTEXT" + value = environment_variable.value + } + } + + dynamic "environment_variable" { + for_each = { + DOCKER_HUB_USERNAME = aws_ssm_parameter.secrets["docker-username"].name + DOCKER_HUB_ACCESS_TOKEN = aws_ssm_parameter.secrets["docker-access-token"].name + GITHUB_TOKEN = aws_ssm_parameter.secrets["github-token"].name + } + + content { + name = environment_variable.key + type = "PARAMETER_STORE" + value = environment_variable.value + } + } + } + + source { + type = "CODEPIPELINE" + buildspec = "buildspec.yml" + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-build" + }) +} + +# ======================== +# 19. CODEPIPELINE +# ======================== +resource "aws_codepipeline" "app_pipeline" { + name = "${local.name_prefix}-pipeline" + role_arn = aws_iam_role.codepipeline_role.arn + + artifact_store { + location = aws_s3_bucket.codepipeline_artifacts.bucket + type = "S3" + } + + stage { + name = "Source" + + action { + name = "Source" + category = "Source" + owner = "ThirdParty" + provider = "GitHub" + version = "1" + output_artifacts = ["source_output"] + + configuration = { + Owner = var.github_owner + Repo = var.github_repo + Branch = var.github_branch + OAuthToken = var.github_token + } + } + } + + stage { + name = "Build" + + action { + name = "Build" + category = "Build" + owner = "AWS" + provider = "CodeBuild" + input_artifacts = ["source_output"] + output_artifacts = ["build_output"] + version = "1" + + configuration = { + ProjectName = aws_codebuild_project.app_build.name + } + } + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-pipeline" + }) +} + +# ======================== +# 20. CLOUDWATCH ALARMS +# ======================== +resource "aws_cloudwatch_metric_alarm" "high_cpu" { + alarm_name = "${local.name_prefix}-high-cpu" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = "2" + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + period = "300" + statistic = "Average" + threshold = "80" + alarm_description = "This metric monitors ECS CPU utilization" + alarm_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.main.name + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-high-cpu-alarm" + }) +} + +resource "aws_cloudwatch_metric_alarm" "high_memory" { + alarm_name = "${local.name_prefix}-high-memory" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = "2" + metric_name = "MemoryUtilization" + namespace = "AWS/ECS" + period = "300" + statistic = "Average" + threshold = "80" + alarm_description = "This metric monitors ECS memory utilization" + alarm_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + ClusterName = aws_ecs_cluster.main.name + ServiceName = aws_ecs_service.main.name + } + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-high-memory-alarm" + }) + +} + +resource "aws_cloudwatch_metric_alarm" "unhealthy_targets" { + alarm_name = "${local.name_prefix}-unhealthy-targets" + comparison_operator = "LessThanThreshold" + evaluation_periods = "2" + metric_name = "HealthyHostCount" + namespace = "AWS/ApplicationELB" + period = "300" + statistic = "Average" + threshold = "1" + alarm_description = "This metric monitors healthy targets in target group" + alarm_actions = [aws_sns_topic.alerts.arn] + + dimensions = { + TargetGroup = aws_lb_target_group.app.arn_suffix + LoadBalancer = aws_lb.main.arn_suffix + } + + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-unhealthy-targets-alarm" + }) + + +} + +# ======================== +# 21. SNS TOPIC FOR ALERTS +# ======================== +resource "aws_sns_topic" "alerts" { + name = "${local.name_prefix}-alerts" + + + tags = merge(local.common_tags, { + Name = "${local.name_prefix}-alerts" + }) + +} + + + +# ======================== +# 22. OUTPUTS (Enhanced) +# ======================== + + +output "ecr_repository_url" { + description = "ECR repository URL" + value = aws_ecr_repository.app_repo.repository_url +} + +output "load_balancer_url" { + description = "Load balancer URL" + value = "http://${aws_lb.main.dns_name}" +} + +output "environment" { + description = "Environment name" + value = var.environment +} + +output "aws_region" { + description = "AWS region" + value = var.aws_region +} \ No newline at end of file diff --git a/terraform.tfvars.example b/terraform.tfvars.example new file mode 100644 index 0000000000..9de4a2a2c7 --- /dev/null +++ b/terraform.tfvars.example @@ -0,0 +1,46 @@ +# ======================== +# SENSITIVE VARIABLES +# ======================== + +aws_access_key_id = +aws_secret_access_key = +github_token = +docker_access_token = + +# ======================== +# PROJECT CONFIGURATION +# ======================== +project_name = "bolt" +aws_region = "us-east-1" +image_tag = "production" +environment = "dev" # Options: dev, staging, prod + + +# ======================== +# GITHUB CONFIGURATION +# ======================== +github_owner = "gafitescu" +github_repo = "bolt" +github_branch = "codebuild-ecr-to-ecs" + +# ======================== +# DOCKER CONFIGURATION +# ======================== +docker_username = "gafitescu" + +# ======================== +# CONTAINER CONFIGURATION +# ======================== +container_cpu = 2048 # CPU units (1024 = 1 vCPU) +container_memory = 4096 # Memory in MiB +desired_count = 2 # Number of ECS tasks + +# ======================== +# COMMON TAGS +# ======================== +common_tags = { + Owner = "John Doe" + Team = "DevOps" + CostCenter = "engineering" + Application = "bolt-ai" +} \ No newline at end of file