diff --git a/.github/workflows/ecr-publish.yml b/.github/workflows/ecr-publish.yml index da628ac12..5073c76d0 100644 --- a/.github/workflows/ecr-publish.yml +++ b/.github/workflows/ecr-publish.yml @@ -4,6 +4,7 @@ on: push: branches: - 'main' + - 'integration' jobs: ecr-publish: @@ -25,7 +26,13 @@ jobs: - name: Build and push Docker image run: | - docker build -t ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/microhealthllc/bo:latest . + # Build image from docker/app/Dockerfile + docker build \ + -f docker/app/Dockerfile \ + -t ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/microhealthllc/bo:latest \ + docker/app + + # Push image docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/microhealthllc/bo:latest - name: Force ECS Service Update diff --git a/config/puma.rb b/config/puma.rb index 085b2c299..7dbe8668e 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,49 +1,33 @@ -require 'yaml' +# frozen_string_literal: true +require "yaml" # Load optional YAML config -if File.exist?('./puma_config.yml') - yaml_data = YAML.load_file('./puma_config.yml') - yaml_data.each do |key, value| - ENV[key] = value if value - end +if File.exist?("./puma_config.yml") + yaml_data = YAML.safe_load(File.read("./puma_config.yml")) || {} + yaml_data.each { |k, v| ENV[k.to_s] = v.to_s if v } end -rails_env = ENV.fetch('RAILS_ENV') -puma_port = Integer(ENV.fetch('PUMA_PORT')) +rails_env = ENV.fetch("RAILS_ENV", "production") +# Support either PUMA_PORT or PORT, fallback to 8443 +puma_port = Integer(ENV["PUMA_PORT"] || ENV["PORT"] || 8443) -max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) -min_threads_count = ENV.fetch('RAILS_MIN_THREADS', max_threads_count) +max_threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS", 5)) +min_threads_count = Integer(ENV.fetch("RAILS_MIN_THREADS", max_threads_count)) -workers Integer(ENV.fetch('WEB_CONCURRENCY', 2)) +workers Integer(ENV.fetch("WEB_CONCURRENCY", 2)) threads min_threads_count, max_threads_count environment rails_env -# Optional logging for debug -puts "[Puma] ENV PUMA_PORT=#{puma_port}" -puts "[Puma] ENV RAILS_ENV=#{rails_env}" - -if rails_env == 'production' - pidfile ENV.fetch('PUMA_PIDFILE') - - # Commented out SSL bind since Nginx handles TLS - # begin - # ssl_bind( - # ENV.fetch('PUMA_SSL_HOST'), - # puma_port, - # key: ENV.fetch('PUMA_SSL_KEY_FILE'), - # cert: ENV.fetch('PUMA_SSL_CERT_FILE'), - # verify_mode: ENV.fetch('PUMA_SSL_VERIFY_MODE') - # ) - # puts "[Puma] SSL bind successful on #{ENV['PUMA_SSL_HOST']}:#{puma_port}" - # rescue KeyError => e - # warn "[Puma] Missing SSL ENV variable: #{e.message}" - # exit 1 - # end - - port puma_port -else - port puma_port -end + +STDOUT.puts "[Puma] ENV PORT=#{ENV['PORT']}" +STDOUT.puts "[Puma] ENV PUMA_PORT=#{ENV['PUMA_PORT']}" +STDOUT.puts "[Puma] RAILS_ENV=#{rails_env}" + +# PID file +pidfile ENV.fetch("PUMA_PIDFILE", File.join(Dir.pwd, "tmp/pids/server.pid")) + +# We’re terminating TLS at ALB +bind "tcp://0.0.0.0:#{puma_port}" plugin :tmp_restart diff --git a/deployment/deployment.drawio b/deployment/deployment.drawio new file mode 100644 index 000000000..a1730df71 --- /dev/null +++ b/deployment/deployment.drawio @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/deployment/ecs.zip b/deployment/ecs.zip new file mode 100644 index 000000000..b3e80cc6f Binary files /dev/null and b/deployment/ecs.zip differ diff --git a/deployment/ecs/.gitignore b/deployment/ecs/.gitignore new file mode 100644 index 000000000..4260bbcff --- /dev/null +++ b/deployment/ecs/.gitignore @@ -0,0 +1,41 @@ +.DS_Store +*.zip +.sql +# Local .terraform directories +**/.terraform/* + +.terraform* +.terraform.lock.hcl +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc + + diff --git a/deployment/ecs/backend-prod.hcl b/deployment/ecs/backend-prod.hcl index b4c509bea..87355e40b 100644 --- a/deployment/ecs/backend-prod.hcl +++ b/deployment/ecs/backend-prod.hcl @@ -1,5 +1,5 @@ -bucket = "healthmetricsai-pub-terraform-state" -key = "state/terraform.tfstate" +bucket = "mpath-terraform-state" +key = "mpath/ecs/vpc/terraform.tfstate" region = "us-east-1" encrypt = true -dynamodb_table = "healthmetricsai-pub-terraform-state" \ No newline at end of file +use_lockfile = true \ No newline at end of file diff --git a/deployment/ecs/envs/bo/backend.hcl b/deployment/ecs/envs/bo/backend.hcl new file mode 100644 index 000000000..2a44389ee --- /dev/null +++ b/deployment/ecs/envs/bo/backend.hcl @@ -0,0 +1,5 @@ +bucket = "mpath-terraform-state" +key = "mpath/ecs/bo/terraform.tfstate" +region = "us-east-1" +encrypt = true +use_lockfile = true diff --git a/deployment/ecs/envs/bo/main.tf b/deployment/ecs/envs/bo/main.tf new file mode 100644 index 000000000..bed591789 --- /dev/null +++ b/deployment/ecs/envs/bo/main.tf @@ -0,0 +1,100 @@ +terraform { + required_providers { + aws = { source = "hashicorp/aws", version = "~> 5.0" } + } + backend "s3" {} # init with: terraform init -backend-config=backend.hcl +} + +provider "aws" { region = var.aws_region } + +locals { + app_name = "mpath" + env = "bo" +} + +# Pull shared network from the ROOT stack +data "terraform_remote_state" "root" { + backend = "s3" + config = { + bucket = "mpath-terraform-state" + key = "mpath/root/terraform.tfstate" + region = var.aws_region + encrypt = true + } +} + +locals { + vpc_id = data.terraform_remote_state.root.outputs.vpc_id + private_subnet_ids = data.terraform_remote_state.root.outputs.private_subnet_ids + public_subnet_ids = data.terraform_remote_state.root.outputs.public_subnet_ids + + tags = { + Project = local.app_name + Environment = local.env + ManagedBy = "Terraform" + } +} + +module "ecs_service" { + source = "../../modules/ecs" + + + cluster_name = "${local.app_name}-${local.env}" + service_name = "${local.app_name}-app-${local.env}" + + vpc_id = local.vpc_id + subnet_ids = local.private_subnet_ids # ECS tasks -> private subnets + + # Container / service + container_image = var.container_image + container_port = var.container_port # set to 8443 in terraform.tfvars + desired_count = var.desired_count + cpu = var.cpu + memory = var.memory + health_check_path = var.health_check_path + + # Create ALB/TG/listeners *inside* the module (per-env ALB) + create_alb = true + public_subnet_ids = local.public_subnet_ids + alb_name = "${local.app_name}-${local.env}-alb" + alb_deletion_protection = true + acm_certificate_arn = var.acm_certificate_arn + ssl_policy = var.ssl_policy + + # Ops & deployments + platform_version = var.platform_version + deployment_maximum_percent = var.deployment_maximum_percent + deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent + deployment_circuit_breaker_enabled = var.deployment_circuit_breaker_enabled + deployment_circuit_breaker_rollback = var.deployment_circuit_breaker_rollback + container_insights_enabled = true + log_retention_days = var.log_retention_days + assign_public_ip = false + + tags = local.tags +} + +output "bo_alb_dns_name" { + value = module.ecs_service.alb_dns_name +} + +output "bo_target_group_arn" { + value = module.ecs_service.target_group_arn_effective +} + +output "bo_ecs_service_sg" { + value = module.ecs_service.security_group_id +} + +# Discover this env’s ALB (created by module.ecs_service) +data "aws_lb" "bo_alb" { + name = "${local.app_name}-${local.env}-alb" + depends_on = [module.ecs_service] +} + +# Associate the root WAF to this ALB +resource "aws_wafv2_web_acl_association" "mpath_web_acl_assoc" { + resource_arn = data.aws_lb.bo_alb.arn + web_acl_arn = data.terraform_remote_state.root.outputs.waf_web_acl_arn +} + diff --git a/deployment/ecs/envs/bo/terraform.tfvars b/deployment/ecs/envs/bo/terraform.tfvars new file mode 100644 index 000000000..cffd85b93 --- /dev/null +++ b/deployment/ecs/envs/bo/terraform.tfvars @@ -0,0 +1,39 @@ +waf_alb_arns = [ + aws_lb.mpath_production_alb.arn +] + +aws_region = "us-east-1" +environment = "bo" # your env code uses local.env = "bo" + +# Container / service +container_image = "295669632222.dkr.ecr.us-east-1.amazonaws.com/microhealthllc/mpath-bo:latest" +container_port = 8443 +desired_count = 2 +cpu = 1024 +memory = 2048 + +# Healthcheck +health_check_path = "/health" + +# TLS / ALB +acm_certificate_arn = "" # ACM ARN +ssl_policy = "ELBSecurityPolicy-TLS-1-2-2017-01" +alb_deletion_protection = true + +# ECS deployment knobs +platform_version = "LATEST" +deployment_maximum_percent = 200 +deployment_minimum_healthy_percent = 50 +deployment_circuit_breaker_enabled = true +deployment_circuit_breaker_rollback = true +log_retention_days = 30 + + +waf_allowed_countries = ["US"] + +# Tags +tags = { + Owner = "DevOps Team" + CostCenter = "Engineering" + Application = "mpath" +} diff --git a/deployment/ecs/envs/bo/variables.tf b/deployment/ecs/envs/bo/variables.tf new file mode 100644 index 000000000..b58c58285 --- /dev/null +++ b/deployment/ecs/envs/bo/variables.tf @@ -0,0 +1,30 @@ +variable "aws_region" { type = string } + +# Container / service +variable "container_image" { type = string } +variable "container_port" { type = number default = 8443 } # app listens on 8443 +variable "desired_count" { type = number default = 2 } +variable "cpu" { type = number default = 512 } +variable "memory" { type = number default = 1024 } +variable "health_check_path" { type = string default = "/health" } + +# TLS for ALB +variable "acm_certificate_arn" { type = string } +variable "ssl_policy" { type = string default = "ELBSecurityPolicy-TLS-1-2-2017-01" } + +# deployments +variable "log_retention_days" { type = number default = 30 } +variable "platform_version" { type = string default = "LATEST" } +variable "deployment_maximum_percent" { type = number default = 200 } +variable "deployment_minimum_healthy_percent"{ type = number default = 50 } +variable "deployment_circuit_breaker_enabled"{ type = bool default = true } +variable "deployment_circuit_breaker_rollback"{ type = bool default = true } + +variable "tags" { + type = map(string) + default = {} +} + +variable "aws_region" { + default = "us-east-1" +} \ No newline at end of file diff --git a/deployment/ecs/main.tf b/deployment/ecs/main.tf index c24c9499b..6bb1ccf23 100644 --- a/deployment/ecs/main.tf +++ b/deployment/ecs/main.tf @@ -1,27 +1,18 @@ terraform { required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - } - backend "s3" { - # Reference backend-prod.hcl (Command: terraform init -backend-config=backend-prod.hcl) + aws = { source = "hashicorp/aws", version = "~> 5.0" } } + backend "s3" {} # init with -backend-config=backend-prod.hcl } -provider "aws" { - region = var.aws_region -} +provider "aws" { region = var.aws_region } -# Local values locals { - app_name = "healthmetricsai" - + app_name = "mpath" common_tags = merge(var.tags, { - Project = "HealthMetricsAI" - Environment = var.environment - ManagedBy = "Terraform" + Project = "mpath" + Environment = "shared-network" + ManagedBy = "Terraform" }) } @@ -30,346 +21,91 @@ resource "aws_vpc" "main" { cidr_block = var.vpc_cidr_block enable_dns_hostnames = true enable_dns_support = true - - tags = merge(local.common_tags, { - Name = var.vpc_name - }) + tags = merge(local.common_tags, { Name = var.vpc_name }) } -# Internet Gateway +# IGW resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id - - tags = merge(local.common_tags, { - Name = var.internet_gateway_name - }) + tags = merge(local.common_tags, { Name = var.internet_gateway_name }) } -# Public Subnets +# Public subnets resource "aws_subnet" "public" { count = length(var.availability_zones) vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(var.vpc_cidr_block, 4, count.index) availability_zone = var.availability_zones[count.index] map_public_ip_on_launch = true - tags = merge(local.common_tags, { Name = "${var.vpc_name}-public-${count.index + 1}" - Type = "Public" + Tier = "public" }) } -# Private Subnets +# Private subnets resource "aws_subnet" "private" { count = length(var.availability_zones) vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(var.vpc_cidr_block, 4, count.index + 10) availability_zone = var.availability_zones[count.index] - tags = merge(local.common_tags, { Name = "${var.vpc_name}-private-${count.index + 1}" - Type = "Private" + Tier = "private" }) } -# Elastic IP for NAT Gateway +# NAT (single-AZ cost saver) resource "aws_eip" "nat" { domain = "vpc" - - tags = merge(local.common_tags, { - Name = "${var.nat_gateway_name}-eip" - }) - + tags = merge(local.common_tags, { Name = "${var.nat_gateway_name}-eip" }) depends_on = [aws_internet_gateway.main] } -# NAT Gateway resource "aws_nat_gateway" "main" { allocation_id = aws_eip.nat.id subnet_id = aws_subnet.public[0].id - - tags = merge(local.common_tags, { - Name = var.nat_gateway_name - }) - - depends_on = [aws_internet_gateway.main] + tags = merge(local.common_tags, { Name = var.nat_gateway_name }) + depends_on = [aws_internet_gateway.main] } -# Route Table for Public Subnets +# 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.main.id - } - - tags = merge(local.common_tags, { - Name = "${var.vpc_name}-public-rt" - }) + route { cidr_block = "0.0.0.0/0"; gateway_id = aws_internet_gateway.main.id } + tags = merge(local.common_tags, { Name = "${var.vpc_name}-public-rt" }) } -# Route Table for Private Subnets 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.main.id - } - - tags = merge(local.common_tags, { - Name = "${var.vpc_name}-private-rt" - }) + route { cidr_block = "0.0.0.0/0"; nat_gateway_id = aws_nat_gateway.main.id } + tags = merge(local.common_tags, { Name = "${var.vpc_name}-private-rt" }) } -# Associate Public Subnets with Public Route Table 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 } -# Associate Private Subnets with Private Route Table 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 } -# Security Group for RDS -resource "aws_security_group" "rds" { - name_prefix = "${local.app_name}-rds-" - vpc_id = aws_vpc.main.id - description = "Security group for RDS database" - - ingress { - from_port = 5432 - to_port = 5432 - protocol = "tcp" - security_groups = [module.ecs.security_group_id] - description = "PostgreSQL access from ECS" - } - - # Add ingress rule for private subnet CIDR blocks as backup - ingress { - from_port = 5432 - to_port = 5432 - protocol = "tcp" - cidr_blocks = [for subnet in aws_subnet.private : subnet.cidr_block] - description = "PostgreSQL access from private subnets" - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - description = "All outbound traffic" - } - - tags = merge(local.common_tags, { - Name = "${local.app_name}-rds-sg" - }) - - lifecycle { - create_before_destroy = true - } -} - -# Security Group for ALB -resource "aws_security_group" "alb" { - name_prefix = "${local.app_name}-alb-" - vpc_id = aws_vpc.main.id - description = "Security group for Application Load Balancer" - - ingress { - from_port = 443 - to_port = 443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - description = "HTTPS access from internet" - } - - ingress { - from_port = 80 - to_port = 80 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - description = "HTTP access from internet (redirects to HTTPS)" - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - description = "All outbound traffic" - } - - tags = merge(local.common_tags, { - Name = "${local.app_name}-alb-sg" - }) - - lifecycle { - create_before_destroy = true - } -} - -# Update ECS Security Group to allow ALB access -resource "aws_security_group_rule" "ecs_alb_ingress" { - type = "ingress" - from_port = 7860 - to_port = 7860 - protocol = "tcp" - source_security_group_id = aws_security_group.alb.id - security_group_id = module.ecs.security_group_id - description = "Allow ALB to access ECS service" -} - -# Application Load Balancer -resource "aws_lb" "healthmetricsai_production_alb" { - name = "${local.app_name}-${var.environment}-alb" - internal = false - load_balancer_type = "application" - security_groups = [aws_security_group.alb.id] - subnets = aws_subnet.public[*].id - - enable_deletion_protection = var.alb_deletion_protection - - tags = merge(local.common_tags, { - Name = "${local.app_name}-${var.environment}-alb" - }) -} - -# Target Group for ECS Service -resource "aws_lb_target_group" "ecs_tg" { - name = "${local.app_name}-${var.environment}-tg" - port = 7860 - protocol = "HTTP" - vpc_id = aws_vpc.main.id - target_type = "ip" - - health_check { - enabled = true - healthy_threshold = 2 - unhealthy_threshold = 2 - timeout = 5 - interval = 30 - path = var.ecs_health_check_path - matcher = "200" - port = "traffic-port" - protocol = "HTTP" - } - - tags = merge(local.common_tags, { - Name = "${local.app_name}-${var.environment}-tg" - }) -} - -# HTTPS Listener -resource "aws_lb_listener" "https" { - load_balancer_arn = aws_lb.healthmetricsai_production_alb.arn - port = "443" - protocol = "HTTPS" - ssl_policy = var.alb_ssl_policy - certificate_arn = var.certificate_arn - - default_action { - type = "forward" - - forward { - target_group { - arn = aws_lb_target_group.ecs_tg.arn - } - } - } - - tags = local.common_tags -} - -# HTTP Listener (redirect to HTTPS) -resource "aws_lb_listener" "http" { - load_balancer_arn = aws_lb.healthmetricsai_production_alb.arn - port = "80" - protocol = "HTTP" - - default_action { - type = "redirect" - - redirect { - port = "443" - protocol = "HTTPS" - status_code = "HTTP_301" - } - } - - tags = local.common_tags +# Outputs (env stacks will read these) +output "vpc_id" { + description = "Shared VPC ID" + value = aws_vpc.main.id } -# ECS Deployment -module "ecs" { - source = "./modules/ecs" - - cluster_name = "${local.app_name}-${var.environment}" - service_name = "${local.app_name}-service" - container_image = "${var.aws_account_id}.dkr.ecr.${var.aws_region}.amazonaws.com/microhealthllc/healthmetricsai:${var.container_image_tag}" - container_port = 7860 - desired_count = var.desired_count - cpu = var.cpu - memory = var.memory - - vpc_id = aws_vpc.main.id - subnet_ids = aws_subnet.private[*].id # Changed to private subnets - - environment_variables = { - DATABASE_URL = "postgresql://${var.postgres_username}:${var.postgres_password}@${module.rds.db_instance_endpoint}/${var.postgres_database}" - SECRET_KEY = var.secret_key - BW_ORG_ID = var.bw_org_id - BW_ACCESS_TOKEN = var.bw_access_token - BW_PROJECT_ID = var.bw_project_id - } - - # ECS Configuration Variables - log_retention_days = var.ecs_log_retention_days - health_check_enabled = var.ecs_health_check_enabled - health_check_path = var.ecs_health_check_path - assign_public_ip = false # Changed to false since we're in private subnets - platform_version = var.ecs_platform_version - deployment_maximum_percent = var.ecs_deployment_maximum_percent - deployment_minimum_healthy_percent = var.ecs_deployment_minimum_healthy_percent - deployment_circuit_breaker_enabled = var.ecs_deployment_circuit_breaker_enabled - deployment_circuit_breaker_rollback = var.ecs_deployment_circuit_breaker_rollback - container_insights_enabled = var.ecs_container_insights_enabled - - # ALB Target Group ARN for service discovery - target_group_arn = aws_lb_target_group.ecs_tg.arn - - tags = local.common_tags +output "private_subnet_ids" { + description = "Private subnet IDs for ECS tasks" + value = aws_subnet.private[*].id } -# RDS Database -module "rds" { - source = "./modules/rds_database" - - identifier = var.identifier - allocated_storage = var.rds_allocated_storage - instance_class = var.rds_instance_class - db_name = var.postgres_database - username = var.postgres_username - password = var.postgres_password - - private_subnet_ids = aws_subnet.private[*].id - vpc_security_group_ids = [aws_security_group.rds.id] - - enabled_cloudwatch_logs_exports = var.rds_enabled_cloudwatch_logs_exports - performance_insights_enabled = var.rds_performance_insights_enabled - performance_insights_retention_period = var.rds_performance_insights_retention_period - monitoring_interval = var.rds_monitoring_interval - - log_retention_in_days = 30 - - tags = local.common_tags +output "public_subnet_ids" { + description = "Public subnet IDs for ALBs" + value = aws_subnet.public[*].id } - - diff --git a/deployment/ecs/modules/ecs/main.tf b/deployment/ecs/modules/ecs/main.tf index 83e33f1d0..0870d1347 100644 --- a/deployment/ecs/modules/ecs/main.tf +++ b/deployment/ecs/modules/ecs/main.tf @@ -1,4 +1,12 @@ -# ECS Cluster +# Data sources +data "aws_region" "current" {} +data "aws_caller_identity" "current" {} + +# Toggle ALB creation inside this module +locals { + do_alb = var.create_alb +} + resource "aws_ecs_cluster" "main" { name = var.cluster_name @@ -10,15 +18,66 @@ resource "aws_ecs_cluster" "main" { tags = var.tags } -# CloudWatch Log Group resource "aws_cloudwatch_log_group" "app_logs" { name = "/ecs/${var.service_name}" retention_in_days = var.log_retention_days + tags = var.tags +} + +resource "aws_iam_role" "ecs_execution_role" { + name = "${var.service_name}-ecs-execution-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) tags = var.tags } -# ECS Task Definition +resource "aws_iam_role" "ecs_task_role" { + name = "${var.service_name}-ecs-task-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Action = "sts:AssumeRole", + Effect = "Allow", + Principal = { Service = "ecs-tasks.amazonaws.com" } + }] + }) + + tags = var.tags +} + +resource "aws_iam_role_policy_attachment" "ecs_execution_role_policy" { + role = aws_iam_role.ecs_execution_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" +} + +resource "aws_iam_role_policy" "ecs_execution_role_ecr_policy" { + name = "${var.service_name}-ecs-execution-ecr-policy" + role = aws_iam_role.ecs_execution_role.id + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Effect = "Allow", + Action = [ + "ecr:GetAuthorizationToken", + "ecr:BatchCheckLayerAvailability", + "ecr:GetDownloadUrlForLayer", + "ecr:BatchGetImage" + ], + Resource = "*" + }] + }) +} + resource "aws_ecs_task_definition" "app" { family = var.service_name requires_compatibilities = ["FARGATE"] @@ -26,17 +85,17 @@ resource "aws_ecs_task_definition" "app" { cpu = var.cpu memory = var.memory execution_role_arn = aws_iam_role.ecs_execution_role.arn - task_role_arn = aws_iam_role.ecs_task_role.arn + task_role_arn = aws_iam_role.ecs_task_role.arn container_definitions = jsonencode([ { - name = var.service_name - image = var.container_image + name = var.service_name + image = var.container_image essential = true - + portMappings = [ { - containerPort = var.container_port + containerPort = var.container_port # set to 8443 in env protocol = "tcp" } ] @@ -49,19 +108,19 @@ resource "aws_ecs_task_definition" "app" { ] logConfiguration = { - logDriver = "awslogs" + logDriver = "awslogs", options = { - awslogs-group = aws_cloudwatch_log_group.app_logs.name - awslogs-region = data.aws_region.current.name + awslogs-group = aws_cloudwatch_log_group.app_logs.name, + awslogs-region = data.aws_region.current.name, awslogs-stream-prefix = "ecs" } } healthCheck = var.health_check_enabled ? { - command = ["CMD-SHELL", "curl -f http://localhost:${var.container_port}${var.health_check_path} || exit 1"] - interval = 30 - timeout = 5 - retries = 3 + command = ["CMD-SHELL", "curl -f http://localhost:${var.container_port}${var.health_check_path} || exit 1"] + interval = 30 + timeout = 5 + retries = 3 startPeriod = 60 } : null } @@ -70,19 +129,38 @@ resource "aws_ecs_task_definition" "app" { tags = var.tags } -# Security Group for ECS Service + resource "aws_security_group" "ecs_service" { name_prefix = "${var.service_name}-ecs-" vpc_id = var.vpc_id description = "Security group for ${var.service_name} ECS service" - ingress { - from_port = var.container_port - to_port = var.container_port - protocol = "tcp" - cidr_blocks = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"] + # Allow from any explicitly provided source SGs (e.g., ALB SG) + dynamic "ingress" { + for_each = var.allowed_source_sg_ids + content { + from_port = var.container_port + to_port = var.container_port + protocol = "tcp" + security_groups = [ingress.value] + description = "Allowed SG -> ECS container port" + } } + # If the ALB is created in this module, also allow from that ALB SG + dynamic "ingress" { + for_each = local.do_alb ? [aws_security_group.alb[0].id] : [] + content { + from_port = var.container_port + to_port = var.container_port + protocol = "tcp" + security_groups = [ingress.value] + description = "Module ALB SG -> ECS container port" + } + } + + + egress { from_port = 0 to_port = 0 @@ -90,43 +168,133 @@ resource "aws_security_group" "ecs_service" { cidr_blocks = ["0.0.0.0/0"] } - tags = merge(var.tags, { - Name = "${var.service_name}-ecs-sg" - }) + tags = merge(var.tags, { Name = "${var.service_name}-ecs-sg" }) + + lifecycle { create_before_destroy = true } +} + + +# ALB SG +resource "aws_security_group" "alb" { + count = local.do_alb ? 1 : 0 + name_prefix = "${var.service_name}-alb-" + vpc_id = var.vpc_id + description = "ALB SG for ${var.service_name}" + + 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"] } + egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } + + tags = merge(var.tags, { Name = "${var.service_name}-alb-sg" }) +} + +# ALB +resource "aws_lb" "this" { + count = local.do_alb ? 1 : 0 + name = coalesce(var.alb_name, "${var.service_name}-alb") + internal = false + load_balancer_type = "application" + security_groups = local.do_alb ? [aws_security_group.alb[0].id] : null + subnets = local.do_alb ? var.public_subnet_ids : null + enable_deletion_protection = var.alb_deletion_protection + tags = var.tags +} + +# Target Group (ALB -> ECS tasks) +# Note: default to HTTP to the container on var.container_port (8443). +# If your app speaks TLS on 8443, change protocol to "HTTPS" and add a cert on the task or use TLS termination differently. +resource "aws_lb_target_group" "ecs" { + count = local.do_alb ? 1 : 0 + name = "${var.service_name}-tg" + port = var.container_port + protocol = "HTTP" + vpc_id = var.vpc_id + target_type = "ip" + + health_check { + enabled = true + healthy_threshold = 2 + unhealthy_threshold = 2 + timeout = 5 + interval = 30 + path = var.health_check_path + matcher = "200" + port = "traffic-port" + protocol = "HTTP" + } + + tags = var.tags +} - lifecycle { - create_before_destroy = true +# HTTPS listener (443) with TLS termination +resource "aws_lb_listener" "https" { + count = local.do_alb ? 1 : 0 + load_balancer_arn = aws_lb.this[0].arn + port = 443 + protocol = "HTTPS" + ssl_policy = var.ssl_policy + certificate_arn = var.acm_certificate_arn + + default_action { + type = "forward" + target_group_arn = aws_lb_target_group.ecs[0].arn } + + tags = var.tags +} + +# HTTP -> HTTPS redirect +resource "aws_lb_listener" "http" { + count = local.do_alb ? 1 : 0 + load_balancer_arn = aws_lb.this[0].arn + port = 80 + protocol = "HTTP" + + default_action { + type = "redirect" + redirect { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + + tags = var.tags +} + + +locals { + effective_tg_arn = var.target_group_arn != null ? var.target_group_arn : + (local.do_alb ? aws_lb_target_group.ecs[0].arn : null) } -# ECS Service resource "aws_ecs_service" "app" { - name = var.service_name - cluster = aws_ecs_cluster.main.id - task_definition = aws_ecs_task_definition.app.arn - desired_count = var.desired_count - launch_type = "FARGATE" + name = var.service_name + cluster = aws_ecs_cluster.main.id + task_definition = aws_ecs_task_definition.app.arn + desired_count = var.desired_count + launch_type = "FARGATE" platform_version = var.platform_version network_configuration { - subnets = var.subnet_ids + subnets = var.subnet_ids # private subnets security_groups = [aws_security_group.ecs_service.id] assign_public_ip = var.assign_public_ip } deployment_maximum_percent = var.deployment_maximum_percent deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent - + deployment_circuit_breaker { enable = var.deployment_circuit_breaker_enabled rollback = var.deployment_circuit_breaker_rollback } - # Load Balancer Configuration + # Attach to TG if provided/created dynamic "load_balancer" { - for_each = var.target_group_arn != null ? [1] : [] + for_each = local.effective_tg_arn != null ? [1] : [] content { - target_group_arn = var.target_group_arn + target_group_arn = local.effective_tg_arn container_name = var.service_name container_port = var.container_port } @@ -138,75 +306,3 @@ resource "aws_ecs_service" "app" { aws_iam_role_policy_attachment.ecs_execution_role_policy ] } - -# IAM Role for ECS Execution -resource "aws_iam_role" "ecs_execution_role" { - name = "${var.service_name}-ecs-execution-role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "ecs-tasks.amazonaws.com" - } - } - ] - }) - - tags = var.tags -} - -# IAM Role for ECS Task -resource "aws_iam_role" "ecs_task_role" { - name = "${var.service_name}-ecs-task-role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "sts:AssumeRole" - Effect = "Allow" - Principal = { - Service = "ecs-tasks.amazonaws.com" - } - } - ] - }) - - tags = var.tags -} - -# Attach AWS managed policy for ECS execution -resource "aws_iam_role_policy_attachment" "ecs_execution_role_policy" { - role = aws_iam_role.ecs_execution_role.name - policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" -} - -# Additional policy for ECS execution role to pull from ECR -resource "aws_iam_role_policy" "ecs_execution_role_ecr_policy" { - name = "${var.service_name}-ecs-execution-ecr-policy" - role = aws_iam_role.ecs_execution_role.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Effect = "Allow" - Action = [ - "ecr:GetAuthorizationToken", - "ecr:BatchCheckLayerAvailability", - "ecr:GetDownloadUrlForLayer", - "ecr:BatchGetImage" - ] - Resource = "*" - } - ] - }) -} - -# Data sources -data "aws_region" "current" {} -data "aws_caller_identity" "current" {} \ No newline at end of file diff --git a/deployment/ecs/modules/ecs/outputs.tf b/deployment/ecs/modules/ecs/outputs.tf index 92bbe6871..7dfc0acb7 100644 --- a/deployment/ecs/modules/ecs/outputs.tf +++ b/deployment/ecs/modules/ecs/outputs.tf @@ -8,6 +8,11 @@ output "cluster_arn" { value = aws_ecs_cluster.main.arn } +output "cluster_name" { + description = "Name of the ECS cluster" + value = aws_ecs_cluster.main.name +} + output "service_id" { description = "ID of the ECS service" value = aws_ecs_service.app.id @@ -18,13 +23,18 @@ output "service_arn" { value = "arn:aws:ecs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}" } +output "service_name" { + description = "Name of the ECS service" + value = aws_ecs_service.app.name +} + output "task_definition_arn" { description = "ARN of the task definition" value = aws_ecs_task_definition.app.arn } output "security_group_id" { - description = "ID of the ECS service security group" + description = "ECS tasks security group ID" value = aws_security_group.ecs_service.id } @@ -48,12 +58,34 @@ output "log_group_arn" { value = aws_cloudwatch_log_group.app_logs.arn } -output "cluster_name" { - description = "Name of the ECS cluster" - value = aws_ecs_cluster.main.name + +output "alb_security_group_id" { + description = "ALB security group ID (null if ALB not created in-module)" + value = try(aws_security_group.alb[0].id, null) } -output "service_name" { - description = "Name of the ECS service" - value = aws_ecs_service.app.name -} \ No newline at end of file +output "alb_arn" { + description = "ALB ARN (null if ALB not created in-module)" + value = try(aws_lb.this[0].arn, null) +} + +output "alb_dns_name" { + description = "ALB DNS name (null if ALB not created in-module)" + value = try(aws_lb.this[0].dns_name, null) +} + +output "alb_zone_id" { + description = "Route53 zone ID for ALB alias (null if ALB not created in-module)" + value = try(aws_lb.this[0].zone_id, null) +} + +output "target_group_arn_created" { + description = "ARN of the TG created by the module (null if not created)" + value = try(aws_lb_target_group.ecs[0].arn, null) +} + +# Effective TG used by the service (created or provided via var.target_group_arn) +output "target_group_arn_effective" { + description = "Target Group ARN actually used by the ECS service" + value = local.effective_tg_arn +} diff --git a/deployment/ecs/modules/ecs/variables.tf b/deployment/ecs/modules/ecs/variables.tf index 8ab29a933..7dfa4fc25 100644 --- a/deployment/ecs/modules/ecs/variables.tf +++ b/deployment/ecs/modules/ecs/variables.tf @@ -16,7 +16,7 @@ variable "container_image" { variable "container_port" { description = "Port the container exposes" type = number - default = 7860 + default = 8443 } variable "desired_count" { @@ -43,7 +43,7 @@ variable "vpc_id" { } variable "subnet_ids" { - description = "Subnet IDs for the ECS service" + description = "PRIVATE subnet IDs for ECS tasks" type = list(string) } @@ -80,7 +80,7 @@ variable "health_check_path" { variable "assign_public_ip" { description = "Assign public IP to ECS tasks" type = bool - default = true + default = false } variable "platform_version" { @@ -119,8 +119,50 @@ variable "container_insights_enabled" { default = true } -variable "target_group_arn" { - description = "ARN of the load balancer target group" +variable "create_alb" { + description = "Create ALB/TG/listeners inside the module" + type = bool + default = false +} + +variable "public_subnet_ids" { + description = "PUBLIC subnet IDs for ALB (required if create_alb = true)" + type = list(string) + default = [] +} + +variable "acm_certificate_arn" { + description = "ACM certificate ARN for HTTPS listener (required if create_alb = true)" + type = string + default = "" +} + +variable "ssl_policy" { + description = "TLS policy for HTTPS listener" + type = string + default = "ELBSecurityPolicy-TLS-1-2-2017-01" +} + +variable "alb_name" { + description = "Name for the ALB (defaults to ${service_name}-alb)" type = string default = null -} \ No newline at end of file +} + +variable "alb_deletion_protection" { + description = "Enable deletion protection on the ALB" + type = bool + default = true +} + +variable "allowed_source_sg_ids" { + description = "Security group IDs allowed to reach the ECS tasks on container_port (e.g., ALB SG). If empty, no SG ingress is created (unless ALB is created in-module)." + type = list(string) + default = [] +} + + +validation { + condition = !(var.create_alb) || (length(var.public_subnet_ids) > 0 && length(var.acm_certificate_arn) > 0) + error_message = "When create_alb = true, you must provide public_subnet_ids and acm_certificate_arn." +} diff --git a/deployment/ecs/outputs.tf b/deployment/ecs/outputs.tf index 673aeaa48..5c3cf77fd 100644 --- a/deployment/ecs/outputs.tf +++ b/deployment/ecs/outputs.tf @@ -66,20 +66,29 @@ output "private_subnet_ids" { output "alb_dns_name" { description = "DNS name of the load balancer" - value = aws_lb.healthmetricsai_production_alb.dns_name + value = aws_lb.mpath_production_alb.dns_name } output "alb_zone_id" { description = "Zone ID of the load balancer" - value = aws_lb.healthmetricsai_production_alb.zone_id + value = aws_lb.mpath_production_alb.zone_id } output "alb_arn" { description = "ARN of the load balancer" - value = aws_lb.healthmetricsai_production_alb.arn + value = aws_lb.mpath_production_alb.arn } output "target_group_arn" { description = "ARN of the target group" value = aws_lb_target_group.ecs_tg.arn -} \ No newline at end of file +} + +# root/outputs.tf +output "waf_web_acl_arn" { + value = aws_wafv2_web_acl.mpath_web_acl.arn +} + +output "waf_web_acl_id" { + value = aws_wafv2_web_acl.mpath_web_acl.id +} diff --git a/deployment/ecs/terraform.tfvars b/deployment/ecs/terraform.tfvars index 1b53cb1f2..ac166f2e1 100644 --- a/deployment/ecs/terraform.tfvars +++ b/deployment/ecs/terraform.tfvars @@ -1,54 +1,18 @@ -# AWS Configuration -aws_account_id = "2" # Replace with your AWS account ID - -# Application Configuration -environment = "Production" -container_image_tag = "latest" - - -# ECS Configuration -desired_count = 2 -cpu = 1024 -memory = 2048 - -# Security Configuration -secret_key = "your-secure-secret-key-here" # Generate a secure key - -# AWS Region Configuration aws_region = "us-east-1" availability_zones = ["us-east-1a", "us-east-1b"] -# VPC Configuration +# VPC vpc_name = "mpath-production-vpc" vpc_cidr_block = "192.168.29.0/24" internet_gateway_name = "mpath-production-igw" nat_gateway_name = "mpath-production-nat-gateway" -# SSL Certificate -certificate_arn = "" +# WAF (only if WAF is defined in root/waf.tf) +waf_allowed_countries = ["US"] # Tags tags = { Owner = "DevOps Team" CostCenter = "Engineering" - Application = "HealthMetricsAI" + Application = "mpath" } - -# ECS Configuration -ecs_log_retention_days = 30 -ecs_health_check_enabled = true -ecs_health_check_path = "/health" -ecs_assign_public_ip = false -ecs_platform_version = "LATEST" -ecs_deployment_maximum_percent = 200 -ecs_deployment_minimum_healthy_percent = 50 -ecs_deployment_circuit_breaker_enabled = true -ecs_deployment_circuit_breaker_rollback = true -ecs_container_insights_enabled = true - -# ALB Configuration -alb_deletion_protection = true -alb_ssl_policy = "ELBSecurityPolicy-TLS-1-2-2017-01" - -# WAF Configuration -waf_allowed_countries = ["US"] \ No newline at end of file diff --git a/deployment/ecs/variables.tf b/deployment/ecs/variables.tf index a473f4d35..8b5f9dee9 100644 --- a/deployment/ecs/variables.tf +++ b/deployment/ecs/variables.tf @@ -29,7 +29,7 @@ variable "availability_zones" { variable "vpc_name" { description = "Name of the VPC" type = string - default = "healthmetricsai-production-vpc" + default = "mpath-production-vpc" validation { condition = length(var.vpc_name) > 0 && length(var.vpc_name) <= 255 @@ -50,7 +50,7 @@ variable "certificate_arn" { variable "internet_gateway_name" { description = "Name of the Internet Gateway" type = string - default = "healthmetricsai-production-igw" + default = "mpath-production-igw" validation { condition = length(var.internet_gateway_name) > 0 && length(var.internet_gateway_name) <= 255 @@ -61,7 +61,7 @@ variable "internet_gateway_name" { variable "nat_gateway_name" { description = "Name of the NAT Gateway" type = string - default = "healthmetricsai-production-nat-gateway" + default = "mpath-production-nat-gateway" validation { condition = length(var.nat_gateway_name) > 0 && length(var.nat_gateway_name) <= 255 @@ -287,4 +287,11 @@ variable "waf_allowed_countries" { description = "List of allowed country codes for WAF geo restriction" type = list(string) default = ["US"] -} \ No newline at end of file +} + +# using these in the name/tags: +variable "environment" { type = string } # e.g., "shared" if this WAF is reused +locals { + app_name = "mpath" # or from a var if you prefer + common_tags = { Project = "mpath", ManagedBy = "Terraform" } +} diff --git a/deployment/ecs/waf.tf b/deployment/ecs/waf.tf index 43286eac6..efd036347 100644 --- a/deployment/ecs/waf.tf +++ b/deployment/ecs/waf.tf @@ -1,150 +1,65 @@ -resource "aws_wafv2_web_acl" "healthmetricsai_production_web_acl" { +# Minimal, low-blocking WebACL +resource "aws_wafv2_web_acl" "mpath_web_acl" { name = "${var.environment}-${local.app_name}-web-acl" description = "${var.environment} ${local.app_name} WebACL" scope = "REGIONAL" - default_action { - allow {} - } + + default_action { allow {} } + rule { - name = "block-non-us-countries" + name = "block-non-allowed-countries" priority = 0 - statement { not_statement { statement { geo_match_statement { - country_codes = var.waf_allowed_countries + country_codes = var.waf_allowed_countries # e.g., ["US"] } } } } - - action { - block {} - } - + action { block {} } visibility_config { sampled_requests_enabled = true cloudwatch_metrics_enabled = true - metric_name = "block-non-us-countries" + metric_name = "block-non-allowed-countries" } } + # 2) Block known bad IPs (very low false-positive) rule { - name = "AWS-AWSManagedRulesCommonRuleSet" - priority = 3 - - statement { - managed_rule_group_statement { - vendor_name = "AWS" - name = "AWSManagedRulesCommonRuleSet" - - rule_action_override { - name = "SizeRestrictions_BODY" - action_to_use { - allow {} - } - } - rule_action_override { - name = "GenericLFI_BODY" - action_to_use { - allow {} - } - } - rule_action_override { - name = "GenericRFI_BODY" - action_to_use { - count {} - } - } - } - } - - override_action { - none {} - } - - visibility_config { - sampled_requests_enabled = true - cloudwatch_metrics_enabled = true - metric_name = "AWS-AWSManagedRulesCommonRuleSet" - } - } - rule { - name = "AWS-AWSManagedRulesKnownBadInputsRuleSet" - priority = 4 - override_action { - none { - } - } - statement { - managed_rule_group_statement { - name = "AWSManagedRulesKnownBadInputsRuleSet" - vendor_name = "AWS" - } - } - visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "AWS-AWSManagedRulesKnownBadInputsRuleSet" - sampled_requests_enabled = true - } - } - rule { - name = "AWS-AWSManagedRulesLinuxRuleSet" - priority = 5 - override_action { - none { - } - } + name = "AWS-AWSManagedRulesAmazonIpReputationList" + priority = 1 statement { managed_rule_group_statement { - name = "AWSManagedRulesLinuxRuleSet" + name = "AWSManagedRulesAmazonIpReputationList" vendor_name = "AWS" } } + override_action { none {} } # enforce block visibility_config { cloudwatch_metrics_enabled = true - metric_name = "AWS-AWSManagedRulesLinuxRuleSet" + metric_name = "AWS-AWSManagedRulesAmazonIpReputationList" sampled_requests_enabled = true } } + + # 3) Common rules in COUNT mode (observe first, then enforce later) rule { - name = "AWS-AWSManagedRulesUnixRuleSet" - priority = 6 - override_action { - none { - } - } + name = "AWS-AWSManagedRulesCommonRuleSet" + priority = 2 statement { managed_rule_group_statement { - name = "AWSManagedRulesUnixRuleSet" vendor_name = "AWS" + name = "AWSManagedRulesCommonRuleSet" } } + override_action { count {} } # low-risk: no blocking yet visibility_config { - cloudwatch_metrics_enabled = true - metric_name = "AWS-AWSManagedRulesUnixRuleSet" sampled_requests_enabled = true - } - } - rule { - name = "AWS-AWSManagedRulesAmazonIpReputationList" - priority = 7 - override_action { - none { - } - } - statement { - managed_rule_group_statement { - name = "AWSManagedRulesAmazonIpReputationList" - vendor_name = "AWS" - } - } - visibility_config { cloudwatch_metrics_enabled = true - metric_name = "AWS-AWSManagedRulesAmazonIpReputationList" - sampled_requests_enabled = true + metric_name = "AWS-AWSManagedRulesCommonRuleSet" } } @@ -156,8 +71,3 @@ resource "aws_wafv2_web_acl" "healthmetricsai_production_web_acl" { tags = local.common_tags } - -resource "aws_wafv2_web_acl_association" "healthmetricsai_production_web_acl_association" { - resource_arn = aws_lb.healthmetricsai_production_alb.arn - web_acl_arn = aws_wafv2_web_acl.healthmetricsai_production_web_acl.arn -} \ No newline at end of file diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile index b2446e790..83c9a52ee 100644 --- a/docker/app/Dockerfile +++ b/docker/app/Dockerfile @@ -4,20 +4,20 @@ FROM ruby:3.1.0 # Set the working directory inside the container WORKDIR /var/www/mPATH -# Install system dependencies, including gosu +# Install system dependencies, including gosu (no Nginx here to keep it clean) RUN apt-get update -qq && apt-get install -y \ curl dirmngr gnupg apt-transport-https ca-certificates \ software-properties-common \ - default-mysql-client libmariadb-dev nginx wget \ - && wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.16/gosu-amd64" \ - && chmod +x /usr/local/bin/gosu \ - && gosu nobody true \ - && rm -rf /var/lib/apt/lists/* + default-mysql-client libmariadb-dev wget \ + && wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.16/gosu-amd64" \ + && chmod +x /usr/local/bin/gosu \ + && gosu nobody true \ + && rm -rf /var/lib/apt/lists/* # Install Node.js (>=14.15.0) and Yarn -RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \ - apt-get install -y nodejs && \ - npm install -g yarn +RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \ + && apt-get install -y nodejs \ + && npm install -g yarn # Install Bundler RUN gem install bundler:2.3.26 @@ -28,41 +28,36 @@ RUN groupadd -r puma && useradd -r -g puma -d /var/www/mPATH puma # Copy the application source code COPY . . -# Set correct permissions on certs and fix ownership in one layer -RUN chown -R puma:puma /var/www/mPATH /usr/local/bundle && \ - chown -R puma:puma /tmp && chmod -R 755 /tmp - +# Set entrypoint permissions while still root +RUN chmod +x /var/www/mPATH/docker/app/entrypoint.sh +USER root +# Single ownership/permissions pass (no duplicates later) +RUN mkdir -p /var/www/mPATH/tmp/pids /var/www/mPATH/tmp/cache /var/www/mPATH/log \ + && chown -R puma:puma /var/www/mPATH /usr/local/bundle /tmp \ + && chmod -R 755 /tmp # Switch to the puma user for security USER puma -# Set Bundler configuration before installing gems -RUN bundle config set --local without 'development test' && \ - bundle install +# Bundler config and install (writes into /usr/local/bundle which puma now owns) +RUN bundle config set --local without 'development test' \ + && bundle install # Install JavaScript dependencies -RUN yarn install --silent +RUN yarn install --silent || true -# Ensure Webpacker/Shakapacker is installed +# Ensure Webpacker/Shakapacker is installed (no-op if already present) RUN NODE_ENV=production RAILS_ENV=production bundle exec rails webpacker:install || true RUN NODE_ENV=production RAILS_ENV=production bundle exec rails shakapacker:install || true -# Precompile Rails assets (including mh_logo.png) +# Precompile Rails assets (build-time placeholders only) RUN SECRET_KEY_BASE=placeholder \ DATABASE_URL=mysql2://mpath_user:mpath_pass@mysql/mpath \ RAILS_ENV=production \ bundle exec rake assets:clobber assets:precompile - -# Switch back to root for final setup -USER root - -# Final chown to ensure correct ownership -RUN chown -R puma:puma /var/www/mPATH /usr/local/bundle - -# Expose port for Puma +# Expose port for Puma (plain HTTP; TLS terminates at ALB) EXPOSE 8443 -# Set the entrypoint script with correct permissions -RUN chmod +x /var/www/mPATH/docker/app/entrypoint.sh +# Entrypoint ENTRYPOINT ["/bin/bash", "/var/www/mPATH/docker/app/entrypoint.sh"] diff --git a/docker/app/entrypoint.sh b/docker/app/entrypoint.sh index c73f97b4a..f86aa3cfa 100755 --- a/docker/app/entrypoint.sh +++ b/docker/app/entrypoint.sh @@ -1,54 +1,54 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +set -euo pipefail -echo "Starting application setup..." +APP_HOME="/var/www/mPATH" +PORT="${PORT:-8443}" -# Ensure we are in the correct directory -if [ -d "/var/www/mPATH" ]; then - cd /var/www/mPATH -elif [ -d "/app" ]; then - cd /app -else - echo "App directory not found! Exiting..." +echo "Starting mPATH (entrypoint) ..." +cd "$APP_HOME" + +# Require secrets (do not silently generate in prod) +if [[ -z "${SECRET_KEY_BASE:-}" ]]; then + echo "ERROR: SECRET_KEY_BASE is not set." exit 1 fi - -# Ensure SECRET_KEY_BASE is set -if [ -z "$SECRET_KEY_BASE" ]; then - echo "Generating SECRET_KEY_BASE..." - export SECRET_KEY_BASE=$(ruby -e 'require "securerandom"; puts SecureRandom.hex(64)') - if [ -f ".env" ]; then - echo "SECRET_KEY_BASE=$SECRET_KEY_BASE" >> .env - fi +if [[ -z "${RAILS_MASTER_KEY:-}" && ! -f "config/master.key" ]]; then + echo "ERROR: RAILS_MASTER_KEY not set and config/master.key not present." + exit 1 fi -# Install any missing gems -echo "Installing missing gems..." -bundle check || bundle install - -# Run database migrations -echo "Running database migrations..." -bundle exec rake db:migrate RAILS_ENV=$RAILS_ENV - -# Seed the database (optional: only in non-local environments if needed) -echo "Seeding database..." -bundle exec rake db:seed RAILS_ENV=$RAILS_ENV +# Optional: wait for DB (Lightsail) if requested +if [[ "${DB_WAIT:-0}" == "1" ]]; then + echo "Waiting for database (up to ${DB_WAIT_TIMEOUT:-60}s)..." + end=$((SECONDS + ${DB_WAIT_TIMEOUT:-60})) + while ! bundle exec ruby -e 'require "uri"; require "mysql2"; + u=URI(ENV["DATABASE_URL"]); + Mysql2::Client.new(host:u.host,port:(u.port||3306),username:u.user,password:u.password).close' >/dev/null 2>&1; do + [[ $SECONDS -ge $end ]] && { echo "DB not reachable in time"; exit 1; } + sleep 2 + done +fi -# Precompile assets if not already present -if ! ls public/packs/manifest.json &>/dev/null; then - echo "Precompiling assets..." - bundle exec rake assets:clobber - bundle exec rake assets:precompile RAILS_ENV=$RAILS_ENV +# Migrations (skip by default on steady-state ECS tasks) +if [[ "${SKIP_MIGRATIONS:-1}" != "1" ]]; then + echo "Running migrations..." + bundle exec rails db:migrate else - echo "Assets already precompiled." + echo "Skipping migrations (SKIP_MIGRATIONS=${SKIP_MIGRATIONS:-1})." fi -# Skip chown to avoid permission errors on mounted volumes -echo "Skipping chown: mounted volumes may restrict ownership change" - -# Still try to apply basic permissions, ignoring errors -chmod -R u+rwX,g+rwX,o-rwx /var/www/mPATH || echo "Warning: chmod failed" +# Seeds only when explicitly requested +if [[ "${RUN_SEEDS:-0}" == "1" ]]; then + echo "Seeding database (RUN_SEEDS=1)..." + bundle exec rails db:seed +else + echo "Skipping seeds." +fi -# Start Puma server as puma user -echo "Starting Puma server..." -exec gosu puma bin/rails server -b 0.0.0.0 -p $PUMA_PORT +echo "Booting Puma on 0.0.0.0:${PORT} (HTTP; TLS at ALB)..." +# We're already USER puma; gosu path is here only if this ever runs as root +if [[ "$(id -u)" -eq 0 ]]; then + exec gosu puma:puma bundle exec puma -C config/puma.rb -b tcp://0.0.0.0:${PORT} +else + exec bundle exec puma -C config/puma.rb +fi